feat: MeshCore SAR - Flutter BLE mesh radio companion app

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

2835
lib/l10n/app_de.arb Normal file

File diff suppressed because it is too large Load Diff

3793
lib/l10n/app_en.arb Normal file

File diff suppressed because it is too large Load Diff

2830
lib/l10n/app_es.arb Normal file

File diff suppressed because it is too large Load Diff

2838
lib/l10n/app_fr.arb Normal file

File diff suppressed because it is too large Load Diff

1106
lib/l10n/app_hr.arb Normal file

File diff suppressed because it is too large Load Diff

2838
lib/l10n/app_it.arb Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1110
lib/l10n/app_sl.arb Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
{}

326
lib/main.dart Normal file
View File

@@ -0,0 +1,326 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:geolocator/geolocator.dart';
import 'package:url_launcher/url_launcher.dart';
import 'providers/connection_provider.dart';
import 'providers/contacts_provider.dart';
import 'providers/messages_provider.dart';
import 'providers/map_provider.dart';
import 'providers/drawing_provider.dart';
import 'providers/channels_provider.dart';
import 'providers/app_provider.dart';
import 'services/tile_cache_service.dart';
import 'services/notification_service.dart';
import 'services/locale_preferences.dart';
import 'services/update_checker_service.dart';
import 'services/wizard_preferences.dart';
import 'screens/home_screen.dart';
import 'screens/welcome_wizard_screen.dart';
import 'theme/app_theme.dart';
import 'l10n/app_localizations.dart';
void main() {
runApp(const MeshCoreSarApp());
}
class MeshCoreSarApp extends StatefulWidget {
const MeshCoreSarApp({super.key});
@override
State<MeshCoreSarApp> createState() => _MeshCoreSarAppState();
}
class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
AppThemeMode _themeMode = AppThemeMode.system;
Locale? _locale;
bool _isInitialized = false;
bool _shouldShowPermissionDialog = false;
bool _wizardCompleted = true; // Will be updated in _initializeApp()
@override
void initState() {
super.initState();
_initializeApp();
}
Future<void> _initializeApp() async {
await _loadThemePreference();
await _loadLocalePreference();
// Check if welcome wizard has been completed
final wizardCompleted = await WizardPreferences.isWizardCompleted();
// Initialize notification service
await NotificationService().initialize();
// Set up notification tap handler for update notifications
NotificationService().onNotificationTapped = _handleNotificationTap;
// Check if we need to request location permissions
await _checkLocationPermissions();
// Check for app updates (Android only) - runs in background
// Shows notification if update is available
_checkForUpdates();
setState(() {
_wizardCompleted = wizardCompleted;
_isInitialized = true;
});
}
/// Handle notification tap
void _handleNotificationTap(String? payload) {
if (payload == null) return;
debugPrint('[Main] Notification tapped: $payload');
// Handle update notification tap
if (payload.startsWith('update:')) {
final downloadUrl = payload.substring(7); // Remove 'update:' prefix
_launchUpdateDownload(downloadUrl);
}
// SAR and message notifications handled by their respective providers
}
/// Launch update download URL
Future<void> _launchUpdateDownload(String downloadUrl) async {
try {
final url = Uri.parse(downloadUrl);
final canLaunch = await canLaunchUrl(url);
if (!canLaunch) {
debugPrint('[Main] Cannot open download URL: $downloadUrl');
return;
}
await launchUrl(url, mode: LaunchMode.externalApplication);
} catch (e) {
debugPrint('[Main] Error launching download URL: $e');
}
}
Future<void> _checkLocationPermissions() async {
try {
final permission = await Geolocator.checkPermission();
// Show dialog if permission is denied or not determined
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
_shouldShowPermissionDialog = true;
}
} catch (e) {
debugPrint('Error checking location permissions: $e');
}
}
Future<void> _loadThemePreference() async {
final prefs = await SharedPreferences.getInstance();
final themeName = prefs.getString('theme_mode') ?? 'system';
setState(() {
_themeMode = AppTheme.themeFromString(themeName);
});
}
Future<void> _loadLocalePreference() async {
final locale = await LocalePreferences.getLocale();
setState(() {
_locale = locale;
});
}
void _handleThemeChanged(AppThemeMode mode) {
setState(() {
_themeMode = mode;
});
}
void _handleLocaleChanged(Locale? locale) {
setState(() {
_locale = locale;
});
}
void _handleWizardCompleted() {
setState(() {
_wizardCompleted = true;
});
}
/// Check for app updates on Android only
/// Shows notification if update is available
Future<void> _checkForUpdates() async {
// Only check for updates on Android
if (!Platform.isAndroid) {
debugPrint('[UpdateChecker] Skipping update check (not Android)');
return;
}
try {
debugPrint('[UpdateChecker] Starting update check...');
final updateInfo = await UpdateCheckerService().checkForUpdate();
if (!updateInfo.isAvailable) {
debugPrint('[UpdateChecker] No update available');
return;
}
if (updateInfo.downloadUrl == null) {
debugPrint('[UpdateChecker] Update available but no download URL');
return;
}
debugPrint('[UpdateChecker] Update available! Showing notification...');
// Show notification (will be visible after app is initialized)
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (mounted) {
await NotificationService().showUpdateNotification(
currentVersion: updateInfo.currentCommitHash,
latestVersion: updateInfo.latestCommitHash ?? 'unknown',
downloadUrl: updateInfo.downloadUrl!,
localizations: null, // Will use English fallback
);
}
});
} catch (e) {
debugPrint('[UpdateChecker] Error checking for updates: $e');
}
}
@override
Widget build(BuildContext context) {
if (!_isInitialized) {
return const MaterialApp(
home: Scaffold(body: Center(child: CircularProgressIndicator())),
);
}
return MultiProvider(
providers: [
// Core providers
ChangeNotifierProvider(create: (_) => ConnectionProvider()),
ChangeNotifierProvider(
create: (_) {
// Initialize early to load persisted contacts for offline viewing
// Self-contact filtering will happen later when BLE connects
final provider = ContactsProvider();
provider.initializeEarly();
return provider;
},
),
ChangeNotifierProvider(
create: (_) {
final provider = MessagesProvider();
// Initialize messages provider asynchronously
provider.initialize();
return provider;
},
),
ChangeNotifierProvider(create: (_) => MapProvider()),
ChangeNotifierProvider(
create: (_) {
final provider = DrawingProvider();
// Initialize drawing provider asynchronously
provider.initialize();
return provider;
},
),
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
// Tile cache service
Provider(create: (_) => TileCacheService()),
// App provider that coordinates everything
ChangeNotifierProxyProvider6<
ConnectionProvider,
ContactsProvider,
MessagesProvider,
DrawingProvider,
ChannelsProvider,
TileCacheService,
AppProvider
>(
create: (context) => AppProvider(
connectionProvider: context.read<ConnectionProvider>(),
contactsProvider: context.read<ContactsProvider>(),
messagesProvider: context.read<MessagesProvider>(),
drawingProvider: context.read<DrawingProvider>(),
channelsProvider: context.read<ChannelsProvider>(),
tileCacheService: context.read<TileCacheService>(),
),
update:
(
context,
conn,
contacts,
messages,
drawings,
channels,
tileCache,
previous,
) =>
previous ??
AppProvider(
connectionProvider: conn,
contactsProvider: contacts,
messagesProvider: messages,
drawingProvider: drawings,
channelsProvider: channels,
tileCacheService: tileCache,
),
),
],
child: _buildMaterialApp(),
);
}
Widget _buildMaterialApp() {
return Builder(
builder: (context) {
final systemBrightness = MediaQuery.platformBrightnessOf(context);
final materialApp = MaterialApp(
key: ValueKey<String?>(
'${_locale?.languageCode ?? 'system'}_${_themeMode.name}',
),
title: 'MeshCore SAR',
debugShowCheckedModeBanner: false,
theme: AppTheme.getTheme(_themeMode, systemBrightness),
locale: _locale,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: LocalePreferences.supportedLocales,
home: _wizardCompleted
? HomeScreen(
onThemeChanged: _handleThemeChanged,
onLocaleChanged: _handleLocaleChanged,
currentTheme: _themeMode,
currentLocale: _locale,
shouldShowPermissionDialog: _shouldShowPermissionDialog,
)
: WelcomeWizardScreen(onCompleted: _handleWizardCompleted),
);
// Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36)
// iOS doesn't need SafeArea wrapping (causes extra black space at bottom)
if (Platform.isAndroid) {
return SafeArea(
left: false,
right: false,
top: false, // prevents black status bar background
child: materialApp,
);
}
return materialApp;
},
);
}
}

View File

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

View File

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

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

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

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

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

View File

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

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

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

View File

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

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

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

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

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,700 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'connection_provider.dart';
import 'contacts_provider.dart';
import 'messages_provider.dart';
import 'drawing_provider.dart';
import 'channels_provider.dart';
import '../services/tile_cache_service.dart';
import '../services/location_tracking_service.dart';
import '../models/contact.dart';
import '../models/message.dart';
import '../utils/drawing_message_parser.dart';
/// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier {
final ConnectionProvider connectionProvider;
final ContactsProvider contactsProvider;
final MessagesProvider messagesProvider;
final DrawingProvider drawingProvider;
final ChannelsProvider channelsProvider;
final TileCacheService tileCacheService;
final LocationTrackingService locationTrackingService = LocationTrackingService();
bool _isInitialized = false;
bool get isInitialized => _isInitialized;
bool _isSimpleMode = true;
bool get isSimpleMode => _isSimpleMode;
bool _isMapEnabled = true;
bool get isMapEnabled => _isMapEnabled;
AppProvider({
required this.connectionProvider,
required this.contactsProvider,
required this.messagesProvider,
required this.drawingProvider,
required this.channelsProvider,
required this.tileCacheService,
}) {
_setupCallbacks();
_initializeTileCache();
_initializeLocationTracking();
_loadSimpleMode();
_loadMapEnabled();
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load
_isInitialized = true;
}
/// Sync drawings from messages on app startup (before BLE connection)
Future<void> _syncDrawingsOnStartup() async {
// Wait for MessagesProvider to finish initializing
// DrawingProvider loads around the same time
int attempts = 0;
while (!messagesProvider.isInitialized && attempts < 20) {
await Future.delayed(const Duration(milliseconds: 50));
attempts++;
}
// Give DrawingProvider a moment to finish loading too
await Future.delayed(const Duration(milliseconds: 100));
debugPrint('🔄 [AppProvider] Early sync: syncing drawings from messages...');
messagesProvider.syncDrawingsWithProvider(drawingProvider);
}
/// Load simple mode setting from shared preferences
Future<void> _loadSimpleMode() async {
try {
final prefs = await SharedPreferences.getInstance();
_isSimpleMode = prefs.getBool('simple_mode') ?? true;
notifyListeners();
} catch (e) {
debugPrint('Error loading simple mode setting: $e');
}
}
/// Toggle simple mode on/off
Future<void> toggleSimpleMode(bool enabled) async {
try {
_isSimpleMode = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('simple_mode', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving simple mode setting: $e');
}
}
/// Load map enabled setting from shared preferences
Future<void> _loadMapEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isMapEnabled = prefs.getBool('map_enabled') ?? true;
notifyListeners();
} catch (e) {
debugPrint('Error loading map enabled setting: $e');
}
}
/// Toggle map on/off
Future<void> toggleMapEnabled(bool enabled) async {
try {
_isMapEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('map_enabled', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving map enabled setting: $e');
}
}
/// Initialize tile cache service
Future<void> _initializeTileCache() async {
try {
await tileCacheService.initialize();
debugPrint('Tile cache initialized');
} catch (e) {
debugPrint('Error initializing tile cache: $e');
}
}
/// Initialize location tracking service
Future<void> _initializeLocationTracking() async {
try {
// Initialize location tracking with BLE service
await locationTrackingService.initialize(connectionProvider.bleService);
// Setup callbacks
locationTrackingService.onPositionUpdate = (position) {
debugPrint('📍 [AppProvider] Position updated: ${position.latitude}, ${position.longitude}');
};
locationTrackingService.onBroadcastSent = (position) {
debugPrint('📡 [AppProvider] Position broadcast to mesh network');
};
locationTrackingService.onError = (error) {
debugPrint('❌ [AppProvider] Location tracking error: $error');
};
locationTrackingService.onTrackingStateChanged = (isTracking) {
debugPrint('🔄 [AppProvider] Location tracking state: ${isTracking ? "started" : "stopped"}');
};
debugPrint('✅ [AppProvider] Location tracking service initialized');
} catch (e) {
debugPrint('❌ [AppProvider] Error initializing location tracking: $e');
}
}
/// Setup callbacks between providers
void _setupCallbacks() {
// Monitor connection state changes to start/stop location tracking
connectionProvider.addListener(_handleConnectionStateChange);
// When a contact is received from BLE
connectionProvider.onContactReceived = (contact) {
// Pass device public key to filter out our own contact
contactsProvider.addOrUpdateContact(
contact,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
// Broadcast to SSE clients if server is running
connectionProvider.broadcastContactToSseClients(contact);
};
// When all contacts are received
connectionProvider.onContactsComplete = (contacts) {
// Pass device public key to filter out our own contact
contactsProvider.addContacts(
contacts,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
debugPrint('Received ${contacts.length} contacts');
// Broadcast all contacts to SSE clients if server is running
for (final contact in contacts) {
connectionProvider.broadcastContactToSseClients(contact);
}
};
// Setup callback for ConnectionProvider to query channel info
connectionProvider.getChannelInfo = (int channelIdx) {
return channelsProvider.getChannel(channelIdx);
};
// When channel info is received
connectionProvider.onChannelInfoReceived = (int channelIdx, String channelName, Uint8List secret, int? flags) {
try {
debugPrint('🔔 [AppProvider] onChannelInfoReceived called: idx=$channelIdx, name="$channelName"');
// Check if this is a channel deletion (empty name)
if (channelName.isEmpty && channelIdx != 0) {
debugPrint(' 🗑️ Channel $channelIdx deleted - removing from providers');
// Remove from ChannelsProvider
channelsProvider.removeChannel(channelIdx);
debugPrint(' ✅ Removed from ChannelsProvider');
// Remove from ContactsProvider using pseudo public key
final publicKeyBytes = Uint8List(32);
publicKeyBytes[0] = 0xFF; // Special marker for channels
publicKeyBytes[1] = channelIdx; // Channel index
final publicKeyHex = publicKeyBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
contactsProvider.removeContact(publicKeyHex);
debugPrint(' ✅ Removed from ContactsProvider');
return;
}
// Add/update in ChannelsProvider
channelsProvider.addOrUpdateChannel(
index: channelIdx,
name: channelName,
secret: secret,
flags: flags,
);
debugPrint(' ✅ Added to ChannelsProvider');
// Also add as Contact to ContactsProvider (for UI display)
// Skip if it's public channel (already exists)
debugPrint('📻 [AppProvider] Channel $channelIdx: "$channelName" (isEmpty: ${channelName.isEmpty}, isHashChannel: ${channelName.startsWith('#')})');
if (channelName.isNotEmpty && channelIdx != 0) {
debugPrint(' ✅ Adding channel $channelIdx to ContactsProvider as Contact');
// Create a pseudo public key for the channel based on its index
// Use channel index as a unique identifier (pad to 32 bytes)
final publicKeyBytes = Uint8List(32);
publicKeyBytes[0] = 0xFF; // Special marker for channels
publicKeyBytes[1] = channelIdx; // Channel index
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
contactsProvider.addOrUpdateContact(
Contact(
publicKey: publicKeyBytes,
type: ContactType.channel,
flags: flags ?? 0,
outPathLen: -1, // Flood mode for channels
outPath: Uint8List(0), // Empty path for channels
advName: channelName,
lastAdvert: now,
advLat: 0, // Channels don't have location
advLon: 0,
lastMod: now,
isNew: false, // Don't mark channels as new
),
);
debugPrint(' ✅ Channel contact added. Total channels in ContactsProvider: ${contactsProvider.channels.length}');
} else {
debugPrint(' ⏭️ Skipping channel $channelIdx (empty: ${channelName.isEmpty}, isPublic: ${channelIdx == 0})');
}
} catch (e, stackTrace) {
debugPrint('❌ [AppProvider] Error in onChannelInfoReceived: $e');
debugPrint(' Stack trace: $stackTrace');
}
};
// When a message is received
connectionProvider.onMessageReceived = (message) {
// Enrich message with sender name from contacts first
Message enrichedMessage = message;
if (message.senderPublicKeyPrefix != null && message.senderName == null) {
final contact = contactsProvider
.findContactByKey(message.senderPublicKeyPrefix!);
if (contact != null) {
enrichedMessage = message.copyWith(senderName: contact.advName);
}
}
// Check if message is a drawing broadcast
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
debugPrint('🎨 [AppProvider] Drawing message received, parsing...');
// Extract sender name from message packet metadata
final senderName = enrichedMessage.senderName ?? 'unknown';
final drawing = DrawingMessageParser.parseDrawingMessage(
enrichedMessage.text,
senderName: senderName,
messageId: enrichedMessage.id, // Pass message ID for navigation linking
);
if (drawing != null) {
debugPrint('🎨 [AppProvider] Drawing parsed successfully: ${drawing.type.name} from ${drawing.senderName ?? "unknown"}');
debugPrint(' Drawing linked to message ID: ${enrichedMessage.id}');
drawingProvider.addReceivedDrawing(drawing);
// Update message to mark as drawing and link to drawing ID
final updatedMessage = enrichedMessage.copyWith(
isDrawing: true,
drawingId: drawing.id,
);
// Add the drawing message to chat with drawing metadata
// This allows users to click on the drawing message to navigate to it
messagesProvider.addMessage(
updatedMessage,
contactLookup: (name) => '',
);
// Broadcast drawing message to SSE clients if server is running
connectionProvider.broadcastMessageToSseClients(updatedMessage);
} else {
debugPrint('⚠️ [AppProvider] Failed to parse drawing message');
}
return;
}
// Pass contact lookup function to link channel messages with contacts
messagesProvider.addMessage(
enrichedMessage,
contactLookup: (name) {
// Find contact by name and return their public key hex (first 12 chars for 6 bytes)
try {
final contact = contactsProvider.contacts.firstWhere(
(c) => c.advName == name,
);
return contact.publicKeyHex.isNotEmpty && contact.publicKeyHex.length >= 12
? contact.publicKeyHex.substring(0, 12)
: '';
} catch (e) {
// No matching contact found
return '';
}
},
);
// Broadcast message to SSE clients if server is running
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
};
// When telemetry is received via PUSH_CODE_TELEMETRY_RESPONSE (0x8B)
// Used by older firmware versions for telemetry responses
connectionProvider.onTelemetryReceived = (publicKey, lppData) {
debugPrint('📊 [AppProvider] Telemetry response (0x8B) received - updating contact');
contactsProvider.updateTelemetry(publicKey, lppData);
};
// When binary response is received via PUSH_CODE_BINARY_RESPONSE (0x8C)
// Used by newer firmware versions for telemetry and other binary data
// BOTH callbacks (0x8B and 0x8C) must be handled for device compatibility
connectionProvider.onBinaryResponse = (publicKeyPrefix, tag, responseData) {
debugPrint('📊 [AppProvider] Binary response (0x8C) received - updating contact telemetry');
// Binary response tag 0 = telemetry data (Cayenne LPP format)
// Other tags may be used for different data types in the future
contactsProvider.updateTelemetry(publicKeyPrefix, responseData);
};
// When a contact's routing path is updated in the mesh network
connectionProvider.onPathUpdated = (publicKey) {
debugPrint('🔄 [AppProvider] Path updated for contact: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...');
// Trigger a single contact fetch to get the updated path information
// This is much more efficient than fetching all contacts
// This happens asynchronously to avoid blocking the event handler
Future.delayed(const Duration(milliseconds: 100), () {
if (connectionProvider.deviceInfo.isConnected) {
connectionProvider.getContact(publicKey);
}
});
};
// When an advertisement is received (PUSH_CODE_ADVERT 0x80)
// This may be sent by the radio for existing contacts instead of PUSH_CODE_NEW_ADVERT (0x8A)
connectionProvider.onAdvertReceived = (publicKey) {
debugPrint('📡 [AppProvider] Advertisement received: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...');
// Check if this is an existing contact that might have updated location
final contact = contactsProvider.findContactByKey(publicKey);
if (contact != null) {
debugPrint(' Existing contact "${contact.advName}" - fetching updated contact info (optimized)');
// Trigger a single contact fetch to get the updated contact information
// This is much more efficient than fetching all contacts
Future.delayed(const Duration(milliseconds: 100), () {
if (connectionProvider.deviceInfo.isConnected) {
connectionProvider.getContact(publicKey);
}
});
} else {
debugPrint(' New contact - waiting for PUSH_CODE_NEW_ADVERT (0x8A) with full details');
}
};
// When a message is sent (RESP_CODE_SENT received)
connectionProvider.onMessageSent = (messageId, expectedAckTag, suggestedTimeoutMs) {
debugPrint('📤 [AppProvider] Message sent - Message ID: $messageId, ACK tag: $expectedAckTag');
messagesProvider.markMessageSent(messageId, expectedAckTag, suggestedTimeoutMs);
};
// When a message is delivered (PUSH_CODE_SEND_CONFIRMED received)
connectionProvider.onMessageDelivered = (ackCode, roundTripTimeMs) {
debugPrint('✅ [AppProvider] Message delivered - ACK: $ackCode, RTT: ${roundTripTimeMs}ms');
messagesProvider.markMessageDelivered(ackCode, roundTripTimeMs);
};
// When an echo is detected for a public channel message (PUSH_CODE_LOG_RX_DATA matched)
connectionProvider.onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) {
debugPrint('🔊 [AppProvider] Echo detected - Message: $messageId, Count: $echoCount');
messagesProvider.handleMessageEcho(messageId, echoCount, snrRaw, rssiDbm);
};
// Wire up MessagesProvider's sendMessageCallback for retry logic
messagesProvider.sendMessageCallback = ({
required contactPublicKey,
required text,
required messageId,
required contact,
retryAttempt = 0,
}) async {
return await connectionProvider.sendTextMessage(
contactPublicKey: contactPublicKey,
text: text,
messageId: messageId,
contact: contact,
retryAttempt: retryAttempt,
);
};
}
/// Initialize the app (load contacts, sync time, etc.)
Future<void> initialize() async {
if (!connectionProvider.deviceInfo.isConnected) return;
try {
// Initialize contacts provider with device public key to exclude self
// If already initialized (from early load), this will just filter out self-contact
// This must happen before getContacts to ensure proper filtering
await contactsProvider.initialize(
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
// Note: Device clock is automatically synced during connection in MeshCoreBleService
// No need to sync it again here
// Get battery and storage information
await connectionProvider.getBatteryAndStorage();
// Load contacts
await connectionProvider.getContacts();
// Small delay to ensure contacts are fully loaded
await Future.delayed(const Duration(milliseconds: 500));
// Sync channels to get channel names
// In simple mode: only sync first 5 channels for faster startup
// In normal mode: sync all channels (up to device max)
final channelsToSync = _isSimpleMode ? 5 : null;
debugPrint('📻 [AppProvider] Syncing channels${_isSimpleMode ? ' (simple mode: max 5)' : ''}...');
await connectionProvider.syncChannels(maxChannels: channelsToSync);
debugPrint('✅ [AppProvider] Channel sync complete');
// Configure the default public channel (channel 0)
// This must be done before sending any channel messages
// Note: Some firmware versions may have this pre-configured
debugPrint('📻 [AppProvider] Configuring default public channel (channel 0)...');
try {
await connectionProvider.configureDefaultPublicChannel();
debugPrint('✅ [AppProvider] Public channel configured successfully');
} catch (e) {
debugPrint('⚠️ [AppProvider] Public channel configuration failed (may already be configured): $e');
// Continue anyway - channel might already be configured in firmware
}
// Automatically login to all saved rooms
await _autoLoginToRooms();
// FALLBACK: Sync messages once after connection to catch any missed push notifications
// This handles the case where messages arrived while the app was disconnected
debugPrint('🔄 [AppProvider] Performing initial message sync (fallback for missed pushes)');
final initialMessageCount = await connectionProvider.syncAllMessages();
debugPrint('📥 [AppProvider] Initial sync retrieved $initialMessageCount message(s)');
// Note: Future messages are synced automatically via PUSH_CODE_MSG_WAITING events
// Start location tracking AFTER all initialization is complete
debugPrint('📍 [AppProvider] Starting location tracking after successful initialization');
await _startLocationTracking();
// Sync drawing messages with DrawingProvider
// This restores any drawings that may be missing from storage
debugPrint('🎨 [AppProvider] Syncing drawing messages with DrawingProvider...');
messagesProvider.syncDrawingsWithProvider(drawingProvider);
notifyListeners();
} catch (e) {
debugPrint('Initialization error: $e');
}
}
/// Automatically login to all rooms with saved passwords on cold connect
Future<void> _autoLoginToRooms() async {
if (!connectionProvider.deviceInfo.isConnected) return;
try {
// Get all room contacts (excluding Public Channel)
final rooms = contactsProvider.rooms
.where((room) => !room.isPublicChannel)
.toList();
if (rooms.isEmpty) {
debugPrint('📂 [AppProvider] No rooms found to auto-login');
return;
}
debugPrint('📂 [AppProvider] Found ${rooms.length} room(s), attempting auto-login...');
final prefs = await SharedPreferences.getInstance();
for (final room in rooms) {
try {
// Load saved password for this room
final roomKey = 'room_password_${room.publicKeyHex}';
final savedPassword = prefs.getString(roomKey) ?? 'hello';
debugPrint('🔑 [AppProvider] Auto-logging into room: ${room.advName}');
// Set up one-time callbacks for this room login
await _loginToRoomWithCallback(room, savedPassword);
// Small delay between logins to avoid overwhelming the device
await Future.delayed(const Duration(milliseconds: 300));
} catch (e) {
debugPrint('❌ [AppProvider] Failed to auto-login to ${room.advName}: $e');
}
}
} catch (e) {
debugPrint('❌ [AppProvider] Auto-login error: $e');
}
}
/// Login to a specific room with callback handling
Future<void> _loginToRoomWithCallback(Contact room, String password) async {
// Create a completer to wait for login result
final completer = Completer<bool>();
// Store original callbacks
final originalOnSuccess = connectionProvider.onLoginSuccess;
final originalOnFail = connectionProvider.onLoginFail;
// Set up temporary callbacks
connectionProvider.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async {
// Restore original callbacks
connectionProvider.onLoginSuccess = originalOnSuccess;
connectionProvider.onLoginFail = originalOnFail;
debugPrint('✅ [AppProvider] Auto-login successful for ${room.advName}');
debugPrint('📡 [AppProvider] Room server will push messages automatically via PUSH_CODE_MSG_WAITING');
completer.complete(true);
};
connectionProvider.onLoginFail = (publicKeyPrefix) {
// Restore original callbacks
connectionProvider.onLoginSuccess = originalOnSuccess;
connectionProvider.onLoginFail = originalOnFail;
debugPrint('❌ [AppProvider] Auto-login failed for ${room.advName} (incorrect password)');
completer.complete(false);
};
try {
// Send login request
await connectionProvider.loginToRoom(
roomPublicKey: room.publicKey,
password: password,
);
// Wait for login result with timeout
await completer.future.timeout(
const Duration(seconds: 10),
onTimeout: () {
// Restore callbacks on timeout
connectionProvider.onLoginSuccess = originalOnSuccess;
connectionProvider.onLoginFail = originalOnFail;
debugPrint('⏱️ [AppProvider] Auto-login timeout for ${room.advName}');
return false;
},
);
} catch (e) {
// Restore callbacks on error
connectionProvider.onLoginSuccess = originalOnSuccess;
connectionProvider.onLoginFail = originalOnFail;
debugPrint('❌ [AppProvider] Error during auto-login to ${room.advName}: $e');
}
}
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
// The ConnectionProvider's onMessageWaiting callback handles automatic message fetching
/// Refresh data (contacts and channels - messages are handled via events)
Future<void> refresh() async {
if (!connectionProvider.deviceInfo.isConnected) return;
try {
// Sync contacts
await connectionProvider.getContacts();
// Sync channels (respect simple mode settings)
final channelsToSync = _isSimpleMode ? 5 : null;
await connectionProvider.syncChannels(maxChannels: channelsToSync);
// Messages are automatically synced via PUSH_CODE_MSG_WAITING events
notifyListeners();
} catch (e) {
debugPrint('Refresh error: $e');
}
}
/// Manually sync messages (only for explicit user pull-to-refresh)
/// Note: Messages are automatically synced via PUSH_CODE_MSG_WAITING events
/// This method should ONLY be called when the user explicitly pulls to refresh
Future<int> syncMessages() async {
if (!connectionProvider.deviceInfo.isConnected) return 0;
try {
debugPrint('🔄 [AppProvider] Manual message sync requested (user initiated)');
final messageCount = await connectionProvider.syncAllMessages();
debugPrint('✅ [AppProvider] Manual sync completed: $messageCount messages');
notifyListeners();
return messageCount;
} catch (e) {
debugPrint('❌ [AppProvider] Message sync error: $e');
return 0;
}
}
/// Handle connection state changes to manage location tracking
void _handleConnectionStateChange() {
final isConnected = connectionProvider.deviceInfo.isConnected;
final wasTracking = locationTrackingService.isTracking;
// Only stop tracking on disconnect - DON'T start on connect
// Location tracking will be started AFTER initialization completes
if (!isConnected && wasTracking) {
// Connection lost - stop location tracking
debugPrint('🔴 [AppProvider] BLE disconnected - stopping location tracking');
_stopLocationTracking();
}
}
/// Start location tracking
Future<void> _startLocationTracking() async {
try {
final started = await locationTrackingService.startTracking();
if (started) {
debugPrint('✅ [AppProvider] Location tracking started successfully');
} else {
debugPrint('⚠️ [AppProvider] Failed to start location tracking');
}
} catch (e) {
debugPrint('❌ [AppProvider] Error starting location tracking: $e');
}
}
/// Stop location tracking
Future<void> _stopLocationTracking() async {
try {
await locationTrackingService.stopTracking();
debugPrint('✅ [AppProvider] Location tracking stopped');
} catch (e) {
debugPrint('❌ [AppProvider] Error stopping location tracking: $e');
}
}
/// Clear all data
void clearAllData() {
contactsProvider.clearContacts();
messagesProvider.clearAll();
notifyListeners();
}
/// Get app statistics
Map<String, dynamic> get statistics {
return {
'connection': {
'isConnected': connectionProvider.deviceInfo.isConnected,
'deviceName': connectionProvider.deviceInfo.deviceName,
'battery': connectionProvider.deviceInfo.batteryPercent,
},
'contacts': contactsProvider.contactCounts,
'messages': messagesProvider.messageStats,
'sarMarkers': messagesProvider.sarMarkerStats,
};
}
@override
void dispose() {
// Remove connection state listener
connectionProvider.removeListener(_handleConnectionStateChange);
// Clear location service callbacks
locationTrackingService.onPositionUpdate = null;
locationTrackingService.onBroadcastSent = null;
locationTrackingService.onError = null;
locationTrackingService.onTrackingStateChanged = null;
// Dispose the location tracking service to stop GPS stream and clean up resources
locationTrackingService.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,110 @@
import 'package:flutter/foundation.dart';
import '../models/channel.dart';
/// Manages channel information from the MeshCore device
class ChannelsProvider with ChangeNotifier {
final Map<int, Channel> _channels = {};
int _selectedChannelIndex = 0; // Default to public channel
/// Get all channels
List<Channel> get channels => _channels.values.toList()..sort((a, b) => a.index.compareTo(b.index));
/// Get a specific channel by index
Channel? getChannel(int index) => _channels[index];
/// Get the currently selected channel
Channel? get selectedChannel => _channels[_selectedChannelIndex];
/// Get the selected channel index
int get selectedChannelIndex => _selectedChannelIndex;
/// Get the display name for a channel
String getChannelDisplayName(int index) {
final channel = _channels[index];
if (channel != null) {
return channel.displayName;
}
// Fallback if channel hasn't been synced yet
return index == 0 ? 'Public' : 'Channel $index';
}
/// Add or update a channel
void addOrUpdateChannel({
required int index,
required String name,
required Uint8List secret,
int? flags,
}) {
_channels[index] = Channel(
index: index,
name: name,
secret: secret,
flags: flags,
);
notifyListeners();
}
/// Add or update a channel using Channel object
void addOrUpdateChannelObject(Channel channel) {
_channels[channel.index] = channel;
notifyListeners();
}
/// Remove a channel by index
void removeChannel(int index) {
if (_channels.containsKey(index)) {
_channels.remove(index);
// If the deleted channel was selected, switch to public channel
if (_selectedChannelIndex == index) {
_selectedChannelIndex = 0;
}
notifyListeners();
}
}
/// Select a channel for sending messages
void selectChannel(int index) {
if (_channels.containsKey(index) || index == 0) {
_selectedChannelIndex = index;
notifyListeners();
}
}
/// Get channels by type (hash-based vs normal)
List<Channel> getHashChannels() {
return channels.where((c) => c.isHashChannel).toList();
}
List<Channel> getNormalChannels() {
return channels.where((c) => !c.isHashChannel).toList();
}
/// Initialize default public channel
void initializePublicChannel() {
if (!_channels.containsKey(0)) {
_channels[0] = Channel.publicChannel();
notifyListeners();
}
}
/// Clear all channels
void clear() {
_channels.clear();
_selectedChannelIndex = 0;
notifyListeners();
}
/// Check if channels have been loaded
bool get hasChannels => _channels.isNotEmpty;
/// Get the number of channels
int get channelCount => _channels.length;
@override
void dispose() {
_channels.clear();
super.dispose();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,446 @@
import 'package:flutter/foundation.dart';
import '../models/contact.dart';
import '../services/cayenne_lpp_parser.dart';
import '../services/contact_storage_service.dart';
import '../utils/key_comparison.dart';
/// Contacts Provider - manages contact list and telemetry
class ContactsProvider with ChangeNotifier {
final Map<String, Contact> _contacts = {};
final ContactStorageService _storageService = ContactStorageService();
bool _isInitialized = false;
// Add default public channel on initialization
ContactsProvider() {
_ensurePublicChannelExists();
}
bool get isInitialized => _isInitialized;
/// Initialize and load persisted contacts at app startup
/// This loads contacts without filtering, allowing offline viewing
Future<void> initializeEarly() async {
if (_isInitialized) return;
try {
debugPrint(
'📦 [ContactsProvider] Early loading persisted contacts (no filtering)...',
);
final storedContacts = await _storageService.loadContacts();
// Add stored contacts (excluding any with all-zeros public key)
const publicChannelKey =
'0000000000000000000000000000000000000000000000000000000000000000';
for (final contact in storedContacts) {
// Skip any contacts with all-zeros public key (shouldn't happen, but safety check)
if (contact.publicKeyHex == publicChannelKey) {
continue;
}
_contacts[contact.publicKeyHex] = contact;
}
_isInitialized = true;
debugPrint(
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts',
);
// Ensure public channel exists after loading
_ensurePublicChannelExists();
notifyListeners();
} catch (e) {
debugPrint('❌ [ContactsProvider] Error in early initialization: $e');
_isInitialized = true; // Mark as initialized even on error
_ensurePublicChannelExists();
}
}
/// Initialize and load persisted contacts
/// [devicePublicKey] - device's own public key to exclude from loaded contacts
Future<void> initialize({Uint8List? devicePublicKey}) async {
if (_isInitialized) {
// If already initialized (from early load), just filter out self-contact
if (devicePublicKey != null) {
_removeSelfContact(devicePublicKey);
}
return;
}
try {
debugPrint('📦 [ContactsProvider] Loading persisted contacts...');
final storedContacts = await _storageService.loadContacts(
excludePublicKey: devicePublicKey,
);
// Add stored contacts (excluding any with all-zeros public key)
const publicChannelKey =
'0000000000000000000000000000000000000000000000000000000000000000';
for (final contact in storedContacts) {
// Skip any contacts with all-zeros public key (shouldn't happen, but safety check)
if (contact.publicKeyHex == publicChannelKey) {
continue;
}
_contacts[contact.publicKeyHex] = contact;
}
_isInitialized = true;
debugPrint(
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts',
);
// Ensure public channel exists after loading
_ensurePublicChannelExists();
notifyListeners();
} catch (e) {
debugPrint('❌ [ContactsProvider] Error initializing: $e');
_isInitialized = true; // Mark as initialized even on error
_ensurePublicChannelExists();
}
}
/// Remove self-contact from loaded contacts (called after BLE connection established)
void _removeSelfContact(Uint8List devicePublicKey) {
final selfKeyHex = devicePublicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
if (_contacts.containsKey(selfKeyHex)) {
final selfContact = _contacts[selfKeyHex]!;
debugPrint(
'🗑️ [ContactsProvider] Removing self-contact: ${selfContact.advName}',
);
_contacts.remove(selfKeyHex);
_persistContacts();
notifyListeners();
}
}
/// Ensure public channel always exists in the list
void _ensurePublicChannelExists() {
// Public channel has all-zeros public key (32 bytes = 64 hex chars)
const publicChannelKey =
'0000000000000000000000000000000000000000000000000000000000000000';
if (!_contacts.containsKey(publicChannelKey)) {
// Create a pseudo-contact for the public channel (ephemeral broadcast)
_contacts[publicChannelKey] = Contact(
publicKey: Uint8List.fromList(
List.filled(32, 0),
), // Zero key for public
type: ContactType.channel, // Channel type (not room!)
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'Public Channel',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
}
/// Persist contacts to storage (async, non-blocking)
Future<void> _persistContacts() async {
try {
// Don't persist the public channel pseudo-contact (all zeros key)
const publicChannelKey =
'0000000000000000000000000000000000000000000000000000000000000000';
final contactsToSave = _contacts.entries
.where((entry) => entry.key != publicChannelKey)
.map((entry) => entry.value)
.toList();
await _storageService.saveContacts(contactsToSave);
} catch (e) {
debugPrint('❌ [ContactsProvider] Error persisting contacts: $e');
}
}
List<Contact> get contacts => _contacts.values.toList();
List<Contact> get chatContacts =>
contacts.where((c) => c.isChat).toList()..sort(_sortByLastSeen);
List<Contact> get repeaters =>
contacts.where((c) => c.isRepeater).toList()..sort(_sortByLastSeen);
List<Contact> get rooms =>
contacts.where((c) => c.isRoom).toList()..sort(_sortByLastSeen);
List<Contact> get channels {
// Always ensure public channel exists when getting channels
_ensurePublicChannelExists();
return contacts.where((c) => c.isChannel).toList()..sort(_sortByLastSeen);
}
/// Get both rooms and channels (destinations for SAR markers)
List<Contact> get roomsAndChannels {
_ensurePublicChannelExists();
return contacts.where((c) => c.isRoom || c.isChannel).toList()
..sort(_sortByLastSeen);
}
/// Get contacts with location (for map display)
List<Contact> get contactsWithLocation =>
contacts.where((c) => c.displayLocation != null).toList();
/// Get chat contacts with location (team members on map)
List<Contact> get chatContactsWithLocation =>
chatContacts.where((c) => c.displayLocation != null).toList();
/// Sort contacts by last seen (most recent first)
int _sortByLastSeen(Contact a, Contact b) {
return b.lastSeenTime.compareTo(a.lastSeenTime);
}
/// Add or update a contact
/// Excludes contacts that match the device's own public key
void addOrUpdateContact(Contact contact, {Uint8List? devicePublicKey}) {
debugPrint('📝 [ContactsProvider] addOrUpdateContact called: ${contact.advName} (type: ${contact.type.displayName}, key: ${contact.publicKeyHex.substring(0, 8)}...)');
// Don't add contacts that match our device's public key
if (devicePublicKey != null &&
contact.publicKey.matches(devicePublicKey)) {
debugPrint(
' [ContactsProvider] Ignoring contact with device\'s own public key: ${contact.advName}',
);
return;
}
// Check if this is a new contact
final isNewContact = !_contacts.containsKey(contact.publicKeyHex);
debugPrint(' isNew: $isNewContact, total contacts before: ${_contacts.length}');
Contact updatedContact;
if (isNewContact) {
// New contact - add initial location to history if available
updatedContact = contact.copyWith(isNew: true);
if (contact.advertLocation != null) {
final timestamp = DateTime.fromMillisecondsSinceEpoch(
contact.lastAdvert * 1000,
);
updatedContact = updatedContact.addAdvertLocation(
contact.advertLocation!,
timestamp,
);
}
} else {
// Existing contact - preserve history and isNew status
final existingContact = _contacts[contact.publicKeyHex]!;
// Start with existing contact
updatedContact = contact.copyWith(
isNew: existingContact.isNew,
advertHistory: existingContact.advertHistory,
);
// Add new location to history if location has changed
if (contact.advertLocation != null) {
final timestamp = DateTime.fromMillisecondsSinceEpoch(
contact.lastAdvert * 1000,
);
updatedContact = updatedContact.addAdvertLocation(
contact.advertLocation!,
timestamp,
);
}
}
_contacts[contact.publicKeyHex] = updatedContact;
debugPrint(' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}');
_persistContacts();
notifyListeners();
debugPrint(' 🔔 notifyListeners() called');
}
/// Add multiple contacts
/// Excludes contacts that match the device's own public key
void addContacts(List<Contact> contacts, {Uint8List? devicePublicKey}) {
int excluded = 0;
for (final contact in contacts) {
// Don't add contacts that match our device's public key
if (devicePublicKey != null &&
contact.publicKey.matches(devicePublicKey)) {
debugPrint(
' [ContactsProvider] Ignoring contact with device\'s own public key: ${contact.advName}',
);
excluded++;
continue;
}
_contacts[contact.publicKeyHex] = contact;
}
if (excluded > 0) {
debugPrint(
' [ContactsProvider] Excluded $excluded contact(s) matching device public key',
);
}
_persistContacts();
notifyListeners();
}
/// Update contact telemetry
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
debugPrint('📊 [ContactsProvider] updateTelemetry() called');
debugPrint(
' Public key prefix (hex): ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
debugPrint(' LPP data size: ${lppData.length} bytes');
// Find contact by public key prefix
final contact = _findContactByPrefix(publicKeyPrefix);
if (contact == null) {
debugPrint(' ❌ Contact not found for this prefix');
return;
}
debugPrint(' ✅ Found contact: ${contact.advName}');
debugPrint(' Old telemetry timestamp: ${contact.telemetry?.timestamp}');
try {
// Parse Cayenne LPP data
final telemetry = CayenneLppParser.parse(lppData);
debugPrint(' ✅ Parsed new telemetry');
debugPrint(' New telemetry timestamp: ${telemetry.timestamp}');
// Update contact with new telemetry AND last seen time
// lastAdvert is Unix timestamp in seconds
final currentTimestamp =
(DateTime.now().millisecondsSinceEpoch / 1000).round();
debugPrint(' Old lastAdvert: ${contact.lastAdvert}');
debugPrint(' New lastAdvert: $currentTimestamp');
final updatedContact = contact.copyWith(
telemetry: telemetry,
lastAdvert: currentTimestamp, // Update last seen time
);
_contacts[contact.publicKeyHex] = updatedContact;
debugPrint(' ✅ Updated contact in map (with new lastAdvert)');
_persistContacts();
debugPrint(' ✅ Persisted contacts to storage');
notifyListeners();
debugPrint(' ✅ Notified listeners - UI should update');
} catch (e) {
debugPrint(' ❌ Failed to parse telemetry: $e');
debugPrint('Failed to parse telemetry: $e');
}
}
/// Find contact by public key prefix (6 bytes)
Contact? _findContactByPrefix(Uint8List prefix) {
if (prefix.length < 6) return null;
final prefixHex = prefix
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
for (final contact in contacts) {
if (contact.publicKeyHex.startsWith(prefixHex)) {
return contact;
}
}
return null;
}
/// Find contact by public key
Contact? findContactByKey(Uint8List publicKey) {
final keyHex = publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
return _contacts[keyHex];
}
/// Find contact by name
Contact? findContactByName(String name) {
return contacts.firstWhere(
(c) => c.advName == name,
orElse: () => contacts.first,
);
}
/// Get contacts with low battery
List<Contact> get lowBatteryContacts {
return contacts.where((c) {
final battery = c.displayBattery;
return battery != null && battery < 20.0;
}).toList();
}
/// Get recently seen contacts (within last 10 minutes)
List<Contact> get recentlySeenContacts {
return contacts.where((c) => c.isRecentlySeen).toList();
}
/// Get count of new contacts (not yet viewed)
int get newContactsCount =>
contacts.where((c) => c.isNew && !c.isChannel).length;
/// Mark all contacts as viewed (not new)
void markAllAsViewed() {
bool hasChanges = false;
_contacts.forEach((key, contact) {
if (contact.isNew && !contact.isChannel) {
_contacts[key] = contact.copyWith(isNew: false);
hasChanges = true;
}
});
if (hasChanges) {
_persistContacts();
notifyListeners();
}
}
/// Mark a specific contact as viewed (not new)
void markAsViewed(String publicKeyHex) {
final contact = _contacts[publicKeyHex];
if (contact != null && contact.isNew) {
_contacts[publicKeyHex] = contact.copyWith(isNew: false);
_persistContacts();
notifyListeners();
}
}
/// Clear all contacts
void clearContacts() {
_contacts.clear();
_persistContacts();
notifyListeners();
}
/// Remove a contact
/// [onRemoveFromDevice] - Optional callback to remove contact from BLE device
Future<void> removeContact(
String publicKeyHex, {
Future<void> Function(Uint8List)? onRemoveFromDevice,
}) async {
// Get the contact before removing
final contact = _contacts[publicKeyHex];
if (contact == null) return;
// Remove from device first if callback provided
if (onRemoveFromDevice != null) {
await onRemoveFromDevice(contact.publicKey);
}
// Then remove from local storage
_contacts.remove(publicKeyHex);
_persistContacts();
notifyListeners();
}
/// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async {
return await _storageService.getStorageStats();
}
/// Get contact count by type
Map<String, int> get contactCounts {
return {
'chat': chatContacts.length,
'repeater': repeaters.length,
'room': rooms.length,
'total': contacts.length,
};
}
}

View File

@@ -0,0 +1,496 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/map_drawing.dart';
import '../utils/drawing_message_parser.dart';
/// Drawing mode state
enum DrawingMode { none, line, rectangle, measure }
/// Provider for managing map drawings
class DrawingProvider with ChangeNotifier {
static const String _storageKey = 'map_drawings';
// Drawing state
DrawingMode _drawingMode = DrawingMode.none;
Color _selectedColor = DrawingColors.palette[0];
bool _showReceivedDrawings = true;
bool _showSarMarkers = true;
// Completed drawings
final List<MapDrawing> _drawings = [];
// In-progress drawing
MapDrawing? _currentDrawing;
List<LatLng> _currentLinePoints = [];
LatLng? _rectangleStartPoint;
// Distance measurement state
LatLng? _measurementPoint1;
LatLng? _measurementPoint2;
double? _measuredDistance; // in meters
// Getters
DrawingMode get drawingMode => _drawingMode;
Color get selectedColor => _selectedColor;
bool get showReceivedDrawings => _showReceivedDrawings;
bool get showSarMarkers => _showSarMarkers;
List<MapDrawing> get drawings {
// Filter out hidden drawings first
var visibleDrawings = _drawings.where((d) => !d.isHidden);
// Then filter by received status if needed
if (!_showReceivedDrawings) {
visibleDrawings = visibleDrawings.where((d) => !d.isReceived);
}
return List.unmodifiable(visibleDrawings.toList());
}
MapDrawing? get currentDrawing => _currentDrawing;
List<LatLng> get currentLinePoints => List.unmodifiable(_currentLinePoints);
LatLng? get rectangleStartPoint => _rectangleStartPoint;
bool get isDrawing => _drawingMode != DrawingMode.none;
LatLng? get measurementPoint1 => _measurementPoint1;
LatLng? get measurementPoint2 => _measurementPoint2;
double? get measuredDistance => _measuredDistance;
/// Initialize and load saved drawings
Future<void> initialize() async {
await _loadDrawings();
}
/// Set drawing mode
void setDrawingMode(DrawingMode mode) {
if (_drawingMode != mode) {
// Cancel any in-progress drawing when switching modes
_cancelCurrentDrawing();
_drawingMode = mode;
notifyListeners();
}
}
/// Set selected color
void setColor(Color color) {
_selectedColor = color;
notifyListeners();
}
/// Toggle visibility of received drawings
void toggleReceivedDrawings() {
_showReceivedDrawings = !_showReceivedDrawings;
notifyListeners();
}
/// Toggle visibility of SAR markers
void toggleSarMarkers() {
_showSarMarkers = !_showSarMarkers;
notifyListeners();
}
/// Start drawing a line
void startLine(LatLng point) {
if (_drawingMode != DrawingMode.line) return;
_currentLinePoints = [point];
notifyListeners();
}
/// Add point to current line
void addLinePoint(LatLng point) {
if (_drawingMode != DrawingMode.line || _currentLinePoints.isEmpty) return;
_currentLinePoints.add(point);
notifyListeners();
}
/// Complete current line drawing
void completeLine() {
if (_drawingMode != DrawingMode.line || _currentLinePoints.length < 2) {
_cancelCurrentDrawing();
return;
}
final drawing = LineDrawing(
id: DateTime.now().millisecondsSinceEpoch.toString(),
color: _selectedColor,
createdAt: DateTime.now(),
points: List.from(_currentLinePoints),
);
_drawings.add(drawing);
_currentLinePoints = [];
_saveDrawings();
notifyListeners();
}
/// Start drawing a rectangle
void startRectangle(LatLng point) {
if (_drawingMode != DrawingMode.rectangle) return;
_rectangleStartPoint = point;
notifyListeners();
}
/// Update rectangle end point (for preview)
void updateRectangleEndPoint(LatLng endPoint) {
if (_drawingMode != DrawingMode.rectangle || _rectangleStartPoint == null) {
return;
}
// Create preview rectangle
_currentDrawing = RectangleDrawing(
id: 'preview',
color: _selectedColor,
createdAt: DateTime.now(),
topLeft: LatLng(
_rectangleStartPoint!.latitude > endPoint.latitude
? endPoint.latitude
: _rectangleStartPoint!.latitude,
_rectangleStartPoint!.longitude < endPoint.longitude
? _rectangleStartPoint!.longitude
: endPoint.longitude,
),
bottomRight: LatLng(
_rectangleStartPoint!.latitude < endPoint.latitude
? endPoint.latitude
: _rectangleStartPoint!.latitude,
_rectangleStartPoint!.longitude > endPoint.longitude
? _rectangleStartPoint!.longitude
: endPoint.longitude,
),
);
notifyListeners();
}
/// Complete current rectangle drawing
void completeRectangle(LatLng endPoint) {
if (_drawingMode != DrawingMode.rectangle || _rectangleStartPoint == null) {
_cancelCurrentDrawing();
return;
}
// Calculate top-left and bottom-right corners
final topLeft = LatLng(
_rectangleStartPoint!.latitude > endPoint.latitude
? endPoint.latitude
: _rectangleStartPoint!.latitude,
_rectangleStartPoint!.longitude < endPoint.longitude
? _rectangleStartPoint!.longitude
: endPoint.longitude,
);
final bottomRight = LatLng(
_rectangleStartPoint!.latitude < endPoint.latitude
? endPoint.latitude
: _rectangleStartPoint!.latitude,
_rectangleStartPoint!.longitude > endPoint.longitude
? _rectangleStartPoint!.longitude
: endPoint.longitude,
);
final drawing = RectangleDrawing(
id: DateTime.now().millisecondsSinceEpoch.toString(),
color: _selectedColor,
createdAt: DateTime.now(),
topLeft: topLeft,
bottomRight: bottomRight,
);
_drawings.add(drawing);
_rectangleStartPoint = null;
_currentDrawing = null;
_saveDrawings();
notifyListeners();
}
/// Set first measurement point
void setMeasurementPoint1(LatLng point) {
if (_drawingMode != DrawingMode.measure) return;
_measurementPoint1 = point;
_measurementPoint2 = null;
_measuredDistance = null;
notifyListeners();
}
/// Set second measurement point and calculate distance
void setMeasurementPoint2(LatLng point) {
if (_drawingMode != DrawingMode.measure || _measurementPoint1 == null) return;
_measurementPoint2 = point;
_measuredDistance = _calculateDistance(_measurementPoint1!, point);
notifyListeners();
}
/// Calculate distance between two points using Haversine formula
double _calculateDistance(LatLng point1, LatLng point2) {
const Distance distance = Distance();
return distance.as(LengthUnit.Meter, point1, point2);
}
/// Clear measurement points
void clearMeasurement() {
_measurementPoint1 = null;
_measurementPoint2 = null;
_measuredDistance = null;
notifyListeners();
}
/// Cancel current drawing in progress
void _cancelCurrentDrawing() {
_currentLinePoints = [];
_rectangleStartPoint = null;
_currentDrawing = null;
_measurementPoint1 = null;
_measurementPoint2 = null;
_measuredDistance = null;
}
/// Clear current drawing (public method)
void cancelCurrentDrawing() {
_cancelCurrentDrawing();
notifyListeners();
}
/// Remove a specific drawing
void removeDrawing(String id) {
_drawings.removeWhere((d) => d.id == id);
_saveDrawings();
notifyListeners();
}
/// Clear all drawings
void clearAllDrawings() {
_drawings.clear();
_cancelCurrentDrawing();
_saveDrawings();
notifyListeners();
}
/// Exit drawing mode
void exitDrawingMode() {
_cancelCurrentDrawing();
_drawingMode = DrawingMode.none;
notifyListeners();
}
/// Save drawings to persistent storage
Future<void> _saveDrawings() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonList = _drawings.map((d) => d.toJson()).toList();
final jsonString = jsonEncode(jsonList);
await prefs.setString(_storageKey, jsonString);
} catch (e) {
debugPrint('Error saving drawings: $e');
}
}
/// Load drawings from persistent storage
Future<void> _loadDrawings() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_storageKey);
if (jsonString == null) return;
final jsonList = jsonDecode(jsonString) as List<dynamic>;
_drawings.clear();
for (final json in jsonList) {
final drawing = MapDrawing.fromJson(json as Map<String, dynamic>);
if (drawing != null) {
_drawings.add(drawing);
}
}
notifyListeners();
} catch (e) {
debugPrint('Error loading drawings: $e');
}
}
/// Get the current preview drawing for rendering
MapDrawing? getPreviewDrawing() {
if (_drawingMode == DrawingMode.line && _currentLinePoints.length >= 2) {
return LineDrawing(
id: 'preview',
color: _selectedColor,
createdAt: DateTime.now(),
points: _currentLinePoints,
);
} else if (_drawingMode == DrawingMode.rectangle &&
_currentDrawing != null) {
return _currentDrawing;
}
return null;
}
/// Add received drawing from another node
void addReceivedDrawing(MapDrawing drawing) {
// Check if drawing with this ID already exists
if (_drawings.any((d) => d.id == drawing.id)) {
debugPrint('Drawing ${drawing.id} already exists, skipping');
return;
}
// Mark as received when adding
final receivedDrawing = _createReceivedCopy(drawing);
_drawings.add(receivedDrawing);
_saveDrawings();
notifyListeners();
}
/// Create a copy of a drawing marked as received
MapDrawing _createReceivedCopy(MapDrawing drawing) {
if (drawing is LineDrawing) {
return LineDrawing(
id: drawing.id,
color: drawing.color,
createdAt: drawing.createdAt,
points: drawing.points,
senderName: drawing.senderName,
isReceived: true,
messageId: drawing.messageId,
isShared: drawing.isShared,
isSent: drawing.isSent,
isHidden: drawing.isHidden,
);
} else if (drawing is RectangleDrawing) {
return RectangleDrawing(
id: drawing.id,
color: drawing.color,
createdAt: drawing.createdAt,
topLeft: drawing.topLeft,
bottomRight: drawing.bottomRight,
senderName: drawing.senderName,
isReceived: true,
messageId: drawing.messageId,
isShared: drawing.isShared,
isSent: drawing.isSent,
isHidden: drawing.isHidden,
);
}
return drawing;
}
/// Get a drawing by its ID
MapDrawing? getDrawingById(String id) {
try {
return _drawings.firstWhere((d) => d.id == id);
} catch (e) {
return null;
}
}
/// Get all unshared drawings (local drawings not yet sent)
List<MapDrawing> getUnsharedDrawings() {
return _drawings.where((d) => !d.isShared && !d.isReceived).toList();
}
/// Mark a drawing as shared
void markDrawingAsShared(String id) {
final index = _drawings.indexWhere((d) => d.id == id);
if (index != -1) {
final drawing = _drawings[index];
// Create a copy with isShared = true
if (drawing is LineDrawing) {
_drawings[index] = LineDrawing(
id: drawing.id,
color: drawing.color,
createdAt: drawing.createdAt,
points: drawing.points,
senderName: drawing.senderName,
isReceived: drawing.isReceived,
messageId: drawing.messageId,
isShared: true,
isSent: drawing.isSent,
isHidden: drawing.isHidden,
);
} else if (drawing is RectangleDrawing) {
_drawings[index] = RectangleDrawing(
id: drawing.id,
color: drawing.color,
createdAt: drawing.createdAt,
topLeft: drawing.topLeft,
bottomRight: drawing.bottomRight,
senderName: drawing.senderName,
isReceived: drawing.isReceived,
messageId: drawing.messageId,
isShared: true,
isSent: drawing.isSent,
isHidden: drawing.isHidden,
);
}
_saveDrawings();
notifyListeners();
}
}
/// Toggle visibility of a drawing (doesn't save to storage)
void toggleDrawingVisibility(String id) {
final index = _drawings.indexWhere((d) => d.id == id);
if (index != -1) {
final drawing = _drawings[index];
// Create a copy with toggled isHidden flag
if (drawing is LineDrawing) {
_drawings[index] = LineDrawing(
id: drawing.id,
color: drawing.color,
createdAt: drawing.createdAt,
points: drawing.points,
senderName: drawing.senderName,
isReceived: drawing.isReceived,
messageId: drawing.messageId,
isShared: drawing.isShared,
isSent: drawing.isSent,
isHidden: !drawing.isHidden,
);
} else if (drawing is RectangleDrawing) {
_drawings[index] = RectangleDrawing(
id: drawing.id,
color: drawing.color,
createdAt: drawing.createdAt,
topLeft: drawing.topLeft,
bottomRight: drawing.bottomRight,
senderName: drawing.senderName,
isReceived: drawing.isReceived,
messageId: drawing.messageId,
isShared: drawing.isShared,
isSent: drawing.isSent,
isHidden: !drawing.isHidden,
);
}
// Don't save to storage - visibility toggle is temporary
notifyListeners();
}
}
/// Remove a drawing and its linked message
void removeDrawingAndMessage(String drawingId, dynamic messagesProvider) {
final drawing = getDrawingById(drawingId);
if (drawing == null) return;
// Remove the drawing
_drawings.removeWhere((d) => d.id == drawingId);
// If the drawing has a linked message, remove it too
if (drawing.messageId != null && messagesProvider != null) {
messagesProvider.deleteMessage(drawing.messageId!);
}
_saveDrawings();
notifyListeners();
}
/// Broadcast a drawing to contacts
/// Returns the formatted message string ready to send
/// Sender will be determined from packet metadata on receiving end
String createDrawingBroadcastMessage(MapDrawing drawing) {
return DrawingMessageParser.createDrawingMessage(drawing);
}
}

View File

@@ -0,0 +1,150 @@
/// Message delivery tracking helper
///
/// Manages message delivery tracking for sent messages, including:
/// - FIFO queue for matching RESP_CODE_SENT with message IDs
/// - ACK tag to message ID mapping
/// - Timeout tracking for stale ACK mappings
/// - Message sent/delivered coordination
///
/// IMPORTANT: Based on MeshCore firmware analysis:
/// - Firmware tracks max 8 pending ACKs in circular buffer
/// - ACK entries overwritten after 8 messages → need rate limiting
/// - Duplicate ACKs suppressed after first match
/// - No automatic retry → app must implement
class MessageDeliveryTracker {
/// FIFO queue of pending message IDs
/// Messages tracked here before sending, popped when RESP_CODE_SENT arrives
final List<String> _pendingMessageIds = [];
/// Map of ACK tag to message ID for delivery confirmation
final Map<int, String> _ackTagToMessageId = {};
/// Map of message ID to ACK tag (reverse mapping for cleanup)
final Map<String, int> _messageIdToAckTag = {};
/// Map of ACK tag to timestamp for timeout cleanup
final Map<int, DateTime> _ackTagTimestamps = {};
/// Track a pending message ID before sending
///
/// This is called BEFORE sending the message. When RESP_CODE_SENT
/// arrives, we pop from this FIFO queue to match with the ACK tag.
void trackPendingMessage(String messageId) {
_pendingMessageIds.add(messageId);
}
/// Pop the next pending message ID from FIFO queue
///
/// Called when RESP_CODE_SENT arrives. Returns null if queue empty.
String? popPendingMessageId() {
if (_pendingMessageIds.isEmpty) {
return null;
}
return _pendingMessageIds.removeAt(0);
}
/// Map ACK tag to message ID after RESP_CODE_SENT received
///
/// Creates bidirectional mapping for efficient cleanup and tracking.
///
/// WARNING: Firmware only tracks 8 pending ACKs! Caller should
/// enforce rate limiting before calling this.
void mapAckTagToMessageId(int ackTag, String messageId) {
// Store bidirectional mapping
_ackTagToMessageId[ackTag] = messageId;
_messageIdToAckTag[messageId] = ackTag;
_ackTagTimestamps[ackTag] = DateTime.now();
}
/// Get message ID for ACK code
///
/// Called when SEND_CONFIRMED arrives. Returns the message ID
/// that corresponds to this ACK code.
///
/// Returns null if ACK tag not found.
String? getMessageIdForAck(int ackCode) {
return _ackTagToMessageId[ackCode];
}
/// Remove ACK tag mapping after delivery confirmed or timeout
///
/// Cleans up both forward and reverse mappings.
void removeAckTag(int ackCode) {
final messageId = _ackTagToMessageId.remove(ackCode);
if (messageId != null) {
_messageIdToAckTag.remove(messageId);
}
_ackTagTimestamps.remove(ackCode);
}
/// Remove ACK tag mapping by message ID
///
/// Used when message times out or is cancelled.
void removeByMessageId(String messageId) {
final ackTag = _messageIdToAckTag.remove(messageId);
if (ackTag != null) {
_ackTagToMessageId.remove(ackTag);
_ackTagTimestamps.remove(ackTag);
}
}
/// Clean up stale ACK mappings
///
/// Removes ACK tags that haven't received delivery confirmation
/// within the specified timeout (default: 5 minutes).
///
/// Returns count of cleaned up entries.
int cleanupStaleAcks({Duration timeout = const Duration(minutes: 5)}) {
final now = DateTime.now();
final staleAcks = <int>[];
for (final entry in _ackTagTimestamps.entries) {
if (now.difference(entry.value) > timeout) {
staleAcks.add(entry.key);
}
}
for (final ackTag in staleAcks) {
removeAckTag(ackTag);
}
return staleAcks.length;
}
/// Clear all tracking state
void clearTracking() {
_pendingMessageIds.clear();
_ackTagToMessageId.clear();
_messageIdToAckTag.clear();
_ackTagTimestamps.clear();
}
/// Get count of pending ACK tags
///
/// WARNING: Firmware only tracks 8 pending ACKs in circular buffer.
/// If this exceeds 7, message sending should be rate limited.
int get pendingCount => _ackTagToMessageId.length;
/// Check if should rate limit message sending
///
/// Returns true if >= 7 pending ACKs (stay under firmware limit of 8)
bool get shouldRateLimit => pendingCount >= 7;
/// Get oldest pending ACK timestamp (for debugging)
DateTime? get oldestPendingTimestamp {
if (_ackTagTimestamps.isEmpty) return null;
return _ackTagTimestamps.values.reduce(
(a, b) => a.isBefore(b) ? a : b,
);
}
/// Get diagnostic info for debugging
Map<String, dynamic> getDiagnostics() {
return {
'pendingCount': pendingCount,
'shouldRateLimit': shouldRateLimit,
'oldestPending': oldestPendingTimestamp?.toIso8601String(),
'ackTags': _ackTagToMessageId.keys.toList(),
};
}
}

View File

@@ -0,0 +1,101 @@
import '../../models/message.dart';
import '../../models/contact.dart';
/// Manages message retry state and logic
///
/// This helper class centralizes retry logic for direct messages, implementing
/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts
/// with learned routing paths.
///
/// IMPORTANT: Based on MeshCore firmware analysis:
/// - Firmware calculates timeout based on path length and airtime
/// - Direct mode: ~(path_len * airtime * 2) + margin
/// - Flood mode: ~10-30 seconds for multi-hop
/// - Our timeouts (4s, 8s, 12s) are conservative for direct paths
/// - Firmware does NOT automatically retry - app must implement
class MessageRetryManager {
// Track retry state for each message ID
final Map<String, int> _retryAttempts = {};
final Map<String, DateTime> _lastRetryTimes = {};
// Progressive timeout values in milliseconds
// These are app-level timeouts, separate from firmware's suggested timeout
// Firmware timeout is for ACK arrival, these are for retry attempts
static const List<int> _timeouts = [4000, 8000, 12000];
/// Get timeout for a specific retry attempt (0-2)
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
int getTimeoutForAttempt(int attempt) {
if (attempt < 0 || attempt >= _timeouts.length) {
return _timeouts.last; // Default to last timeout if out of range
}
return _timeouts[attempt];
}
/// Check if a message is eligible for retry
///
/// Returns true if:
/// - The message has retryAttempt < 3
/// - The contact has a learned path (contact.hasPath == true)
/// - The message hasn't used flood fallback yet
///
/// Messages to contacts without paths should NOT retry (flood mode already broadcasts)
bool canRetry(Message message, Contact contact) {
// Never retry if already tried flood mode
if (message.usedFloodFallback) {
return false;
}
// Never retry beyond 3 attempts
if (message.retryAttempt >= 3) {
return false;
}
// Only retry if contact has a learned path
// If no path, the device uses flood mode automatically - retrying won't help
return contact.hasPath;
}
/// Check if should fall back to flood mode
///
/// Returns true if:
/// - Message has exhausted all 3 retry attempts with direct mode
/// - Contact HAS a learned path (so direct mode was used)
/// - Hasn't already used flood fallback
///
/// IMPORTANT: Only contacts WITH paths need flood fallback.
/// Contacts without paths already use flood mode automatically.
bool shouldUseFloodFallback(Message message, Contact contact) {
return message.retryAttempt >= 3 &&
contact.hasPath && // ✅ FIXED: Flood fallback for failed direct paths
!message.usedFloodFallback;
}
/// Track a retry attempt for a message
void trackRetry(String messageId, int attempt) {
_retryAttempts[messageId] = attempt;
_lastRetryTimes[messageId] = DateTime.now();
}
/// Clear retry tracking for a message (on success or permanent failure)
void clearRetry(String messageId) {
_retryAttempts.remove(messageId);
_lastRetryTimes.remove(messageId);
}
/// Clear all retry tracking (on disconnect)
void clearAll() {
_retryAttempts.clear();
_lastRetryTimes.clear();
}
/// Get current retry attempt for a message (for debugging)
int? getRetryAttempt(String messageId) {
return _retryAttempts[messageId];
}
/// Get last retry time for a message (for debugging)
DateTime? getLastRetryTime(String messageId) {
return _lastRetryTimes[messageId];
}
}

View File

@@ -0,0 +1,103 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
/// Helper class to track pending ping (telemetry) requests
/// and implement automatic fallback to flooding if no response received
class PingTracker {
// Map of public key hex string to ping request state
final Map<String, _PingRequest> _pendingPings = {};
// Timeout duration for ping responses (seconds)
static const int _pingTimeoutSeconds = 5;
/// Track a new ping request
/// Returns a Future that completes when either:
/// - A response is received (completes with true)
/// - Timeout occurs (completes with false)
Future<bool> trackPing({
required Uint8List publicKey,
required bool wasDirectAttempt,
}) {
final String keyHex = _publicKeyToHex(publicKey);
// Cancel any existing pending ping for this contact
_pendingPings[keyHex]?.cancel();
// Create new ping request tracker
final completer = Completer<bool>();
final timer = Timer(const Duration(seconds: _pingTimeoutSeconds), () {
// Timeout occurred - mark as failed
_pendingPings.remove(keyHex);
if (!completer.isCompleted) {
completer.complete(false);
}
});
_pendingPings[keyHex] = _PingRequest(
publicKey: publicKey,
wasDirectAttempt: wasDirectAttempt,
timer: timer,
completer: completer,
);
return completer.future;
}
/// Mark a ping as successful (response received)
/// Should be called when telemetry response arrives
void markPingSuccessful(Uint8List publicKey) {
final String keyHex = _publicKeyToHex(publicKey);
final request = _pendingPings.remove(keyHex);
if (request != null) {
request.cancel();
if (!request.completer.isCompleted) {
request.completer.complete(true);
}
}
}
/// Check if there's a pending ping for this contact
bool hasPendingPing(Uint8List publicKey) {
final String keyHex = _publicKeyToHex(publicKey);
return _pendingPings.containsKey(keyHex);
}
/// Get pending ping info (was it a direct attempt?)
bool? wasPingDirect(Uint8List publicKey) {
final String keyHex = _publicKeyToHex(publicKey);
return _pendingPings[keyHex]?.wasDirectAttempt;
}
/// Clear all pending pings (useful on disconnect)
void clearAll() {
for (final request in _pendingPings.values) {
request.cancel();
}
_pendingPings.clear();
}
/// Convert public key to hex string for map key
String _publicKeyToHex(Uint8List publicKey) {
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}
}
/// Internal class to track a single ping request
class _PingRequest {
final Uint8List publicKey;
final bool wasDirectAttempt;
final Timer timer;
final Completer<bool> completer;
_PingRequest({
required this.publicKey,
required this.wasDirectAttempt,
required this.timer,
required this.completer,
});
void cancel() {
timer.cancel();
}
}

View File

@@ -0,0 +1,84 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../models/room_login_state.dart';
/// Room login state management helper
///
/// Manages login state tracking for room contacts, including:
/// - Room login state per contact (Map of String to RoomLoginState)
/// - Password checking logic
/// - Login success/fail state updates
class RoomLoginManager {
/// Map of room public key prefix (hex string) to login state
final Map<String, RoomLoginState> _roomLoginStates = {};
/// Get all room login states (unmodifiable view)
Map<String, RoomLoginState> get roomLoginStates => Map.unmodifiable(_roomLoginStates);
/// Get login state for a room by public key prefix
RoomLoginState? getRoomLoginState(Uint8List publicKeyPrefix) {
final prefixHex = _publicKeyPrefixToHex(publicKeyPrefix);
return _roomLoginStates[prefixHex];
}
/// Check if logged into a specific room
bool isLoggedIntoRoom(Uint8List publicKeyPrefix) {
final state = getRoomLoginState(publicKeyPrefix);
return state?.isLoggedIn ?? false;
}
/// Update room login state after successful login
Future<void> handleLoginSuccess({
required Uint8List publicKeyPrefix,
required int permissions,
required bool isAdmin,
required int tag,
}) async {
final prefixHex = _publicKeyPrefixToHex(publicKeyPrefix);
final hasPassword = await _hasPasswordForRoom(publicKeyPrefix);
_roomLoginStates[prefixHex] = RoomLoginState.loggedIn(
publicKeyPrefix: publicKeyPrefix,
permissions: permissions,
isAdmin: isAdmin,
tag: tag,
hasPassword: hasPassword,
);
}
/// Update room login state after failed login
void handleLoginFail({
required Uint8List publicKeyPrefix,
}) {
final prefixHex = _publicKeyPrefixToHex(publicKeyPrefix);
_roomLoginStates[prefixHex] = RoomLoginState.loggedOut(
publicKeyPrefix: publicKeyPrefix,
hasPassword: false, // Password was incorrect
);
}
/// Clear all room login states (call on disconnect)
void clearRoomLoginStates() {
_roomLoginStates.clear();
}
/// Check if a password exists for a room (by public key prefix)
Future<bool> _hasPasswordForRoom(Uint8List publicKeyPrefix) async {
try {
final prefs = await SharedPreferences.getInstance();
// Convert prefix to hex string for storage key
final prefixHex = _publicKeyPrefixToHex(publicKeyPrefix);
final roomKey = 'room_password_$prefixHex';
return prefs.getString(roomKey) != null;
} catch (e) {
debugPrint('Error checking password for room: $e');
return false;
}
}
/// Convert public key prefix to hex string (colon-separated)
String _publicKeyPrefixToHex(Uint8List publicKeyPrefix) {
return publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
}
}

View File

@@ -0,0 +1,433 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/location_trail.dart';
import '../models/map_drawing.dart';
class MapProvider with ChangeNotifier {
LatLng? _targetLocation;
double? _targetZoom;
bool _shouldAnimate = false;
// Track which contact paths are currently visible
final Set<String> _visibleContactPaths = {};
// Location trail tracking
LocationTrail? _currentTrail;
bool _isTrailVisible = true;
final List<LocationTrail> _trailHistory = [];
// WMS overlay toggles
bool _showCadastralOverlay = false;
bool _showForestRoadsOverlay = false;
bool _showHikingTrailsOverlay = false;
bool _showMainRoadsOverlay = false;
bool _showHouseNumbersOverlay = false;
bool _showFireHazardZonesOverlay = false;
bool _showHistoricalFiresOverlay = false;
bool _showFirebreaksOverlay = false;
bool _showKrasFireZonesOverlay = false;
bool _showPlaceNamesOverlay = false;
bool _showMunicipalityBordersOverlay = false;
// Contact trail toggles
bool _showAllContactTrails = true; // Default to showing all contact trails
// Imported trail (from GPX)
LocationTrail? _importedTrail;
// Download area selection
bool _isSelectingDownloadArea = false;
LatLngBounds? _downloadAreaBounds;
LatLng? get targetLocation => _targetLocation;
double? get targetZoom => _targetZoom;
bool get shouldAnimate => _shouldAnimate;
Set<String> get visibleContactPaths => Set.unmodifiable(_visibleContactPaths);
// Trail getters
LocationTrail? get currentTrail => _currentTrail;
bool get isTrailVisible => _isTrailVisible;
List<LocationTrail> get trailHistory => List.unmodifiable(_trailHistory);
bool get isTrailActive => _currentTrail?.isActive ?? false;
// WMS overlay getters
bool get showCadastralOverlay => _showCadastralOverlay;
bool get showForestRoadsOverlay => _showForestRoadsOverlay;
bool get showHikingTrailsOverlay => _showHikingTrailsOverlay;
bool get showMainRoadsOverlay => _showMainRoadsOverlay;
bool get showHouseNumbersOverlay => _showHouseNumbersOverlay;
bool get showFireHazardZonesOverlay => _showFireHazardZonesOverlay;
bool get showHistoricalFiresOverlay => _showHistoricalFiresOverlay;
bool get showFirebreaksOverlay => _showFirebreaksOverlay;
bool get showKrasFireZonesOverlay => _showKrasFireZonesOverlay;
bool get showPlaceNamesOverlay => _showPlaceNamesOverlay;
bool get showMunicipalityBordersOverlay => _showMunicipalityBordersOverlay;
// Contact trail getters
bool get showAllContactTrails => _showAllContactTrails;
// Imported trail getters
LocationTrail? get importedTrail => _importedTrail;
// Download area getters
bool get isSelectingDownloadArea => _isSelectingDownloadArea;
LatLngBounds? get downloadAreaBounds => _downloadAreaBounds;
void navigateToLocation({
required LatLng location,
double zoom = 15.0,
bool animate = true,
}) {
_targetLocation = location;
_targetZoom = zoom;
_shouldAnimate = animate;
notifyListeners();
}
void clearNavigation() {
_targetLocation = null;
_targetZoom = null;
_shouldAnimate = false;
// Don't notify listeners to avoid rebuilds
}
/// Navigate to a drawing by its ID
void navigateToDrawing(String drawingId, dynamic drawingProvider) {
debugPrint('🗺️ [MapProvider] navigateToDrawing called with ID: $drawingId');
// Find the drawing in the provider
final drawings = drawingProvider.drawings as List;
debugPrint('🗺️ [MapProvider] Total drawings in provider: ${drawings.length}');
final drawing = drawings.cast<dynamic>().firstWhere(
(d) => d.id == drawingId,
orElse: () => null,
);
if (drawing == null) {
debugPrint('⚠️ [MapProvider] Drawing $drawingId not found');
debugPrint('⚠️ [MapProvider] Available drawing IDs: ${drawings.map((d) => d.id).toList()}');
return;
}
// Use MapDrawing's built-in getCenter and getBounds methods
final center = drawing.getCenter();
final bounds = drawing.getBounds();
// Calculate appropriate zoom level based on bounds
// For larger drawings, use lower zoom to fit the whole drawing
// For smaller drawings, use higher zoom for better detail
final latDiff = (bounds.north - bounds.south).abs();
final lonDiff = (bounds.east - bounds.west).abs();
final maxDiff = latDiff > lonDiff ? latDiff : lonDiff;
// Zoom scale: smaller drawings get higher zoom
// 0.001 degrees (~100m) -> zoom 17
// 0.005 degrees (~500m) -> zoom 16
// 0.01 degrees (~1km) -> zoom 15
// 0.05 degrees (~5km) -> zoom 13
// 0.1 degrees (~10km) -> zoom 12
double zoom = 15.0;
if (maxDiff < 0.001) {
zoom = 17.0;
} else if (maxDiff < 0.005) {
zoom = 16.0;
} else if (maxDiff < 0.01) {
zoom = 15.0;
} else if (maxDiff < 0.05) {
zoom = 13.0;
} else if (maxDiff < 0.1) {
zoom = 12.0;
} else {
zoom = 10.0;
}
final typeStr = drawing is LineDrawing ? 'line' : 'rectangle';
debugPrint('🗺️ [MapProvider] Navigating to drawing: $typeStr, zoom: $zoom');
navigateToLocation(location: center, zoom: zoom, animate: true);
}
void updateZoom(double zoom) {
_targetZoom = zoom;
notifyListeners();
}
/// Toggle path visibility for a contact
void toggleContactPath(String publicKeyHex) {
if (_visibleContactPaths.contains(publicKeyHex)) {
_visibleContactPaths.remove(publicKeyHex);
} else {
_visibleContactPaths.add(publicKeyHex);
}
notifyListeners();
}
/// Check if a contact's path is visible
bool isContactPathVisible(String publicKeyHex) {
return _visibleContactPaths.contains(publicKeyHex);
}
/// Hide all contact paths
void hideAllPaths() {
_visibleContactPaths.clear();
notifyListeners();
}
/// Show path for specific contact (hide all others)
void showOnlyPath(String publicKeyHex) {
_visibleContactPaths.clear();
_visibleContactPaths.add(publicKeyHex);
notifyListeners();
}
/// Start a new location trail
void startTrail() {
// End current trail if active
if (_currentTrail != null && _currentTrail!.isActive) {
endTrail();
}
_currentTrail = LocationTrail(
id: DateTime.now().millisecondsSinceEpoch.toString(),
startTime: DateTime.now(),
);
_isTrailVisible = true;
notifyListeners();
}
/// Add a point to the current trail
void addTrailPoint(LatLng position, {double? accuracy, double? speed}) {
if (_currentTrail == null || !_currentTrail!.isActive) {
startTrail();
}
_currentTrail!.addPoint(TrailPoint(
position: position,
timestamp: DateTime.now(),
accuracy: accuracy,
speed: speed,
));
notifyListeners();
}
/// End the current trail
void endTrail() {
if (_currentTrail != null) {
_currentTrail!.isActive = false;
_currentTrail!.endTime = DateTime.now();
if (_currentTrail!.points.isNotEmpty) {
_trailHistory.add(_currentTrail!);
}
_currentTrail = null;
notifyListeners();
}
}
/// Toggle trail visibility
void toggleTrailVisibility() {
_isTrailVisible = !_isTrailVisible;
notifyListeners();
}
/// Clear the current trail
void clearCurrentTrail() {
if (_currentTrail != null) {
_currentTrail = null;
notifyListeners();
}
}
/// Clear all trail history
void clearAllTrails() {
_currentTrail = null;
_trailHistory.clear();
notifyListeners();
}
/// Get total trail distance in meters
double get totalTrailDistance {
if (_currentTrail == null) return 0;
return _currentTrail!.totalDistance;
}
/// Get trail duration
Duration get trailDuration {
if (_currentTrail == null) return Duration.zero;
return _currentTrail!.duration;
}
/// Toggle cadastral parcels overlay
Future<void> toggleCadastralOverlay() async {
_showCadastralOverlay = !_showCadastralOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle forest roads overlay
Future<void> toggleForestRoadsOverlay() async {
_showForestRoadsOverlay = !_showForestRoadsOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle hiking trails overlay
Future<void> toggleHikingTrailsOverlay() async {
_showHikingTrailsOverlay = !_showHikingTrailsOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle main roads overlay
Future<void> toggleMainRoadsOverlay() async {
_showMainRoadsOverlay = !_showMainRoadsOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle house numbers overlay
Future<void> toggleHouseNumbersOverlay() async {
_showHouseNumbersOverlay = !_showHouseNumbersOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle fire hazard zones overlay
Future<void> toggleFireHazardZonesOverlay() async {
_showFireHazardZonesOverlay = !_showFireHazardZonesOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle historical fires overlay
Future<void> toggleHistoricalFiresOverlay() async {
_showHistoricalFiresOverlay = !_showHistoricalFiresOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle firebreaks overlay
Future<void> toggleFirebreaksOverlay() async {
_showFirebreaksOverlay = !_showFirebreaksOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle Kras fire zones overlay
Future<void> toggleKrasFireZonesOverlay() async {
_showKrasFireZonesOverlay = !_showKrasFireZonesOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle place names overlay
Future<void> togglePlaceNamesOverlay() async {
_showPlaceNamesOverlay = !_showPlaceNamesOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle municipality borders overlay
Future<void> toggleMunicipalityBordersOverlay() async {
_showMunicipalityBordersOverlay = !_showMunicipalityBordersOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Load overlay state from SharedPreferences
Future<void> loadOverlayState() async {
final prefs = await SharedPreferences.getInstance();
_showCadastralOverlay = prefs.getBool('map_show_cadastral_overlay') ?? false;
_showForestRoadsOverlay = prefs.getBool('map_show_forest_roads_overlay') ?? false;
_showHikingTrailsOverlay = prefs.getBool('map_show_hiking_trails_overlay') ?? false;
_showMainRoadsOverlay = prefs.getBool('map_show_main_roads_overlay') ?? false;
_showHouseNumbersOverlay = prefs.getBool('map_show_house_numbers_overlay') ?? false;
_showFireHazardZonesOverlay = prefs.getBool('map_show_fire_hazard_zones_overlay') ?? false;
_showHistoricalFiresOverlay = prefs.getBool('map_show_historical_fires_overlay') ?? false;
_showFirebreaksOverlay = prefs.getBool('map_show_firebreaks_overlay') ?? false;
_showKrasFireZonesOverlay = prefs.getBool('map_show_kras_fire_zones_overlay') ?? false;
_showPlaceNamesOverlay = prefs.getBool('map_show_place_names_overlay') ?? false;
_showMunicipalityBordersOverlay = prefs.getBool('map_show_municipality_borders_overlay') ?? false;
notifyListeners();
}
/// Save overlay state to SharedPreferences
Future<void> _saveOverlayState() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('map_show_cadastral_overlay', _showCadastralOverlay);
await prefs.setBool('map_show_forest_roads_overlay', _showForestRoadsOverlay);
await prefs.setBool('map_show_hiking_trails_overlay', _showHikingTrailsOverlay);
await prefs.setBool('map_show_main_roads_overlay', _showMainRoadsOverlay);
await prefs.setBool('map_show_house_numbers_overlay', _showHouseNumbersOverlay);
await prefs.setBool('map_show_fire_hazard_zones_overlay', _showFireHazardZonesOverlay);
await prefs.setBool('map_show_historical_fires_overlay', _showHistoricalFiresOverlay);
await prefs.setBool('map_show_firebreaks_overlay', _showFirebreaksOverlay);
await prefs.setBool('map_show_kras_fire_zones_overlay', _showKrasFireZonesOverlay);
await prefs.setBool('map_show_place_names_overlay', _showPlaceNamesOverlay);
await prefs.setBool('map_show_municipality_borders_overlay', _showMunicipalityBordersOverlay);
}
/// Toggle all contact trails on/off
Future<void> toggleAllContactTrails() async {
_showAllContactTrails = !_showAllContactTrails;
notifyListeners();
await _saveTrailSettings();
}
/// Load trail settings from SharedPreferences
Future<void> loadTrailSettings() async {
final prefs = await SharedPreferences.getInstance();
_showAllContactTrails = prefs.getBool('map_show_all_contact_trails') ?? true; // Default to true (show all)
notifyListeners();
}
/// Save trail settings to SharedPreferences
Future<void> _saveTrailSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails);
}
/// Set imported trail (from GPX import)
void setImportedTrail(LocationTrail trail) {
_importedTrail = trail;
notifyListeners();
}
/// Clear imported trail
void clearImportedTrail() {
_importedTrail = null;
notifyListeners();
}
/// Replace current trail with imported trail
void replaceCurrentTrailWithImport(LocationTrail importedTrail) {
// End current trail if active
if (_currentTrail != null && _currentTrail!.isActive) {
endTrail();
}
// Set imported trail as current trail
_currentTrail = importedTrail;
_isTrailVisible = true;
notifyListeners();
}
/// Enter download area selection mode with initial bounds
void enterDownloadAreaMode(LatLngBounds initialBounds) {
_isSelectingDownloadArea = true;
_downloadAreaBounds = initialBounds;
notifyListeners();
}
/// Exit download area selection mode
void exitDownloadAreaMode() {
_isSelectingDownloadArea = false;
_downloadAreaBounds = null;
notifyListeners();
}
/// Update the download area bounds (while dragging/resizing)
void updateDownloadAreaBounds(LatLngBounds bounds) {
_downloadAreaBounds = bounds;
notifyListeners();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,324 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import '../l10n/app_localizations.dart';
import '../providers/contacts_provider.dart';
import '../providers/app_provider.dart';
import '../providers/connection_provider.dart';
import '../widgets/contacts/contact_tile.dart';
import '../widgets/contacts/add_channel_dialog.dart';
class ContactsTab extends StatefulWidget {
final VoidCallback? onNavigateToMap;
const ContactsTab({super.key, this.onNavigateToMap});
@override
State<ContactsTab> createState() => _ContactsTabState();
}
class _ContactsTabState extends State<ContactsTab> {
Position? _currentPosition;
@override
void initState() {
super.initState();
_getCurrentLocation();
// Mark all contacts as viewed when tab is opened
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<ContactsProvider>().markAllAsViewed();
});
}
Future<void> _getCurrentLocation() async {
try {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: 0,
),
);
if (mounted) {
setState(() {
_currentPosition = position;
});
}
} catch (e) {
// Silently fail if location not available
debugPrint('Failed to get location: $e');
}
}
Future<void> _handleRefresh() async {
if (!mounted) return;
final appProvider = context.read<AppProvider>();
await appProvider.refresh();
// Also refresh location
if (!mounted) return;
await _getCurrentLocation();
}
/// Calculate distance between two points in meters
double _calculateDistanceInMeters(
double lat1,
double lon1,
double lat2,
double lon2,
) {
const R = 6371000; // Earth's radius in meters
final dLat = (lat2 - lat1) * pi / 180;
final dLon = (lon2 - lon1) * pi / 180;
final a =
sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * pi / 180) *
cos(lat2 * pi / 180) *
sin(dLon / 2) *
sin(dLon / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
return R * c;
}
/// Format distance for display
String _formatDistance(double meters) {
if (meters < 1000) {
return '${meters.round()}m';
} else if (meters < 10000) {
return '${(meters / 1000).toStringAsFixed(2)}km';
} else {
return '${(meters / 1000).toStringAsFixed(1)}km';
}
}
/// Show the add channel dialog
Future<void> _showAddChannelDialog(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
await showDialog(
context: context,
builder: (context) => AddChannelDialog(
onCreateChannel: (name, secret) async {
final connectionProvider = context.read<ConnectionProvider>();
try {
await connectionProvider.createChannel(
channelName: name,
channelSecret: secret,
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.channelCreatedSuccessfully),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.channelCreationFailed(e.toString())),
backgroundColor: Colors.red,
),
);
}
rethrow; // Re-throw to let dialog handle the error state
}
},
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
body: Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final chatContacts = contactsProvider.chatContacts;
final repeaters = contactsProvider.repeaters;
final rooms = contactsProvider.rooms;
final channels = contactsProvider.channels;
// Check if there are any displayable contacts (excluding channels)
final hasDisplayableContacts = chatContacts.isNotEmpty ||
repeaters.isNotEmpty ||
rooms.isNotEmpty;
if (!hasDisplayableContacts) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.contacts_outlined,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
l10n.noContactsYet,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
l10n.connectToDeviceToLoadContacts,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
);
}
return RefreshIndicator(
onRefresh: _handleRefresh,
child: ListView(
padding: const EdgeInsets.all(8),
children: [
// Team Members (Chat contacts)
if (chatContacts.isNotEmpty) ...[
_SectionHeader(
title: l10n.teamMembers,
count: chatContacts.length,
icon: Icons.people,
),
...chatContacts.map(
(contact) => ContactTile(
contact: contact,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
),
),
const Divider(height: 32),
],
// Repeaters
if (repeaters.isNotEmpty) ...[
_SectionHeader(
title: l10n.repeaters,
count: repeaters.length,
icon: Icons.router,
),
...repeaters.map(
(contact) => ContactTile(
contact: contact,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
),
),
const Divider(height: 32),
],
// Rooms
if (rooms.isNotEmpty) ...[
_SectionHeader(
title: l10n.rooms,
count: rooms.length,
icon: Icons.tag,
),
...rooms.map(
(contact) => ContactTile(
contact: contact,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
),
),
const Divider(height: 32),
],
// Channels (visible in both simple and advanced mode)
_SectionHeader(
title: l10n.channels,
count: channels.length,
icon: Icons.broadcast_on_personal,
),
if (channels.isNotEmpty) ...[
...channels.map(
(contact) => ContactTile(
contact: contact,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
),
),
],
// Add Channel Button (visible in both simple and advanced mode, only show when connected)
if (context.watch<ConnectionProvider>().deviceInfo.isConnected)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: OutlinedButton.icon(
onPressed: () => _showAddChannelDialog(context),
icon: const Icon(Icons.add_circle_outline),
label: Text(l10n.addChannel),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
),
],
),
);
},
),
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
final int count;
final IconData icon;
const _SectionHeader({
required this.title,
required this.count,
required this.icon,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(icon, size: 20),
const SizedBox(width: 8),
Text(
title,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
count.toString(),
style: Theme.of(context).textTheme.labelSmall,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,834 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import '../providers/connection_provider.dart';
import '../services/validation_service.dart';
import '../l10n/app_localizations.dart';
class DeviceConfigScreen extends StatefulWidget {
const DeviceConfigScreen({super.key});
@override
State<DeviceConfigScreen> createState() => _DeviceConfigScreenState();
}
class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
late TextEditingController _nameController;
late TextEditingController _latController;
late TextEditingController _lonController;
late TextEditingController _freqController;
late TextEditingController _txPowerController;
bool _telemetryEnabled = false;
String _selectedBandwidth = '62.5 kHz';
int _selectedSpreadingFactor = 8;
int _selectedCodingRate = 8;
final List<String> _bandwidthOptions = [
'7.8 kHz',
'10.4 kHz',
'15.6 kHz',
'20.8 kHz',
'31.25 kHz',
'41.7 kHz',
'62.5 kHz',
'125 kHz',
'250 kHz',
'500 kHz',
];
@override
void initState() {
super.initState();
final deviceInfo = context.read<ConnectionProvider>().deviceInfo;
_nameController = TextEditingController(
text: deviceInfo.selfName ?? deviceInfo.deviceName ?? '',
);
_latController = TextEditingController(
text: deviceInfo.advLat != null
? (deviceInfo.advLat! / 1000000).toStringAsFixed(6)
: '0.0',
);
_lonController = TextEditingController(
text: deviceInfo.advLon != null
? (deviceInfo.advLon! / 1000000).toStringAsFixed(6)
: '0.0',
);
_freqController = TextEditingController(
text: deviceInfo.radioFreq != null
? (deviceInfo.radioFreq! / 1000).toStringAsFixed(3)
: '869.618',
);
_txPowerController = TextEditingController(
text: deviceInfo.txPower?.toString() ?? '20',
);
if (deviceInfo.radioBw != null &&
deviceInfo.radioBw! >= 0 &&
deviceInfo.radioBw! <= 9) {
_selectedBandwidth = _bandwidthFromValue(deviceInfo.radioBw!);
}
if (deviceInfo.radioSf != null &&
deviceInfo.radioSf! >= 7 &&
deviceInfo.radioSf! <= 12) {
_selectedSpreadingFactor = deviceInfo.radioSf!;
}
if (deviceInfo.radioCr != null &&
deviceInfo.radioCr! >= 5 &&
deviceInfo.radioCr! <= 8) {
_selectedCodingRate = deviceInfo.radioCr!;
}
// Check if telemetry is enabled (check if lat/lon are set and not zero)
_telemetryEnabled =
(deviceInfo.advLat != null && deviceInfo.advLat! != 0) ||
(deviceInfo.advLon != null && deviceInfo.advLon! != 0);
}
@override
void dispose() {
_nameController.dispose();
_latController.dispose();
_lonController.dispose();
_freqController.dispose();
_txPowerController.dispose();
super.dispose();
}
String _bandwidthFromValue(int bw) {
switch (bw) {
case 0:
return '7.8 kHz';
case 1:
return '10.4 kHz';
case 2:
return '15.6 kHz';
case 3:
return '20.8 kHz';
case 4:
return '31.25 kHz';
case 5:
return '41.7 kHz';
case 6:
return '62.5 kHz';
case 7:
return '125 kHz';
case 8:
return '250 kHz';
case 9:
return '500 kHz';
default:
return '62.5 kHz';
}
}
int _bandwidthToValue(String bw) {
return _bandwidthOptions.indexOf(bw);
}
Future<void> _savePublicInfo() async {
final connectionProvider = context.read<ConnectionProvider>();
final deviceInfo = connectionProvider.deviceInfo;
final validator = ValidationService();
try {
// Save name
if (_nameController.text.isNotEmpty) {
await connectionProvider.setAdvertName(_nameController.text);
}
// Save position and telemetry settings
if (_telemetryEnabled) {
// Parse and validate coordinates
final latResult = validator.parseLatitude(_latController.text);
if (!latResult.isSuccess) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(latResult.errorMessage!),
backgroundColor: Colors.red,
),
);
}
return;
}
final lonResult = validator.parseLongitude(_lonController.text);
if (!lonResult.isSuccess) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(lonResult.errorMessage!),
backgroundColor: Colors.red,
),
);
}
return;
}
await connectionProvider.setAdvertLatLon(
latitude: latResult.value!,
longitude: lonResult.value!,
);
// Set telemetry modes to "Allow All" (mode 2 for both base and location)
final telemetryModes = 0x0A; // binary: 00001010 (base=2, location=2)
await connectionProvider.setOtherParams(
manualAddContacts: deviceInfo.manualAddContacts == true ? 1 : 0,
telemetryModes: telemetryModes,
advertLocationPolicy: 1,
);
} else {
// Clear position
await connectionProvider.setAdvertLatLon(latitude: 0.0, longitude: 0.0);
// Set telemetry modes to "Deny" (mode 0)
final telemetryModes = 0x00;
await connectionProvider.setOtherParams(
manualAddContacts: deviceInfo.manualAddContacts == true ? 1 : 0,
telemetryModes: telemetryModes,
advertLocationPolicy: 0,
);
}
// Refetch device info to update UI with new settings
await connectionProvider.refreshDeviceInfo();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.save),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.failedToSave(e.toString()),
),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _saveRadioSettings() async {
final connectionProvider = context.read<ConnectionProvider>();
final validator = ValidationService();
final deviceInfo = connectionProvider.deviceInfo;
try {
// Parse and validate frequency
final freqResult = validator.parseFrequency(_freqController.text);
if (!freqResult.isSuccess) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(freqResult.errorMessage!),
backgroundColor: Colors.red,
),
);
}
return;
}
// Parse and validate TX power
final txPowerResult = validator.parseTxPower(
_txPowerController.text,
maxPower: deviceInfo.maxTxPower,
);
if (!txPowerResult.isSuccess) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(txPowerResult.errorMessage!),
backgroundColor: Colors.red,
),
);
}
return;
}
// Convert from MHz to kHz for protocol
final freqKhz = (freqResult.value! * 1000).round();
await connectionProvider.setRadioParams(
frequency: freqKhz,
bandwidth: _bandwidthToValue(_selectedBandwidth),
spreadingFactor: _selectedSpreadingFactor,
codingRate: _selectedCodingRate,
);
// Save TX power
await connectionProvider.setTxPower(txPowerResult.value!);
// Refetch device info to update UI with new settings
await connectionProvider.refreshDeviceInfo();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.save),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.failedToSave(e.toString()),
),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _useCurrentLocation() async {
try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.locationServicesDisabled,
),
),
);
}
return;
}
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.locationPermissionDenied,
),
),
);
}
return;
}
}
if (permission == LocationPermission.deniedForever) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(
context,
)!.locationPermissionPermanentlyDenied,
),
),
);
}
return;
}
Position position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.best,
),
);
if (!mounted) return;
setState(() {
_latController.text = position.latitude.toStringAsFixed(6);
_lonController.text = position.longitude.toStringAsFixed(6);
_telemetryEnabled = true;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.locationBroadcast(
position.latitude.toStringAsFixed(6),
position.longitude.toStringAsFixed(6),
),
),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.failedToGetLocation(e.toString()),
),
),
);
}
}
}
@override
Widget build(BuildContext context) {
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// Device Info Card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.deviceInformation,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
_InfoRow(
AppLocalizations.of(context)!.bleName,
deviceInfo.deviceName ??
AppLocalizations.of(context)!.unknown,
),
_InfoRow(
AppLocalizations.of(context)!.meshName,
deviceInfo.selfName ?? AppLocalizations.of(context)!.notSet,
),
_InfoRow(
AppLocalizations.of(context)!.type,
_getDeviceTypeString(context, deviceInfo.deviceType),
),
_InfoRow(
AppLocalizations.of(context)!.model,
deviceInfo.manufacturerModel ??
AppLocalizations.of(context)!.unknown,
),
_InfoRow(
AppLocalizations.of(context)!.version,
deviceInfo.semanticVersion ??
AppLocalizations.of(context)!.unknown,
),
_InfoRow(
AppLocalizations.of(context)!.buildDate,
deviceInfo.firmwareBuildDate ??
AppLocalizations.of(context)!.unknown,
),
_InfoRow(
AppLocalizations.of(context)!.firmware,
'v${deviceInfo.firmwareVersion?.toString() ?? "?"}',
),
_InfoRow(
AppLocalizations.of(context)!.maxContacts,
deviceInfo.maxContacts?.toString() ??
AppLocalizations.of(context)!.unknown,
),
_InfoRow(
AppLocalizations.of(context)!.maxChannels,
deviceInfo.maxChannels?.toString() ??
AppLocalizations.of(context)!.unknown,
),
_CopyableInfoRow(
AppLocalizations.of(context)!.publicKey,
_getPublicKeyHex(deviceInfo.publicKey),
),
],
),
),
),
const SizedBox(height: 24),
// Public Info Section
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
AppLocalizations.of(context)!.publicInfo,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: 8),
IconButton.filled(
onPressed: _savePublicInfo,
icon: const Icon(Icons.save),
tooltip: AppLocalizations.of(context)!.save,
),
],
),
],
),
const SizedBox(height: 16),
// Mesh Network Name
TextField(
controller: _nameController,
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.meshNetworkName,
border: const OutlineInputBorder(),
helperText: AppLocalizations.of(
context,
)!.nameBroadcastInMesh,
),
),
const SizedBox(height: 8),
// Telemetry Toggle - Compact version
Row(
children: [
Expanded(
child: Text(
AppLocalizations.of(
context,
)!.telemetryAndLocationSharing,
style: theme.textTheme.bodyMedium,
),
),
Switch(
value: _telemetryEnabled,
onChanged: (value) {
setState(() {
_telemetryEnabled = value;
});
},
),
],
),
// GPS Coordinates (only show if telemetry enabled)
if (_telemetryEnabled) ...[
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _latController,
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.lat,
border: const OutlineInputBorder(),
isDense: true,
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _lonController,
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.lon,
border: const OutlineInputBorder(),
isDense: true,
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
),
),
const SizedBox(width: 8),
IconButton.filled(
onPressed: _useCurrentLocation,
icon: const Icon(Icons.my_location, size: 20),
tooltip: AppLocalizations.of(
context,
)!.useCurrentLocation,
),
],
),
],
],
),
),
),
const SizedBox(height: 24),
// Radio Settings Section
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
AppLocalizations.of(context)!.radioSettings,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
IconButton.filled(
onPressed: _saveRadioSettings,
icon: const Icon(Icons.save),
tooltip: AppLocalizations.of(context)!.save,
),
],
),
const SizedBox(height: 16),
// LoRa Frequency
TextField(
controller: _freqController,
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.frequencyMHz,
border: const OutlineInputBorder(),
helperText: AppLocalizations.of(
context,
)!.frequencyExample,
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
),
const SizedBox(height: 16),
// Bandwidth
DropdownButtonFormField<String>(
initialValue: _selectedBandwidth,
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.bandwidth,
border: const OutlineInputBorder(),
),
items: _bandwidthOptions.map((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
setState(() {
_selectedBandwidth = newValue;
});
}
},
),
const SizedBox(height: 16),
// Spreading Factor
DropdownButtonFormField<int>(
initialValue: _selectedSpreadingFactor,
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.spreadingFactor,
border: const OutlineInputBorder(),
),
items: List.generate(6, (index) => index + 7).map((
int value,
) {
return DropdownMenuItem<int>(
value: value,
child: Text(value.toString()),
);
}).toList(),
onChanged: (int? newValue) {
if (newValue != null) {
setState(() {
_selectedSpreadingFactor = newValue;
});
}
},
),
const SizedBox(height: 16),
// Coding Rate
DropdownButtonFormField<int>(
initialValue: _selectedCodingRate,
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.codingRate,
border: const OutlineInputBorder(),
),
items: List.generate(4, (index) => index + 5).map((
int value,
) {
return DropdownMenuItem<int>(
value: value,
child: Text(value.toString()),
);
}).toList(),
onChanged: (int? newValue) {
if (newValue != null) {
setState(() {
_selectedCodingRate = newValue;
});
}
},
),
const SizedBox(height: 16),
// TX Power
TextField(
controller: _txPowerController,
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.txPowerDbm,
border: const OutlineInputBorder(),
helperText: AppLocalizations.of(
context,
)!.maxPowerDbm(deviceInfo.maxTxPower ?? 22),
),
keyboardType: TextInputType.number,
),
],
),
),
),
const SizedBox(height: 24),
],
),
);
}
String _getDeviceTypeString(BuildContext context, int? deviceType) {
if (deviceType == null) return AppLocalizations.of(context)!.unknown;
switch (deviceType) {
case 0:
return AppLocalizations.of(context)!.noneUnknown;
case 1:
return AppLocalizations.of(context)!.chatNode;
case 2:
return AppLocalizations.of(context)!.repeater;
case 3:
return AppLocalizations.of(context)!.roomChannel;
default:
return AppLocalizations.of(context)!.typeNumber(deviceType);
}
}
String _getPublicKeyHex(List<int>? publicKey) {
if (publicKey == null || publicKey.isEmpty) return 'N/A';
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;
const _InfoRow(this.label, this.value);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: const TextStyle(
fontWeight: FontWeight.w500,
color: Colors.grey,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
);
}
}
class _CopyableInfoRow extends StatelessWidget {
final String label;
final String value;
const _CopyableInfoRow(this.label, this.value);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: const TextStyle(
fontWeight: FontWeight.w500,
color: Colors.grey,
),
),
),
Expanded(
child: GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: value));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(
context,
)!.copiedToClipboardShort(label),
),
duration: const Duration(seconds: 2),
backgroundColor: Colors.green,
),
);
},
child: Row(
children: [
Expanded(
child: Text(
value,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontFamily: 'monospace',
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
const Icon(Icons.copy, size: 16, color: Colors.grey),
],
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,780 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:vibration/vibration.dart';
import '../providers/connection_provider.dart';
import '../providers/app_provider.dart';
import '../providers/messages_provider.dart';
import '../providers/contacts_provider.dart';
import '../theme/app_theme.dart';
import 'messages_tab.dart';
import 'contacts_tab.dart';
import 'map_tab.dart';
import 'map_management_screen.dart';
import 'settings_screen.dart';
import 'device_config_screen.dart';
import 'packet_log_screen.dart';
import '../utils/toast_logger.dart';
import '../l10n/app_localizations.dart';
import '../widgets/permission_request_dialog.dart';
import '../widgets/connection_dialog.dart';
import '../utils/battery_display_helper.dart';
class HomeScreen extends StatefulWidget {
final Function(AppThemeMode) onThemeChanged;
final Function(Locale?) onLocaleChanged;
final AppThemeMode currentTheme;
final Locale? currentLocale;
final bool shouldShowPermissionDialog;
const HomeScreen({
super.key,
required this.onThemeChanged,
required this.onLocaleChanged,
required this.currentTheme,
required this.currentLocale,
this.shouldShowPermissionDialog = false,
});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
int _currentIndex = 0;
bool _isMapFullscreen = false;
bool _showRxTxIndicators = true;
bool _isMapEnabled = true;
@override
void initState() {
super.initState();
_loadMapEnabledAndInitTabs();
_loadRxTxPreference();
// Show permission dialog after the first frame if needed
if (widget.shouldShowPermissionDialog) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_showPermissionDialog();
});
}
}
Future<void> _loadMapEnabledAndInitTabs() async {
final prefs = await SharedPreferences.getInstance();
final mapEnabled = prefs.getBool('map_enabled') ?? true;
if (mapEnabled != _isMapEnabled) {
_isMapEnabled = mapEnabled;
}
_initTabController();
if (mounted) {
setState(() {});
}
}
void _initTabController() {
final tabCount = _isMapEnabled ? 3 : 2;
_tabController = TabController(length: tabCount, vsync: this);
_tabController.addListener(_onTabChanged);
}
void _onTabChanged() {
setState(() {
_currentIndex = _tabController.index;
// Exit fullscreen when switching away from map tab (only if map is enabled and is tab 2)
if (_isMapEnabled && _currentIndex != 2) {
_isMapFullscreen = false;
}
});
}
void _updateTabController(bool mapEnabled) {
if (_isMapEnabled == mapEnabled) return;
// Save current index before rebuilding
final oldIndex = _tabController.index;
// Remove old listener and dispose
_tabController.removeListener(_onTabChanged);
_tabController.dispose();
// Update state
_isMapEnabled = mapEnabled;
// Create new controller
final tabCount = mapEnabled ? 3 : 2;
_tabController = TabController(length: tabCount, vsync: this);
_tabController.addListener(_onTabChanged);
// Restore index (clamp to valid range)
if (oldIndex < tabCount) {
_tabController.index = oldIndex;
_currentIndex = oldIndex;
} else {
_currentIndex = tabCount - 1;
}
setState(() {});
}
Future<void> _loadRxTxPreference() async {
final prefs = await SharedPreferences.getInstance();
if (mounted) {
setState(() {
_showRxTxIndicators = prefs.getBool('show_rx_tx_indicators') ?? true;
});
}
}
@override
void dispose() {
_tabController.removeListener(_onTabChanged);
_tabController.dispose();
super.dispose();
}
void _showPermissionDialog() {
if (!mounted) return;
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => PermissionRequestDialog(
onPermissionsGranted: () {
debugPrint('✅ Location permissions granted');
},
onPermissionsDenied: () {
debugPrint('⚠️ Location permissions denied');
// Show a snackbar to inform the user
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.locationPermissionRequired,
),
duration: const Duration(seconds: 5),
),
);
}
},
),
);
}
Future<void> _advertiseDevice(BuildContext context) async {
final connectionProvider = context.read<ConnectionProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
if (context.mounted) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.deviceNotConnected,
);
}
return;
}
try {
// Check if location services are enabled
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
if (context.mounted) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.locationServicesDisabled,
);
}
return;
}
// Check location permissions
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
if (context.mounted) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.locationPermissionDenied,
);
}
return;
}
}
if (permission == LocationPermission.deniedForever) {
if (context.mounted) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.locationPermissionPermanentlyDenied,
);
}
return;
}
// Get current GPS position
Position? position;
try {
position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: 0,
),
).timeout(const Duration(seconds: 5));
} catch (e) {
debugPrint('❌ Failed to get GPS position: $e');
if (context.mounted) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.failedToGetGpsLocation,
);
}
return;
}
// Update lat/lon on device
await connectionProvider.setAdvertLatLon(
latitude: position.latitude,
longitude: position.longitude,
);
// Small delay to ensure the lat/lon is set
await Future.delayed(const Duration(milliseconds: 100));
// Send flood advertisement
await connectionProvider.sendSelfAdvert(floodMode: true);
} catch (e) {
debugPrint('❌ Failed to advertise device: $e');
if (context.mounted) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.failedToAdvertise(e.toString()),
);
}
}
}
void _showConnectionDialog(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => const ConnectionDialog(),
);
}
@override
Widget build(BuildContext context) {
// Set localizations for notifications
final messagesProvider = context.read<MessagesProvider>();
final localizations = AppLocalizations.of(context);
if (localizations != null) {
messagesProvider.setLocalizations(localizations);
}
// Check if map enabled setting changed and update tab controller
final appProvider = context.watch<AppProvider>();
if (_isMapEnabled != appProvider.isMapEnabled) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_updateTabController(appProvider.isMapEnabled);
});
}
// Determine if we should hide the UI (only in fullscreen on map tab)
final shouldHideUI = _isMapEnabled && _isMapFullscreen && _currentIndex == 2;
return Scaffold(
appBar: shouldHideUI
? null
: AppBar(
title: _buildCompactStatusBar(),
actions: [
Consumer<ConnectionProvider>(
builder: (context, provider, child) {
final isConnected = provider.deviceInfo.isConnected || provider.isSseClientConnected;
if (isConnected) {
return IconButton(
onPressed: () async {
await provider.disconnect();
},
icon: const Icon(Icons.power_settings_new),
tooltip: AppLocalizations.of(context)!.disconnect,
color: Colors.red.shade700,
);
}
return const SizedBox.shrink();
},
),
PopupMenuButton(
icon: const Icon(Icons.more_vert),
itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.map),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.mapManagement),
],
),
onTap: () {
// Capture context-dependent objects before async gap
final navigator = Navigator.of(context);
final appProvider = context.read<AppProvider>();
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) => MapManagementScreen(
tileCacheService: appProvider.tileCacheService,
),
),
);
});
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.settings),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.settings),
],
),
onTap: () {
// Capture context-dependent objects before async gap
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () async {
if (!mounted) return;
await navigator.push(
MaterialPageRoute(
builder: (context) => SettingsScreen(
onThemeChanged: widget.onThemeChanged,
onLocaleChanged: widget.onLocaleChanged,
currentTheme: widget.currentTheme,
currentLocale: widget.currentLocale,
),
),
);
// Reload preference when returning from settings
_loadRxTxPreference();
});
},
),
],
),
],
),
body: TabBarView(
controller: _tabController,
children: [
MessagesTab(
onNavigateToMap: _isMapEnabled
? () => _tabController.animateTo(2)
: null,
),
ContactsTab(
onNavigateToMap: _isMapEnabled
? () => _tabController.animateTo(2)
: null,
),
if (_isMapEnabled)
MapTab(
onFullscreenChanged: (isFullscreen) {
setState(() {
_isMapFullscreen = isFullscreen;
});
},
onNavigateToMessages: () => _tabController.animateTo(0),
),
],
),
bottomNavigationBar: shouldHideUI
? null
: Consumer2<MessagesProvider, ContactsProvider>(
builder: (context, messagesProvider, contactsProvider, child) {
final unreadCount = messagesProvider.unreadCount;
final newContactsCount = contactsProvider.newContactsCount;
return Container(
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: TabBar(
controller: _tabController,
tabs: [
Tab(
icon: _buildTabIconWithBadge(
Icons.message,
unreadCount,
),
text: AppLocalizations.of(context)!.messages,
),
Tab(
icon: _buildTabIconWithBadge(
Icons.contacts,
newContactsCount,
),
text: AppLocalizations.of(context)!.contacts,
),
if (_isMapEnabled)
Tab(
icon: const Icon(Icons.map),
text: AppLocalizations.of(context)!.map,
),
],
),
);
},
),
);
}
Widget _buildCompactStatusBar() {
return Consumer<ConnectionProvider>(
builder: (context, provider, child) {
final deviceInfo = provider.deviceInfo;
final isBleConnected = deviceInfo.isConnected;
final isSseConnected = provider.isSseClientConnected;
final isConnected = isBleConnected || isSseConnected;
if (!isConnected) {
// Disconnected state: show connect button
return Row(
children: [
Expanded(
child: Text(
AppLocalizations.of(context)!.appTitle,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
ElevatedButton.icon(
onPressed: provider.isReconnecting
? null
: () => _showConnectionDialog(context),
icon: provider.isReconnecting
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.black54,
),
),
)
: const Icon(Icons.bluetooth, size: 18),
label: Text(
provider.isReconnecting
? '${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts}'
: AppLocalizations.of(context)!.connect,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
),
if (provider.isReconnecting) ...[
const SizedBox(width: 8),
IconButton(
onPressed: () => provider.cancelReconnection(),
icon: const Icon(Icons.close, size: 20),
tooltip: AppLocalizations.of(context)!.cancelReconnection,
style: IconButton.styleFrom(
backgroundColor: Colors.red.shade700,
foregroundColor: Colors.white,
padding: const EdgeInsets.all(8),
),
),
],
],
);
}
// Connected state: LEFT | CENTER | RIGHT layout
return Row(
children: [
// LEFT: Name + BT/Battery + Cog
Expanded(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
deviceInfo.selfName ??
AppLocalizations.of(context)!.appTitle,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isSseConnected
? Icons.wifi
: Icons.bluetooth_connected,
color: isSseConnected
? Colors.green
: (deviceInfo.signalRssi != null
? BatteryDisplayHelper.getSignalColor(deviceInfo.signalRssi!)
: Colors.grey),
size: 13,
),
if (isBleConnected && deviceInfo.signalRssi != null) ...[
const SizedBox(width: 3),
Text(
'${deviceInfo.signalRssi}',
style: TextStyle(
fontSize: 11,
color: BatteryDisplayHelper.getSignalColor(
deviceInfo.signalRssi!,
),
),
),
],
if (isSseConnected && !isBleConnected) ...[
const SizedBox(width: 3),
Text(
'SSE',
style: const TextStyle(
fontSize: 11,
color: Colors.green,
),
),
],
if (deviceInfo.batteryPercent != null) ...[
const SizedBox(width: 8),
Icon(
BatteryDisplayHelper.getBatteryIcon(deviceInfo.batteryPercent!),
color: BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
size: 13,
),
const SizedBox(width: 3),
Text(
'${deviceInfo.batteryPercent!.round()}%',
style: TextStyle(
fontSize: 11,
color: BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
),
),
],
],
),
],
),
),
// Settings cog - hidden in simple mode
if (!context.watch<AppProvider>().isSimpleMode) ...[
const SizedBox(width: 8),
GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DeviceConfigScreen(),
),
);
},
onLongPress: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PacketLogScreen(
bleService: provider.bleService,
),
),
);
},
child: Container(
width: 32,
height: 32,
alignment: Alignment.center,
child: const Icon(Icons.settings, size: 18),
),
),
],
],
),
),
// CENTER: Broadcast button
const SizedBox(width: 8),
FilledButton(
onPressed: () async {
// Capture platform before async operations
final platform = Theme.of(context).platform;
// iOS: Use haptic feedback (always works)
// Android: Try vibration package for better control
try {
if (platform == TargetPlatform.iOS) {
// iOS: Try multiple haptic types for reliability
await HapticFeedback.lightImpact();
await Future.delayed(const Duration(milliseconds: 50));
await HapticFeedback.lightImpact();
} else {
// Android vibration
if (await Vibration.hasVibrator()) {
await Vibration.vibrate(duration: 50);
} else {
await HapticFeedback.mediumImpact();
}
}
} catch (e) {
// Fallback if anything fails
debugPrint('Haptic feedback error: $e');
await HapticFeedback.vibrate();
}
if (!mounted) return;
if (!context.mounted) return;
_advertiseDevice(context);
},
style: FilledButton.styleFrom(
backgroundColor: Colors.blue.shade700,
foregroundColor: Colors.white,
padding: const EdgeInsets.all(10),
minimumSize: const Size(40, 40),
shape: const CircleBorder(),
),
child: const Icon(Icons.campaign, size: 20),
),
const SizedBox(width: 8),
// RIGHT: RX/TX indicators
if (_showRxTxIndicators)
GestureDetector(
onLongPress: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
PacketLogScreen(bleService: provider.bleService),
),
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: provider.rxActivity
? Colors.green
: Colors.grey.withValues(alpha: 0.3),
),
),
const SizedBox(width: 3),
Text(
'RX:${provider.rxPacketCount}',
style: const TextStyle(
fontSize: 10,
color: Colors.grey,
),
),
],
),
const SizedBox(height: 3),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: provider.txActivity
? Colors.blue
: Colors.grey.withValues(alpha: 0.3),
),
),
const SizedBox(width: 3),
Text(
'TX:${provider.txPacketCount}',
style: const TextStyle(
fontSize: 10,
color: Colors.grey,
),
),
],
),
],
),
)
else
const SizedBox(
width: 52,
), // Placeholder to maintain layout balance
],
);
},
);
}
/// Build tab icon with badge showing count
Widget _buildTabIconWithBadge(IconData icon, int count) {
if (count == 0) {
return Icon(icon);
}
return Stack(
clipBehavior: Clip.none,
children: [
Icon(icon),
Positioned(
right: -8,
top: -4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
child: Text(
count > 99 ? '99+' : count.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
),
],
);
}
}

File diff suppressed because it is too large Load Diff

2593
lib/screens/map_tab.dart Normal file

File diff suppressed because it is too large Load Diff

10
lib/screens/mcp.json Normal file
View File

@@ -0,0 +1,10 @@
{
"mcpServers": {
"dart": {
"command": "dart",
"args": [
"mcp-server"
]
}
}
}

View File

@@ -0,0 +1,920 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import '../providers/messages_provider.dart';
import '../providers/contacts_provider.dart';
import '../providers/map_provider.dart';
import '../providers/connection_provider.dart';
import '../providers/drawing_provider.dart';
import '../providers/app_provider.dart';
import '../models/message.dart';
import '../models/contact.dart';
import '../widgets/messages/sar_update_sheet.dart';
import '../widgets/messages/recipient_selector_sheet.dart';
import '../widgets/messages/message_bubble.dart';
import '../services/message_destination_preferences.dart';
import '../utils/toast_logger.dart';
import '../utils/key_comparison.dart';
import '../l10n/app_localizations.dart';
class MessagesTab extends StatefulWidget {
final VoidCallback? onNavigateToMap;
const MessagesTab({super.key, this.onNavigateToMap});
@override
State<MessagesTab> createState() => _MessagesTabState();
}
class _MessagesTabState extends State<MessagesTab> {
final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode();
final ScrollController _scrollController = ScrollController();
int _characterCount = 0;
static const int _maxCharacters = 160;
String? _highlightedMessageId;
Timer? _highlightTimer; // Timer for clearing message highlight
// Message destination state
String _destinationType =
MessageDestinationPreferences.destinationTypeChannel;
Contact? _selectedRecipient;
@override
void initState() {
super.initState();
_textController.addListener(_updateCharacterCount);
// Load saved message destination
_loadSavedDestination();
// Mark all messages as read when tab is opened
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<MessagesProvider>().markAllAsRead();
_checkForNavigationRequest();
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Reload saved destination and check for navigation request whenever dependencies change
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadSavedDestination();
_checkForNavigationRequest();
});
}
@override
void dispose() {
_highlightTimer?.cancel();
_textController.dispose();
_focusNode.dispose();
_scrollController.dispose();
super.dispose();
}
void _checkForNavigationRequest() {
final messagesProvider = context.read<MessagesProvider>();
final targetMessageId = messagesProvider.targetMessageId;
if (targetMessageId != null) {
_scrollToMessage(targetMessageId);
messagesProvider.clearMessageNavigation();
}
}
void _scrollToMessage(String messageId) {
final messagesProvider = context.read<MessagesProvider>();
final messages = _getFilteredMessages(messagesProvider);
final messageIndex = messages.indexWhere((m) => m.id == messageId);
if (messageIndex != -1 && _scrollController.hasClients) {
// Calculate position - accounting for reverse list
final itemHeight = 80.0; // Approximate height of a message bubble
final targetOffset = messageIndex * itemHeight;
// Scroll to the message
_scrollController.animateTo(
targetOffset,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
// Highlight the message briefly
setState(() {
_highlightedMessageId = messageId;
});
// Clear highlight after 2 seconds using a properly managed Timer
_highlightTimer?.cancel();
_highlightTimer = Timer(const Duration(seconds: 2), () {
if (mounted) {
setState(() {
_highlightedMessageId = null;
});
}
});
}
}
void _updateCharacterCount() {
setState(() {
_characterCount = _textController.text.length;
});
}
/// Load saved message destination from preferences
Future<void> _loadSavedDestination() async {
final savedDestination =
await MessageDestinationPreferences.getDestination();
if (savedDestination == null || !mounted) {
// Default to public channel
return;
}
final type = savedDestination['type']!;
final publicKey = savedDestination['publicKey'];
setState(() {
_destinationType = type;
});
// If it's a contact or room, try to find it in the contacts list
if (publicKey != null && mounted) {
final contactsProvider = context.read<ContactsProvider>();
final contact = contactsProvider.contacts.where((c) {
return c.publicKeyHex == publicKey;
}).firstOrNull;
if (contact != null) {
setState(() {
_selectedRecipient = contact;
});
} else {
// Contact/room not found, fallback to public channel
debugPrint(
'⚠️ [MessagesTab] Saved recipient not found, falling back to public channel',
);
setState(() {
_destinationType =
MessageDestinationPreferences.destinationTypeChannel;
_selectedRecipient = null;
});
await MessageDestinationPreferences.clearDestination();
}
}
}
/// Show recipient selector bottom sheet
void _showRecipientSelector() {
final contactsProvider = context.read<ContactsProvider>();
// Filter contacts by type
final contacts = contactsProvider.contacts
.where((c) => c.type == ContactType.chat)
.toList();
final rooms = contactsProvider.contacts
.where((c) => c.type == ContactType.room)
.toList();
final channels = contactsProvider.contacts
.where((c) => c.type == ContactType.channel)
.toList();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => RecipientSelectorSheet(
contacts: contacts,
rooms: rooms,
channels: channels,
currentDestinationType: _destinationType,
currentRecipientPublicKey: _selectedRecipient?.publicKeyHex,
onSelect: _onRecipientSelected,
),
);
}
/// Handle recipient selection
Future<void> _onRecipientSelected(String type, Contact? recipient) async {
setState(() {
_destinationType = type;
_selectedRecipient = recipient;
});
// Save to preferences
await MessageDestinationPreferences.setDestination(
type,
recipientPublicKey: recipient?.publicKeyHex,
);
// Show confirmation toast
if (!mounted) return;
}
/// Get icon for current destination type
IconData _getDestinationIcon() {
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel) {
return Icons.public;
} else if (_destinationType ==
MessageDestinationPreferences.destinationTypeRoom) {
return Icons.meeting_room;
} else {
return Icons.person;
}
}
/// Get tooltip for destination button
String _getDestinationTooltip() {
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel && _selectedRecipient != null) {
final channelName = _selectedRecipient!.getLocalizedDisplayName(context);
return '$channelName (tap to change)';
} else if (_selectedRecipient != null) {
final recipientName = _selectedRecipient!.displayName;
return '$recipientName (tap to change)';
}
return 'Select recipient';
}
Future<void> _sendMessage() async {
final text = _textController.text.trim();
if (text.isEmpty) return;
final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>();
final contactsProvider = context.read<ContactsProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
if (!mounted) return;
ToastLogger.error(context, 'Not connected to device');
return;
}
try {
// Check destination type and send accordingly
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel) {
// Send to selected channel (or public channel if none selected)
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0; // Extract channel index from pseudo public key
await _sendToChannel(text, connectionProvider, messagesProvider, channelIdx);
} else if (_selectedRecipient != null) {
// Send to contact or room
await _sendToRecipient(
text,
connectionProvider,
messagesProvider,
contactsProvider,
);
} else {
// Fallback to public channel if no recipient selected
debugPrint(
'⚠️ [MessagesTab] No recipient selected, falling back to public channel',
);
await _sendToChannel(text, connectionProvider, messagesProvider, 0);
}
_textController.clear();
_focusNode.unfocus();
if (!mounted) return;
} catch (e) {
if (!mounted) return;
ToastLogger.error(context, 'Failed to send: $e');
}
}
/// Send message to channel
Future<void> _sendToChannel(
String text,
ConnectionProvider connectionProvider,
MessagesProvider messagesProvider,
int channelIdx,
) async {
// Create message ID
final messageId = '${DateTime.now().millisecondsSinceEpoch}_channel_sent';
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// Get current device's public key (first 6 bytes)
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6);
// Create sent message object
final sentMessage = Message(
id: messageId,
messageType: MessageType.channel,
senderPublicKeyPrefix: senderPublicKeyPrefix,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: timestamp,
text: text,
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
channelIdx: channelIdx,
);
// Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage);
// Send to selected channel
await connectionProvider.sendChannelMessage(
channelIdx: channelIdx,
text: text,
messageId: messageId,
);
}
/// Send message to contact or room
Future<void> _sendToRecipient(
String text,
ConnectionProvider connectionProvider,
MessagesProvider messagesProvider,
ContactsProvider contactsProvider,
) async {
if (_selectedRecipient == null) return;
// Create message ID
final messageId = '${DateTime.now().millisecondsSinceEpoch}_sent';
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// Get current device's public key (first 6 bytes)
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6);
// Create sent message object with recipient public key for retry support
final sentMessage = Message(
id: messageId,
messageType: MessageType.contact,
senderPublicKeyPrefix: senderPublicKeyPrefix,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: timestamp,
text: text,
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
recipientPublicKey: _selectedRecipient!.publicKey,
);
// Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage);
// Send message to selected recipient
final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: _selectedRecipient!.publicKey,
text: text,
messageId: messageId,
contact: _selectedRecipient,
);
if (!sentSuccessfully) {
// Mark message as failed if sending failed
messagesProvider.markMessageFailed(messageId);
}
}
void _showSarDialog() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => SarUpdateSheet(
onSend:
(
emoji,
name,
position,
roomPublicKey,
sendToChannel,
sendToAllContacts,
colorIndex,
) async {
await _sendSarMessage(
emoji,
name,
position,
roomPublicKey,
sendToChannel,
sendToAllContacts,
colorIndex,
);
},
),
);
}
Future<void> _sendSarMessage(
String emoji,
String name,
Position position,
Uint8List? roomPublicKey,
bool sendToChannel,
bool sendToAllContacts,
int colorIndex,
) async {
final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
if (!mounted) return;
ToastLogger.error(context, 'Not connected to device');
return;
}
if (!sendToChannel && !sendToAllContacts && roomPublicKey == null) {
if (!mounted) return;
ToastLogger.error(context, 'Please select a destination to send SAR marker');
return;
}
try {
// New format: S:<emoji>:<colorIndex>:<latitude>,<longitude>:<name>
// Round coordinates to 5 decimal places (~1m accuracy) since most GPS is only that accurate
final sarMessage =
'S:$emoji:${colorIndex.toString()}:${position.latitude.toStringAsFixed(5)},${position.longitude.toStringAsFixed(5)}:$name';
if (sendToAllContacts) {
// Send to all chat contacts (ContactType.chat)
final contactsProvider = context.read<ContactsProvider>();
final chatContacts = contactsProvider.chatContacts;
if (chatContacts.isEmpty) {
if (!mounted) return;
ToastLogger.error(context, AppLocalizations.of(context)!.noContactsAvailable);
return;
}
// Create a single grouped message instead of multiple individual messages
final groupId = '${DateTime.now().millisecondsSinceEpoch}_group';
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6);
// Create recipient list
final recipients = chatContacts.map((contact) {
return MessageRecipient(
publicKey: contact.publicKey,
displayName: contact.displayName,
deliveryStatus: MessageDeliveryStatus.sending,
sentAt: DateTime.now(),
);
}).toList();
// Create single grouped message
final groupedMessage = Message(
id: groupId,
messageType: MessageType.contact,
senderPublicKeyPrefix: senderPublicKeyPrefix,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: timestamp,
text: sarMessage,
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
groupId: groupId,
recipients: recipients,
);
// Add the grouped message to the list
messagesProvider.addSentMessage(groupedMessage);
// Send to each contact and track status
int successCount = 0;
for (final contact in chatContacts) {
final individualMessageId = '${groupId}_${contact.publicKeyShort}';
// Register this individual send as part of the grouped message
messagesProvider.registerGroupedMessageSend(
individualMessageId,
groupId,
contact.publicKey,
);
// Send SAR message to contact (with ACK tracking)
final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: contact.publicKey,
text: sarMessage,
messageId: individualMessageId,
contact: contact,
);
if (sentSuccessfully) {
successCount++;
} else {
// Update recipient status in grouped message
messagesProvider.updateGroupedMessageRecipientStatus(
groupId,
contact.publicKey,
MessageDeliveryStatus.failed,
);
}
// Add 1 second delay between sends to ensure:
// 1. Different timestamps (messages sent in different seconds)
// 2. Radio has time to fully process previous message and assign ACK tag
// This ensures each message gets a unique ACK tag from the radio
if (contact != chatContacts.last) {
await Future.delayed(const Duration(seconds: 1));
}
}
if (!mounted) return;
ToastLogger.success(
context,
AppLocalizations.of(context)!.sarMarkerSentToContacts(successCount),
);
} else if (sendToChannel) {
// Create message ID
final messageId =
'${DateTime.now().millisecondsSinceEpoch}_channel_sent';
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// Get current device's public key (first 6 bytes)
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6);
// Create sent message object
final sentMessage = Message(
id: messageId,
messageType: MessageType.channel,
senderPublicKeyPrefix: senderPublicKeyPrefix,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: timestamp,
text: sarMessage,
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
channelIdx: 0,
// SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider
);
// Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage);
// Send to public channel (ephemeral, over-the-air only)
await connectionProvider.sendChannelMessage(
channelIdx: 0,
text: sarMessage,
messageId: messageId,
);
if (!mounted) return;
} else {
// Create message ID
final messageId = '${DateTime.now().millisecondsSinceEpoch}_sent';
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// Get current device's public key (first 6 bytes)
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6);
// Create sent message object with recipient public key for retry support
final sentMessage = Message(
id: messageId,
messageType: MessageType.contact,
senderPublicKeyPrefix: senderPublicKeyPrefix,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: timestamp,
text: sarMessage,
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
recipientPublicKey: roomPublicKey, // Store recipient for retry
// SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider
);
// Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage);
// Look up the room contact for path logging
final contactsProvider = context.read<ContactsProvider>();
final roomContact = contactsProvider.contacts.where((c) {
return c.publicKey.length >= roomPublicKey!.length &&
c.publicKey.matches(roomPublicKey);
}).firstOrNull;
// Send SAR message to selected room (persisted and immutable)
final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: roomPublicKey!,
text: sarMessage,
messageId: messageId, // Pass message ID so it can be tracked
contact: roomContact, // Include contact for path status logging
);
if (!sentSuccessfully) {
// Mark message as failed if sending failed
messagesProvider.markMessageFailed(messageId);
}
if (!mounted) return;
ToastLogger.success(context, 'SAR marker sent to room');
}
} catch (e) {
if (!mounted) return;
ToastLogger.error(context, 'Failed to send SAR marker: $e');
}
}
/// Handle pull-to-refresh for manual message sync
/// This is a FALLBACK mechanism - messages are normally synced automatically via PUSH_CODE_MSG_WAITING
Future<void> _handleRefresh() async {
final connectionProvider = context.read<ConnectionProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
if (!mounted) return;
ToastLogger.warning(context, 'Not connected - cannot sync messages');
return;
}
try {
debugPrint(
'🔄 [MessagesTab] Manual refresh triggered - syncing messages',
);
if (!mounted) return;
} catch (e) {
debugPrint('❌ [MessagesTab] Sync error: $e');
if (!mounted) return;
ToastLogger.error(context, 'Sync failed: $e');
}
}
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
// Get all recent messages
final allMessages = messagesProvider.getRecentMessages(count: 100);
// Get simple mode setting from AppProvider
final appProvider = context.read<AppProvider>();
final isSimpleMode = appProvider.isSimpleMode;
List<Message> filteredMessages;
// If public channel is selected, show ALL messages
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel &&
_selectedRecipient == null) {
filteredMessages = allMessages;
}
// If a contact or room is selected, filter by recipient
else if ((_destinationType ==
MessageDestinationPreferences.destinationTypeContact ||
_destinationType ==
MessageDestinationPreferences.destinationTypeRoom) &&
_selectedRecipient != null) {
filteredMessages = allMessages.where((message) {
// Include messages sent TO this recipient
if (message.recipientPublicKey != null &&
message.recipientPublicKey!.length >= 6 &&
_selectedRecipient!.publicKey.length >= 6) {
// Compare first 6 bytes (public key prefix)
final recipientPrefix = message.recipientPublicKey!.sublist(0, 6);
final selectedPrefix = _selectedRecipient!.publicKey.sublist(0, 6);
if (recipientPrefix.matches(selectedPrefix)) {
return true;
}
}
// Include messages received FROM this recipient
if (message.senderPublicKeyPrefix != null &&
message.senderPublicKeyPrefix!.length >= 6 &&
_selectedRecipient!.publicKey.length >= 6) {
final senderPrefix = message.senderPublicKeyPrefix!.sublist(0, 6);
final selectedPrefix = _selectedRecipient!.publicKey.sublist(0, 6);
if (senderPrefix.matches(selectedPrefix)) {
return true;
}
}
return false;
}).toList();
} else {
// Default: show all messages (fallback case)
filteredMessages = allMessages;
}
// In simple mode, filter out system messages (toast logs)
if (isSimpleMode) {
filteredMessages = filteredMessages
.where((message) => !message.isSystemMessage)
.toList();
}
return filteredMessages;
}
@override
Widget build(BuildContext context) {
return Consumer<MessagesProvider>(
builder: (context, messagesProvider, child) {
final messages = _getFilteredMessages(messagesProvider);
return Column(
children: [
// Messages list with pull-to-refresh
Expanded(
child: RefreshIndicator(
onRefresh: _handleRefresh,
child: messages.isEmpty
? LayoutBuilder(
builder: (context, constraints) =>
SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.message_outlined,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
AppLocalizations.of(
context,
)!.noMessagesYet,
style: Theme.of(
context,
).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
AppLocalizations.of(
context,
)!.pullDownToSync,
style: Theme.of(
context,
).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
),
),
)
: ListView.builder(
controller: _scrollController,
reverse: true,
padding: const EdgeInsets.all(8),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
final isHighlighted =
message.id == _highlightedMessageId;
return MessageBubble(
key: ValueKey(message.id),
message: message,
isHighlighted: isHighlighted,
onNavigateToMap: widget.onNavigateToMap,
onTap:
widget.onNavigateToMap != null &&
message.isSarMarker &&
message.sarGpsCoordinates != null
? () {
final mapProvider = context
.read<MapProvider>();
mapProvider.navigateToLocation(
location: message.sarGpsCoordinates!,
zoom: 15.0,
);
widget.onNavigateToMap?.call();
}
: widget.onNavigateToMap != null &&
message.isDrawing && message.drawingId != null
? () {
debugPrint('🗺️ [MessagesTab] Drawing tapped! ID: ${message.drawingId}');
final mapProvider = context
.read<MapProvider>();
final drawingProvider = context
.read<DrawingProvider>();
mapProvider.navigateToDrawing(
message.drawingId!,
drawingProvider,
);
widget.onNavigateToMap?.call();
}
: null,
);
},
),
),
),
// Message input area
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// SAR quick action button
IconButton(
icon: const Icon(Icons.add_location_alt),
tooltip: AppLocalizations.of(context)!.sendSarMarker,
onPressed: _showSarDialog,
style: IconButton.styleFrom(
backgroundColor: Theme.of(
context,
).colorScheme.primaryContainer,
foregroundColor: Theme.of(
context,
).colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 4),
// Destination switcher button
IconButton(
icon: Icon(_getDestinationIcon()),
tooltip: _getDestinationTooltip(),
onPressed: _showRecipientSelector,
style: IconButton.styleFrom(
backgroundColor:
_destinationType ==
MessageDestinationPreferences
.destinationTypeChannel
? Theme.of(
context,
).colorScheme.surfaceContainerHighest
: Theme.of(context).colorScheme.secondaryContainer,
foregroundColor:
_destinationType ==
MessageDestinationPreferences
.destinationTypeChannel
? Theme.of(context).colorScheme.onSurface
: Theme.of(context).colorScheme.onSecondaryContainer,
),
),
const SizedBox(width: 4),
// Text field with embedded send button
Expanded(
child: TextField(
controller: _textController,
focusNode: _focusNode,
maxLength: _maxCharacters,
maxLines: null,
maxLengthEnforcement: MaxLengthEnforcement.enforced,
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
hintText: AppLocalizations.of(context)!.typeYourMessage,
hintStyle: const TextStyle(fontSize: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
isDense: true,
counterText: _characterCount >= 150
? '$_characterCount/$_maxCharacters'
: '',
counterStyle: TextStyle(
fontSize: 10,
color: _characterCount > _maxCharacters * 0.9
? Colors.orange
: Theme.of(context).textTheme.bodySmall?.color,
),
suffixIcon: IconButton(
icon: Icon(
Icons.send_rounded,
size: 22,
color: _textController.text.trim().isEmpty
? Theme.of(context).disabledColor
: Theme.of(context).colorScheme.primary,
),
onPressed: _textController.text.trim().isEmpty
? null
: _sendMessage,
tooltip: 'Send',
),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
),
),
],
),
),
],
);
},
);
}
}

View File

@@ -0,0 +1,590 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import '../models/ble_packet_log.dart';
import '../services/meshcore_ble_service.dart';
import '../l10n/app_localizations.dart';
class PacketLogScreen extends StatefulWidget {
final MeshCoreBleService bleService;
const PacketLogScreen({
super.key,
required this.bleService,
});
@override
State<PacketLogScreen> createState() => _PacketLogScreenState();
}
class _PacketLogScreenState extends State<PacketLogScreen> {
bool _autoScroll = true;
final ScrollController _scrollController = ScrollController();
String _searchQuery = '';
PacketDirection? _filterDirection;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
List<BlePacketLog> get _filteredLogs {
var logs = widget.bleService.packetLogs;
// Filter by direction
if (_filterDirection != null) {
logs = logs.where((log) => log.direction == _filterDirection).toList();
}
// Filter by search query
if (_searchQuery.isNotEmpty) {
final query = _searchQuery.toLowerCase();
logs = logs.where((log) {
return log.hexData.toLowerCase().contains(query) ||
(log.description?.toLowerCase().contains(query) ?? false) ||
log.summary.toLowerCase().contains(query);
}).toList();
}
return logs;
}
Future<void> _exportLogs(BuildContext context) async {
try {
final logs = _filteredLogs;
if (logs.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No logs to export')),
);
}
return;
}
// Create CSV content
final buffer = StringBuffer();
buffer.writeln('Timestamp,Direction,Size (bytes),Opcode Name,Code,Hex Data,Description');
for (final log in logs) {
buffer.writeln(log.toCsvRow());
}
// Save to temporary file
final tempDir = await getTemporaryDirectory();
if (!context.mounted) return;
final file = File('${tempDir.path}/ble_packets_${DateTime.now().millisecondsSinceEpoch}.csv');
await file.writeAsString(buffer.toString());
// Share the file
if (!context.mounted) return;
await SharePlus.instance.share(
ShareParams(
files: [XFile(file.path)],
subject: 'MeshCore BLE Packet Logs',
text: 'Exported ${logs.length} BLE packets from MeshCore SAR app',
),
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Export failed: $e')),
);
}
}
}
Future<void> _exportAsText(BuildContext context) async {
try {
final logs = _filteredLogs;
if (logs.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No logs to export')),
);
}
return;
}
// Create text content
final buffer = StringBuffer();
buffer.writeln('MeshCore BLE Packet Logs');
buffer.writeln('=' * 80);
buffer.writeln('Exported: ${DateTime.now().toIso8601String()}');
buffer.writeln('Total packets: ${logs.length}');
buffer.writeln('=' * 80);
buffer.writeln();
for (final log in logs) {
buffer.writeln(log.toLogString());
}
// Save to temporary file
final tempDir = await getTemporaryDirectory();
if (!context.mounted) return;
final file = File('${tempDir.path}/ble_packets_${DateTime.now().millisecondsSinceEpoch}.txt');
await file.writeAsString(buffer.toString());
// Share the file
if (!context.mounted) return;
await SharePlus.instance.share(
ShareParams(
files: [XFile(file.path)],
subject: 'MeshCore BLE Packet Logs',
text: 'Exported ${logs.length} BLE packets from MeshCore SAR app',
),
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Export failed: $e')),
);
}
}
}
void _copyToClipboard(BuildContext context, BlePacketLog log) {
Clipboard.setData(ClipboardData(text: log.hexData));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Hex data copied to clipboard'),
duration: Duration(seconds: 1),
),
);
}
void _clearLogs(BuildContext context) {
final parentContext = context; // Store parent context for setState
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(AppLocalizations.of(dialogContext)!.clearAllData),
content: const Text('Are you sure you want to clear all packet logs? This cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(AppLocalizations.of(dialogContext)!.cancel),
),
TextButton(
onPressed: () {
widget.bleService.clearPacketLogs();
Navigator.pop(dialogContext);
if (!mounted) return;
setState(() {});
if (parentContext.mounted) {
ScaffoldMessenger.of(parentContext).showSnackBar(
const SnackBar(content: Text('Packet logs cleared')),
);
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(AppLocalizations.of(dialogContext)!.clear),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final logs = _filteredLogs;
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('BLE Packet Logs'),
Text(
'${logs.length} packets',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
actions: [
// Direction filter
PopupMenuButton<PacketDirection?>(
icon: Icon(_filterDirection == null
? Icons.filter_list
: _filterDirection == PacketDirection.rx
? Icons.arrow_downward
: Icons.arrow_upward),
tooltip: 'Filter by direction',
onSelected: (direction) {
setState(() {
_filterDirection = direction;
});
},
itemBuilder: (context) => [
PopupMenuItem(
value: null,
child: Row(
children: [
Icon(Icons.filter_list,
color: _filterDirection == null ? Theme.of(context).colorScheme.primary : null),
const SizedBox(width: 8),
Text('All',
style: TextStyle(
fontWeight: _filterDirection == null ? FontWeight.bold : FontWeight.normal)),
],
),
),
PopupMenuItem(
value: PacketDirection.rx,
child: Row(
children: [
Icon(Icons.arrow_downward,
color: _filterDirection == PacketDirection.rx
? Theme.of(context).colorScheme.primary
: null),
const SizedBox(width: 8),
Text('RX (Received)',
style: TextStyle(
fontWeight:
_filterDirection == PacketDirection.rx ? FontWeight.bold : FontWeight.normal)),
],
),
),
PopupMenuItem(
value: PacketDirection.tx,
child: Row(
children: [
Icon(Icons.arrow_upward,
color: _filterDirection == PacketDirection.tx
? Theme.of(context).colorScheme.primary
: null),
const SizedBox(width: 8),
Text('TX (Sent)',
style: TextStyle(
fontWeight:
_filterDirection == PacketDirection.tx ? FontWeight.bold : FontWeight.normal)),
],
),
),
],
),
// Auto-scroll toggle
IconButton(
icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center),
tooltip: _autoScroll ? 'Disable auto-scroll' : 'Enable auto-scroll',
onPressed: () {
setState(() {
_autoScroll = !_autoScroll;
});
},
),
// Export menu
PopupMenuButton(
icon: const Icon(Icons.share),
tooltip: 'Export logs',
itemBuilder: (context) => [
const PopupMenuItem(
value: 'csv',
child: Row(
children: [
Icon(Icons.table_chart),
SizedBox(width: 8),
Text('Export as CSV'),
],
),
),
const PopupMenuItem(
value: 'txt',
child: Row(
children: [
Icon(Icons.text_snippet),
SizedBox(width: 8),
Text('Export as Text'),
],
),
),
],
onSelected: (value) {
if (value == 'csv') {
_exportLogs(context);
} else if (value == 'txt') {
_exportAsText(context);
}
},
),
// Clear logs
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Clear logs',
onPressed: () => _clearLogs(context),
),
],
),
body: Column(
children: [
// Search bar
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
decoration: InputDecoration(
hintText: 'Search logs...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
setState(() {
_searchQuery = '';
});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
),
),
// Logs list
Expanded(
child: logs.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.list_alt,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
_searchQuery.isNotEmpty || _filterDirection != null
? 'No matching packets found'
: 'No packets logged yet',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
if (_searchQuery.isNotEmpty || _filterDirection != null) ...[
const SizedBox(height: 8),
TextButton.icon(
onPressed: () {
setState(() {
_searchQuery = '';
_filterDirection = null;
});
},
icon: const Icon(Icons.clear_all),
label: const Text('Clear filters'),
),
],
],
),
)
: ListView.builder(
controller: _scrollController,
itemCount: logs.length,
itemBuilder: (context, index) {
final log = logs[index];
// Auto-scroll to bottom
if (_autoScroll && index == logs.length - 1) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
return _PacketLogCard(
log: log,
onCopy: () => _copyToClipboard(context, log),
);
},
),
),
],
),
);
}
}
class _PacketLogCard extends StatelessWidget {
final BlePacketLog log;
final VoidCallback onCopy;
const _PacketLogCard({
required this.log,
required this.onCopy,
});
@override
Widget build(BuildContext context) {
final isRx = log.direction == PacketDirection.rx;
final directionColor = isRx ? Colors.green : Colors.blue;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ExpansionTile(
leading: CircleAvatar(
backgroundColor: directionColor.withValues(alpha: 0.2),
child: Icon(
isRx ? Icons.arrow_downward : Icons.arrow_upward,
color: directionColor,
size: 20,
),
),
title: Row(
children: [
Text(
isRx ? 'RX' : 'TX',
style: TextStyle(
fontWeight: FontWeight.bold,
color: directionColor,
fontSize: 12,
),
),
const SizedBox(width: 8),
Flexible(
child: Text(
log.responseCode != null ? log.opcodeName : 'N/A',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
'${log.rawData.length} bytes • ${_formatTimestamp(log.timestamp)}',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
),
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Hex data
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Hex: ',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey[700],
),
),
Expanded(
child: SelectableText(
log.hexData,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
IconButton(
icon: const Icon(Icons.copy, size: 18),
tooltip: 'Copy hex data',
onPressed: onCopy,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: 8),
// Metadata
Wrap(
spacing: 16,
runSpacing: 8,
children: [
_InfoChip(
icon: Icons.schedule,
label: log.timestamp.toIso8601String(),
),
_InfoChip(
icon: Icons.data_usage,
label: '${log.rawData.length} bytes',
),
if (log.responseCode != null)
_InfoChip(
icon: Icons.tag,
label: log.opcodeDescription,
),
// Show RSSI and SNR for LOG_RX_DATA packets
if (log.logRxDataInfo?.rssiDbm != null)
_InfoChip(
icon: Icons.signal_cellular_alt,
label: 'RSSI: ${log.logRxDataInfo!.rssiDbm} dBm',
),
if (log.logRxDataInfo?.snrDb != null)
_InfoChip(
icon: Icons.waves,
label: 'SNR: ${log.logRxDataInfo!.snrDb!.toStringAsFixed(1)} dB',
),
],
),
],
),
),
],
),
);
}
String _formatTimestamp(DateTime timestamp) {
final now = DateTime.now();
final diff = now.difference(timestamp);
if (diff.inSeconds < 60) {
return '${diff.inSeconds}s ago';
} else if (diff.inMinutes < 60) {
return '${diff.inMinutes}m ago';
} else if (diff.inHours < 24) {
return '${diff.inHours}h ago';
} else {
return '${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}:${timestamp.second.toString().padLeft(2, '0')}';
}
}
}
class _InfoChip extends StatelessWidget {
final IconData icon;
final String label;
const _InfoChip({
required this.icon,
required this.label,
});
@override
Widget build(BuildContext context) {
return Chip(
avatar: Icon(icon, size: 16),
label: Text(
label,
style: const TextStyle(fontSize: 11),
),
padding: const EdgeInsets.all(4),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
}
}

View File

@@ -0,0 +1,456 @@
import 'package:flutter/material.dart';
import '../models/sar_template.dart';
import '../services/sar_template_service.dart';
import '../widgets/sar/sar_template_edit_dialog.dart';
import '../l10n/app_localizations.dart';
/// Screen for managing SAR templates
class SarTemplateManagementScreen extends StatefulWidget {
const SarTemplateManagementScreen({super.key});
@override
State<SarTemplateManagementScreen> createState() => _SarTemplateManagementScreenState();
}
class _SarTemplateManagementScreenState extends State<SarTemplateManagementScreen> {
final SarTemplateService _templateService = SarTemplateService();
bool _isLoading = false;
@override
void initState() {
super.initState();
_initializeService();
}
Future<void> _initializeService() async {
if (!_templateService.isInitialized) {
if (!mounted) return;
setState(() => _isLoading = true);
await _templateService.initialize();
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _addTemplate() async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => SarTemplateEditDialog(
onSave: (template) async {
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context)!;
await _templateService.addTemplate(template);
if (mounted) {
messenger.showSnackBar(
SnackBar(
content: Text(l10n.templateAdded),
backgroundColor: Colors.green,
),
);
}
},
),
);
}
Future<void> _editTemplate(SarTemplate template) async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => SarTemplateEditDialog(
template: template,
onSave: (updatedTemplate) async {
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context)!;
await _templateService.updateTemplate(template.id, updatedTemplate);
if (mounted) {
messenger.showSnackBar(
SnackBar(
content: Text(l10n.templateUpdated),
backgroundColor: Colors.green,
),
);
}
},
),
);
}
Future<void> _deleteTemplate(SarTemplate template) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.deleteTemplate),
content: Text(
AppLocalizations.of(context)!.deleteTemplateConfirmation(template.getLocalizedName(context)),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(AppLocalizations.of(context)!.delete),
),
],
),
);
if (confirmed == true) {
await _templateService.deleteTemplate(template.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.templateDeleted),
backgroundColor: Colors.orange,
),
);
}
}
}
Future<void> _importFromClipboard() async {
if (!mounted) return;
setState(() => _isLoading = true);
try {
final importedCount = await _templateService.importFromClipboard();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.templatesImported(importedCount)),
backgroundColor: importedCount > 0 ? Colors.green : Colors.orange,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.importFailed(e.toString())),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _exportToClipboard() async {
try {
await _templateService.exportToClipboard();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.templatesExported(_templateService.templateCount),
),
backgroundColor: Colors.green,
action: SnackBarAction(
label: AppLocalizations.of(context)!.ok,
textColor: Colors.white,
onPressed: () {},
),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.exportFailed(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _resetToDefaults() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.resetToDefaults),
content: Text(AppLocalizations.of(context)!.resetToDefaultsConfirmation),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(AppLocalizations.of(context)!.reset),
),
],
),
);
if (confirmed == true) {
await _templateService.resetToDefaults();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.resetComplete),
backgroundColor: Colors.green,
),
);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(l10n.sarTemplates),
actions: [
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
tooltip: 'More options',
onSelected: (value) {
switch (value) {
case 'import':
_importFromClipboard();
break;
case 'export':
_exportToClipboard();
break;
case 'reset':
_resetToDefaults();
break;
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'import',
child: ListTile(
leading: const Icon(Icons.download),
title: Text(l10n.importFromClipboard),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: 'export',
child: ListTile(
leading: const Icon(Icons.upload),
title: Text(l10n.exportToClipboard),
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuDivider(),
PopupMenuItem(
value: 'reset',
child: ListTile(
leading: const Icon(Icons.restart_alt),
title: Text(l10n.resetToDefaults),
contentPadding: EdgeInsets.zero,
),
),
],
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: ListenableBuilder(
listenable: _templateService,
builder: (context, child) {
final templates = _templateService.templates;
if (templates.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.location_searching,
size: 64,
color: colorScheme.onSurface.withValues(alpha: 0.3),
),
const SizedBox(height: 16),
Text(
l10n.noTemplates,
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 8),
Text(
l10n.tapAddToCreate,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
),
);
}
return ListView.builder(
itemCount: templates.length,
itemBuilder: (context, index) {
final template = templates[index];
return _TemplateListItem(
template: template,
onTap: () => _editTemplate(template),
onDelete: () => _deleteTemplate(template),
);
},
);
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _addTemplate,
icon: const Icon(Icons.add),
label: Text(l10n.addTemplate),
),
);
}
}
/// Template list item widget
class _TemplateListItem extends StatelessWidget {
final SarTemplate template;
final VoidCallback onTap;
final VoidCallback onDelete;
const _TemplateListItem({
required this.template,
required this.onTap,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Dismissible(
key: Key(template.id),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete, color: Colors.white),
),
confirmDismiss: (direction) async {
// Show confirmation dialog
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.deleteTemplate),
content: Text(
AppLocalizations.of(context)!.deleteTemplateConfirmation(template.getLocalizedName(context)),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(AppLocalizations.of(context)!.delete),
),
],
),
);
},
onDismissed: (direction) => onDelete(),
child: ListTile(
onTap: onTap,
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: template.color,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: template.color.withValues(alpha: 0.3),
blurRadius: 4,
spreadRadius: 1,
),
],
),
child: Center(
child: Text(
template.emoji,
style: const TextStyle(fontSize: 24),
),
),
),
title: Row(
children: [
Text(
template.getLocalizedName(context),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
if (template.isDefault) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Colors.blue.withValues(alpha: 0.5),
),
),
child: Text(
'DEFAULT',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.blue.shade700,
),
),
),
],
],
),
subtitle: template.description.isNotEmpty
? Text(
template.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
)
: Text(
template.toSarMessage(),
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
color: Colors.red,
onPressed: onDelete,
),
),
);
}
}

View File

@@ -0,0 +1,970 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
import 'package:url_launcher/url_launcher.dart';
import '../providers/contacts_provider.dart';
import '../providers/messages_provider.dart';
import '../providers/app_provider.dart';
import '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart';
import '../services/update_checker_service.dart';
import '../utils/sample_data_generator.dart';
import '../theme/app_theme.dart';
import '../l10n/app_localizations.dart';
import '../widgets/connection_mode_selector.dart';
import '../widgets/update_dialog.dart';
import 'sar_template_management_screen.dart';
import 'welcome_wizard_screen.dart';
class SettingsScreen extends StatefulWidget {
final Function(AppThemeMode) onThemeChanged;
final Function(Locale?) onLocaleChanged;
final AppThemeMode currentTheme;
final Locale? currentLocale;
const SettingsScreen({
super.key,
required this.onThemeChanged,
required this.onLocaleChanged,
required this.currentTheme,
required this.currentLocale,
});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
late AppThemeMode _selectedTheme;
late Locale? _selectedLocale;
PackageInfo? _packageInfo;
bool _isLoadingSampleData = false;
bool _showRxTxIndicators = true;
bool _isCheckingForUpdates = false;
final LocationTrackingService _locationService = LocationTrackingService();
@override
void initState() {
super.initState();
_selectedTheme = widget.currentTheme;
_selectedLocale = widget.currentLocale;
_loadPackageInfo();
_initializeLocationService();
_loadRxTxPreference();
}
@override
void dispose() {
// Clear location service callbacks to prevent memory leaks
_locationService.onError = null;
_locationService.onBroadcastSent = null;
_locationService.onTrackingStateChanged = null;
super.dispose();
}
Future<void> _loadPackageInfo() async {
final info = await PackageInfo.fromPlatform();
if (mounted) {
setState(() {
_packageInfo = info;
});
}
}
Future<void> _loadRxTxPreference() async {
final prefs = await SharedPreferences.getInstance();
if (mounted) {
setState(() {
_showRxTxIndicators = prefs.getBool('show_rx_tx_indicators') ?? true;
});
}
}
Future<void> _saveRxTxPreference(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('show_rx_tx_indicators', value);
}
Future<void> _initializeLocationService() async {
// Initialize location service with BLE service
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (mounted) {
final appProvider = context.read<AppProvider>();
await _locationService.initialize(
appProvider.connectionProvider.bleService,
);
// Set up callbacks for UI feedback
_locationService.onError = (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error), backgroundColor: Colors.orange),
);
}
};
_locationService.onBroadcastSent = (position) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.locationBroadcast(
position.latitude.toStringAsFixed(5),
position.longitude.toStringAsFixed(5),
),
),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
}
};
_locationService.onTrackingStateChanged = (isTracking) {
if (mounted) {
setState(() {});
}
};
// Load settings and restore tracking state
final prefs = await SharedPreferences.getInstance();
final wasTracking =
prefs.getBool('background_tracking_enabled') ?? false;
if (wasTracking) {
await _startBackgroundTracking();
}
if (mounted) {
setState(() {});
}
}
});
}
Future<void> _saveThemePreference(AppThemeMode theme) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('theme_mode', theme.name);
}
void _handleThemeChange(AppThemeMode? theme) {
if (theme != null) {
setState(() {
_selectedTheme = theme;
});
_saveThemePreference(theme);
widget.onThemeChanged(theme);
}
}
Future<void> _saveLocalePreference(Locale? locale) async {
await LocalePreferences.setLocale(locale);
}
/// Check for app updates and show notification or dialog
Future<void> _checkForUpdates() async {
// Only on Android
if (!Platform.isAndroid) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Update check is only available on Android'),
backgroundColor: Colors.orange,
),
);
}
return;
}
setState(() {
_isCheckingForUpdates = true;
});
try {
debugPrint('[Settings] Checking for updates...');
final updateInfo = await UpdateCheckerService().checkForUpdate();
if (!mounted) return;
setState(() {
_isCheckingForUpdates = false;
});
if (!updateInfo.isAvailable) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('You are running the latest version'),
backgroundColor: Colors.green,
),
);
return;
}
if (updateInfo.downloadUrl == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Update available but download URL not found'),
backgroundColor: Colors.orange,
),
);
return;
}
// Show dialog with update details
UpdateDialog.show(context, updateInfo);
} catch (e) {
debugPrint('[Settings] Error checking for updates: $e');
if (mounted) {
setState(() {
_isCheckingForUpdates = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error checking for updates: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
void _handleLocaleChange(Locale? locale) {
setState(() {
_selectedLocale = locale;
});
_saveLocalePreference(locale);
widget.onLocaleChanged(locale);
}
Future<void> _loadSampleData() async {
setState(() => _isLoadingSampleData = true);
try {
// Get current location or use default
LatLng centerLocation;
try {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.best,
timeLimit: Duration(seconds: 5),
),
);
centerLocation = LatLng(position.latitude, position.longitude);
} catch (e) {
// Default to Ljubljana, Slovenia if location unavailable
centerLocation = const LatLng(46.0569, 14.5058);
}
if (!mounted) return;
// Get localization
final l10n = AppLocalizations.of(context)!;
// Generate sample data
final contacts = SampleDataGenerator.generateContacts(
centerLocation: centerLocation,
l10n: l10n,
teamMemberCount: 5,
channelCount: 2,
);
final sarMessages = SampleDataGenerator.generateSarMarkerMessages(
centerLocation: centerLocation,
l10n: l10n,
foundPersonCount: 2,
fireCount: 1,
stagingCount: 1,
objectCount: 1,
);
final channelMessages = SampleDataGenerator.generateChannelMessages(
centerLocation: centerLocation,
l10n: l10n,
generalChannelMessages: 8,
emergencyChannelMessages: 5,
);
// Combine all messages
final allMessages = [...sarMessages, ...channelMessages];
// Add to providers
final contactsProvider = Provider.of<ContactsProvider>(
context,
listen: false,
);
final messagesProvider = Provider.of<MessagesProvider>(
context,
listen: false,
);
contactsProvider.addContacts(contacts);
messagesProvider.addMessages(allMessages);
if (!mounted) return;
final teamCount = contacts.where((c) => c.isChat).length;
final channelCount = contacts.where((c) => c.isRoom).length;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.loadedSampleData(
teamCount,
channelCount,
sarMessages.length,
channelMessages.length,
),
),
backgroundColor: Colors.green,
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.failedToLoadSampleData(e.toString()),
),
backgroundColor: Colors.red,
),
);
} finally {
if (mounted) {
setState(() => _isLoadingSampleData = false);
}
}
}
Future<void> _handleLocationPermissionTap() async {
try {
final permission = await Geolocator.checkPermission();
if (permission == LocationPermission.deniedForever) {
// Show dialog to open app settings
if (!mounted) return;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
const Icon(Icons.settings, size: 24),
const SizedBox(width: 12),
Text(AppLocalizations.of(context)!.locationPermission),
],
),
content: Text(
AppLocalizations.of(context)!.locationPermissionDialogContent,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.cancel),
),
ElevatedButton(
onPressed: () async {
Navigator.pop(context);
await Geolocator.openAppSettings();
},
child: Text(AppLocalizations.of(context)!.openSettings),
),
],
),
);
} else if (permission == LocationPermission.denied) {
// Request permission
final newPermission = await Geolocator.requestPermission();
if (!mounted) return;
if (newPermission == LocationPermission.whileInUse ||
newPermission == LocationPermission.always) {
// Permission granted
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.locationPermissionGranted,
),
backgroundColor: Colors.green,
),
);
setState(() {}); // Refresh UI to show new status
} else {
// Permission denied
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.locationPermissionRequiredForGps,
),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 4),
),
);
}
} else {
// Already granted - show info
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.locationPermissionAlreadyGranted,
),
backgroundColor: Colors.blue,
),
);
}
} catch (e) {
debugPrint('Error handling location permission: $e');
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red),
);
}
}
Future<void> _startBackgroundTracking() async {
final success = await _locationService.startTracking(
distanceThreshold: _locationService.gpsUpdateDistance,
);
if (!success && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.failedToStartBackgroundTracking,
),
duration: const Duration(seconds: 3),
),
);
}
if (mounted) {
setState(() {});
}
}
Future<void> _clearSampleData() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.clearAllDataConfirmTitle),
content: Text(AppLocalizations.of(context)!.clearAllDataConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(AppLocalizations.of(context)!.clear),
),
],
),
);
if (confirmed != true || !mounted) return;
final contactsProvider = Provider.of<ContactsProvider>(
context,
listen: false,
);
final messagesProvider = Provider.of<MessagesProvider>(
context,
listen: false,
);
contactsProvider.clearContacts();
messagesProvider.clearAll();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.allDataCleared),
backgroundColor: Colors.orange,
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)),
body: ListView(
children: [
// General Settings Section
_buildSectionHeader(AppLocalizations.of(context)!.general),
ListTile(
leading: const Icon(Icons.palette),
title: Text(AppLocalizations.of(context)!.theme),
subtitle: Text(AppTheme.getThemeDisplayName(_selectedTheme)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showThemeDialog(),
),
SwitchListTile(
secondary: const Icon(Icons.radar),
title: Text(AppLocalizations.of(context)!.showRxTxIndicators),
subtitle: Text(AppLocalizations.of(context)!.displayPacketActivity),
value: _showRxTxIndicators,
onChanged: (value) async {
setState(() {
_showRxTxIndicators = value;
});
await _saveRxTxPreference(value);
},
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.visibility_off),
title: Text(AppLocalizations.of(context)!.simpleMode),
subtitle: Text(
AppLocalizations.of(context)!.simpleModeDescription,
),
value: appProvider.isSimpleMode,
onChanged: (value) async {
await appProvider.toggleSimpleMode(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.map_outlined),
title: Text(AppLocalizations.of(context)!.disableMap),
subtitle: Text(
AppLocalizations.of(context)!.disableMapDescription,
),
value: !appProvider.isMapEnabled,
onChanged: (value) async {
await appProvider.toggleMapEnabled(!value);
},
),
),
ListTile(
leading: const Icon(Icons.language),
title: Text(AppLocalizations.of(context)!.language),
subtitle: Text(LocalePreferences.getDisplayName(_selectedLocale)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showLanguageDialog(),
),
ListTile(
leading: const Icon(Icons.location_searching),
title: Text(AppLocalizations.of(context)!.sarTemplates),
subtitle: Text(AppLocalizations.of(context)!.manageSarTemplates),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SarTemplateManagementScreen(),
),
);
},
),
ListTile(
leading: const Icon(Icons.school),
title: Text(AppLocalizations.of(context)!.viewWelcomeTutorial),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
// Show wizard without resetting state - just as a modal
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => WelcomeWizardScreen(
onCompleted: () {
// Just pop back to settings when done
Navigator.of(context).pop();
},
),
),
);
},
),
const Divider(),
// Network Sharing Section
const ConnectionModeSelector(),
const Divider(),
// Permissions Section
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
ListTile(
leading: const Icon(Icons.location_on),
title: Text(AppLocalizations.of(context)!.locationPermission),
subtitle: FutureBuilder<LocationPermission>(
future: Geolocator.checkPermission(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Text(AppLocalizations.of(context)!.checking);
}
final permission = snapshot.data!;
String statusText;
Color statusColor;
switch (permission) {
case LocationPermission.always:
statusText = AppLocalizations.of(
context,
)!.locationPermissionGrantedAlways;
statusColor = Colors.green;
break;
case LocationPermission.whileInUse:
statusText = AppLocalizations.of(
context,
)!.locationPermissionGrantedWhileInUse;
statusColor = Colors.green;
break;
case LocationPermission.denied:
statusText = AppLocalizations.of(
context,
)!.locationPermissionDeniedTapToRequest;
statusColor = Colors.orange;
break;
case LocationPermission.deniedForever:
statusText = AppLocalizations.of(
context,
)!.locationPermissionPermanentlyDeniedOpenSettings;
statusColor = Colors.red;
break;
default:
statusText = AppLocalizations.of(context)!.unknown;
statusColor = Colors.grey;
}
return Text(statusText, style: TextStyle(color: statusColor));
},
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _handleLocationPermissionTap(),
),
const Divider(),
// About Section
_buildSectionHeader(AppLocalizations.of(context)!.about),
ListTile(
leading: const Icon(Icons.info),
title: Text(AppLocalizations.of(context)!.appVersion),
subtitle: Text(
_packageInfo != null
? '${_packageInfo!.version} (${_packageInfo!.buildNumber})'
: 'Loading...',
),
),
// Check for Updates button (Android only)
if (Platform.isAndroid)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: FilledButton.icon(
onPressed: _isCheckingForUpdates ? null : _checkForUpdates,
icon: _isCheckingForUpdates
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.system_update),
label: Text(
_isCheckingForUpdates ? 'Checking...' : 'Check for Updates',
),
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
),
),
),
ListTile(
leading: const Icon(Icons.badge),
title: Text(AppLocalizations.of(context)!.appName),
subtitle: Text(_packageInfo?.appName ?? 'MeshCore SAR'),
),
ListTile(
leading: const Icon(Icons.description),
title: Text(AppLocalizations.of(context)!.aboutMeshCoreSar),
subtitle: Text(
AppLocalizations.of(context)!.aboutDescription.split('\n\n')[0],
),
onTap: () => _showAboutDialog(),
),
const Divider(),
// Developer Section
_buildSectionHeader(AppLocalizations.of(context)!.developer),
ListTile(
leading: const Icon(Icons.bug_report),
title: Text(AppLocalizations.of(context)!.packageName),
subtitle: Text(_packageInfo?.packageName ?? 'com.meshcore.sar'),
),
const Divider(),
// Sample Data Section
_buildSectionHeader(AppLocalizations.of(context)!.sampleData),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
AppLocalizations.of(context)!.sampleDataDescription,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _isLoadingSampleData ? null : _loadSampleData,
icon: _isLoadingSampleData
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.add_circle_outline),
label: Text(AppLocalizations.of(context)!.loadSampleData),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _isLoadingSampleData ? null : _clearSampleData,
icon: const Icon(Icons.delete_outline),
label: Text(AppLocalizations.of(context)!.clearAllData),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
),
],
),
);
}
Widget _buildSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
);
}
void _showThemeDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.chooseTheme),
content: SingleChildScrollView(
child: RadioGroup<AppThemeMode>(
groupValue: _selectedTheme,
onChanged: (value) {
_handleThemeChange(value);
Navigator.pop(context);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<AppThemeMode>(
title: Text(AppLocalizations.of(context)!.light),
subtitle: Text(AppLocalizations.of(context)!.blueLightTheme),
value: AppThemeMode.light,
),
RadioListTile<AppThemeMode>(
title: Text(AppLocalizations.of(context)!.dark),
subtitle: Text(AppLocalizations.of(context)!.blueDarkTheme),
value: AppThemeMode.dark,
),
const Divider(),
RadioListTile<AppThemeMode>(
title: Row(
children: [
Text(AppLocalizations.of(context)!.sarRed),
const SizedBox(width: 8),
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: const Color(0xFFFF5252),
shape: BoxShape.circle,
border: Border.all(color: Colors.black26),
),
),
],
),
subtitle: Text(
AppLocalizations.of(context)!.alertEmergencyMode,
),
value: AppThemeMode.sarRed,
),
RadioListTile<AppThemeMode>(
title: Row(
children: [
Text(AppLocalizations.of(context)!.sarGreen),
const SizedBox(width: 8),
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: const Color(0xFF69F0AE),
shape: BoxShape.circle,
border: Border.all(color: Colors.black26),
),
),
],
),
subtitle: Text(AppLocalizations.of(context)!.safeAllClearMode),
value: AppThemeMode.sarGreen,
),
RadioListTile<AppThemeMode>(
title: Row(
children: [
Text(AppLocalizations.of(context)!.sarNavyBlue),
const SizedBox(width: 8),
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: const Color(0xFF5C9FFF),
shape: BoxShape.circle,
border: Border.all(color: Colors.black26),
),
),
],
),
subtitle: Text(
AppLocalizations.of(context)!.sarNavyBlueDescription,
),
value: AppThemeMode.sarNavyBlue,
),
const Divider(),
RadioListTile<AppThemeMode>(
title: Text(AppLocalizations.of(context)!.autoSystem),
subtitle: Text(AppLocalizations.of(context)!.followSystemTheme),
value: AppThemeMode.system,
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.cancel),
),
],
),
);
}
void _showLanguageDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.chooseLanguage),
content: SingleChildScrollView(
child: RadioGroup<Locale?>(
groupValue: _selectedLocale,
onChanged: (value) {
_handleLocaleChange(value);
Navigator.pop(context);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<Locale?>(
title: Text(LocalePreferences.getDisplayName(null)),
subtitle: Text(LocalePreferences.getDisplayName(null)),
value: null,
),
const Divider(),
...LocalePreferences.supportedLocales.map((locale) {
return RadioListTile<Locale?>(
title: Text(LocalePreferences.getNativeDisplayName(locale)),
value: locale,
);
}),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.cancel),
),
],
),
);
}
void _showAboutDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.aboutMeshCoreSar),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'MeshCore SAR',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Version ${_packageInfo?.version ?? '1.0.0'}',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
Text(AppLocalizations.of(context)!.aboutDescription),
const SizedBox(height: 16),
Text(
AppLocalizations.of(context)!.technologiesUsed,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(AppLocalizations.of(context)!.technologiesList),
],
),
),
actions: [
TextButton.icon(
onPressed: () async {
final url = Uri.parse('https://dz0ny.dev/posts/meshcore-sar/');
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
},
icon: const Icon(Icons.open_in_new),
label: Text(AppLocalizations.of(context)!.moreInfo),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.close),
),
],
),
);
}
}

View File

@@ -0,0 +1,399 @@
import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart';
import '../services/wizard_preferences.dart';
/// Welcome wizard screen to introduce new users to the app
class WelcomeWizardScreen extends StatefulWidget {
final VoidCallback? onCompleted;
const WelcomeWizardScreen({super.key, this.onCompleted});
@override
State<WelcomeWizardScreen> createState() => _WelcomeWizardScreenState();
}
class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
final PageController _pageController = PageController();
int _currentPage = 0;
static const int _totalPages = 6;
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _onPageChanged(int page) {
setState(() {
_currentPage = page;
});
}
void _nextPage() {
if (_currentPage < _totalPages - 1) {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
} else {
_completeWizard();
}
}
void _previousPage() {
if (_currentPage > 0) {
_pageController.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
Future<void> _completeWizard() async {
await WizardPreferences.setWizardCompleted(true);
if (mounted) {
widget.onCompleted?.call();
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
body: SafeArea(
child: Column(
children: [
// Top bar with skip button
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (_currentPage > 0)
TextButton.icon(
onPressed: _previousPage,
icon: const Icon(Icons.arrow_back),
label: Text(l10n.wizardBack),
)
else
const SizedBox(width: 80),
if (_currentPage < _totalPages - 1)
TextButton(
onPressed: _completeWizard,
child: Text(l10n.wizardSkip),
)
else
const SizedBox(width: 80),
],
),
),
// Page view with wizard content
Expanded(
child: PageView(
controller: _pageController,
onPageChanged: _onPageChanged,
children: [
_buildWelcomePage(context, l10n, colorScheme),
_buildConnectingPage(context, l10n, colorScheme),
_buildSimpleModePage(context, l10n, colorScheme),
_buildChannelPage(context, l10n, colorScheme),
_buildContactsPage(context, l10n, colorScheme),
_buildMapPage(context, l10n, colorScheme),
],
),
),
// Page indicators
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
_totalPages,
(index) => Container(
margin: const EdgeInsets.symmetric(horizontal: 4.0),
width: _currentPage == index ? 12.0 : 8.0,
height: _currentPage == index ? 12.0 : 8.0,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _currentPage == index
? colorScheme.primary
: colorScheme.outline.withValues(alpha: 0.3),
),
),
),
),
),
// Next/Get Started button
Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _nextPage,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
_currentPage < _totalPages - 1
? l10n.wizardNext
: l10n.wizardGetStarted,
style: const TextStyle(fontSize: 16),
),
),
),
),
],
),
),
);
}
Widget _buildWelcomePage(
BuildContext context,
AppLocalizations l10n,
ColorScheme colorScheme,
) {
return _buildPage(
icon: Icons.waving_hand,
iconColor: Colors.orange,
title: l10n.wizardWelcomeTitle,
description: l10n.wizardWelcomeDescription,
colorScheme: colorScheme,
);
}
Widget _buildConnectingPage(
BuildContext context,
AppLocalizations l10n,
ColorScheme colorScheme,
) {
return _buildPage(
icon: Icons.bluetooth_searching,
iconColor: Colors.blue,
title: l10n.wizardConnectingTitle,
description: l10n.wizardConnectingDescription,
features: [
_FeatureItem(
icon: Icons.radio,
text: l10n.wizardConnectingFeature1,
),
_FeatureItem(
icon: Icons.link,
text: l10n.wizardConnectingFeature2,
),
_FeatureItem(
icon: Icons.wifi_off,
text: l10n.wizardConnectingFeature3,
),
],
colorScheme: colorScheme,
);
}
Widget _buildSimpleModePage(
BuildContext context,
AppLocalizations l10n,
ColorScheme colorScheme,
) {
return _buildPage(
icon: Icons.toggle_on,
iconColor: Colors.green,
title: l10n.wizardSimpleModeTitle,
description: l10n.wizardSimpleModeDescription,
features: [
_FeatureItem(
icon: Icons.check_circle_outline,
text: l10n.wizardSimpleModeFeature1,
),
_FeatureItem(
icon: Icons.settings,
text: l10n.wizardSimpleModeFeature2,
),
],
colorScheme: colorScheme,
);
}
Widget _buildChannelPage(
BuildContext context,
AppLocalizations l10n,
ColorScheme colorScheme,
) {
return _buildPage(
icon: Icons.campaign,
iconColor: Colors.purple,
title: l10n.wizardChannelTitle,
description: l10n.wizardChannelDescription,
features: [
_FeatureItem(
icon: Icons.public,
text: l10n.wizardChannelFeature1,
),
_FeatureItem(
icon: Icons.groups,
text: l10n.wizardChannelFeature2,
),
_FeatureItem(
icon: Icons.send,
text: l10n.wizardChannelFeature3,
),
],
colorScheme: colorScheme,
);
}
Widget _buildContactsPage(
BuildContext context,
AppLocalizations l10n,
ColorScheme colorScheme,
) {
return _buildPage(
icon: Icons.people,
iconColor: Colors.teal,
title: l10n.wizardContactsTitle,
description: l10n.wizardContactsDescription,
features: [
_FeatureItem(
icon: Icons.person_add,
text: l10n.wizardContactsFeature1,
),
_FeatureItem(
icon: Icons.chat,
text: l10n.wizardContactsFeature2,
),
_FeatureItem(
icon: Icons.battery_std,
text: l10n.wizardContactsFeature3,
),
],
colorScheme: colorScheme,
);
}
Widget _buildMapPage(
BuildContext context,
AppLocalizations l10n,
ColorScheme colorScheme,
) {
return _buildPage(
icon: Icons.map,
iconColor: Colors.red,
title: l10n.wizardMapTitle,
description: l10n.wizardMapDescription,
features: [
_FeatureItem(
icon: Icons.location_on,
text: l10n.wizardMapFeature1,
),
_FeatureItem(
icon: Icons.person_pin_circle,
text: l10n.wizardMapFeature2,
),
_FeatureItem(
icon: Icons.offline_pin,
text: l10n.wizardMapFeature3,
),
_FeatureItem(
icon: Icons.draw,
text: l10n.wizardMapFeature4,
),
],
colorScheme: colorScheme,
);
}
Widget _buildPage({
required IconData icon,
required Color iconColor,
required String title,
required String description,
List<_FeatureItem>? features,
required ColorScheme colorScheme,
}) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 20),
// Icon
Container(
padding: const EdgeInsets.all(24.0),
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 80,
color: iconColor,
),
),
const SizedBox(height: 32),
// Title
Text(
title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Description
Text(
description,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.7),
height: 1.5,
),
textAlign: TextAlign.center,
),
if (features != null && features.isNotEmpty) ...[
const SizedBox(height: 32),
// Features list
...features.map((feature) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
Icon(
feature.icon,
color: colorScheme.primary,
size: 24,
),
const SizedBox(width: 16),
Expanded(
child: Text(
feature.text,
style:
Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
),
),
),
],
),
)),
],
const SizedBox(height: 20),
],
),
);
}
}
class _FeatureItem {
final IconData icon;
final String text;
_FeatureItem({required this.icon, required this.text});
}

View File

@@ -0,0 +1,174 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'meshcore_ble_service.dart';
/// Background location tracking service for SAR operations
/// Tracks user location and sends periodic updates via MeshCore BLE
@pragma('vm:entry-point')
class BackgroundLocationService {
static const String _prefKeyEnabled = 'background_tracking_enabled';
static const String _prefKeyDistance = 'background_tracking_distance';
static const String _prefKeyLastLat = 'background_last_lat';
static const String _prefKeyLastLon = 'background_last_lon';
MeshCoreBleService? _bleService;
bool _isInitialized = false;
StreamSubscription<Position>? _positionSubscription;
/// Initialize the service with BLE service reference
void initialize(MeshCoreBleService bleService) {
_bleService = bleService;
_isInitialized = true;
}
/// Start location tracking and automatic advertisement
/// Returns true if successful, false otherwise
///
/// Note: This is foreground tracking. For true background operation,
/// additional platform-specific configuration is required.
Future<bool> startTracking({double distanceThreshold = 10.0}) async {
if (!_isInitialized || _bleService == null) {
debugPrint(
'⚠️ [BackgroundLocation] Service not initialized or BLE service null',
);
return false;
}
if (!_bleService!.isConnected) {
debugPrint('⚠️ [BackgroundLocation] BLE not connected');
return false;
}
// Check location permissions
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
debugPrint('⚠️ [BackgroundLocation] Location permission denied');
return false;
}
}
if (permission == LocationPermission.deniedForever) {
debugPrint(
'⚠️ [BackgroundLocation] Location permission permanently denied',
);
return false;
}
// Save settings
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyEnabled, true);
await prefs.setDouble(_prefKeyDistance, distanceThreshold);
// Start listening to position updates
Position? lastPosition;
try {
_positionSubscription =
Geolocator.getPositionStream(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: distanceThreshold.toInt(),
),
).listen((Position position) async {
debugPrint(
'📍 [BackgroundLocation] New position: ${position.latitude}, ${position.longitude}',
);
// Calculate distance from last position
if (lastPosition != null) {
final distance = Geolocator.distanceBetween(
lastPosition!.latitude,
lastPosition!.longitude,
position.latitude,
position.longitude,
);
debugPrint(
' Distance moved: ${distance.toStringAsFixed(1)}m (threshold: ${distanceThreshold}m)',
);
// Skip if haven't moved enough
if (distance < distanceThreshold) {
return;
}
}
// Update last position
lastPosition = position;
// Save to preferences
await prefs.setDouble(_prefKeyLastLat, position.latitude);
await prefs.setDouble(_prefKeyLastLon, position.longitude);
// Update device's advertised location
if (_bleService != null && _bleService!.isConnected) {
try {
debugPrint(
'📤 [BackgroundLocation] Updating device location...',
);
await _bleService!.setAdvertLatLon(
latitude: position.latitude,
longitude: position.longitude,
);
// Send advertisement to mesh network
debugPrint(
'📡 [BackgroundLocation] Broadcasting self advertisement...',
);
await _bleService!.sendSelfAdvert(floodMode: true);
debugPrint(
'✅ [BackgroundLocation] Location update sent successfully',
);
} catch (e) {
debugPrint(
'❌ [BackgroundLocation] Failed to send location update: $e',
);
}
} else {
debugPrint(
'⚠️ [BackgroundLocation] BLE disconnected, cannot send update',
);
}
});
debugPrint(
'✅ [BackgroundLocation] Tracking started with ${distanceThreshold}m threshold',
);
return true;
} catch (e) {
debugPrint('❌ [BackgroundLocation] Failed to start tracking: $e');
return false;
}
}
/// Stop location tracking
Future<void> stopTracking() async {
debugPrint('🛑 [BackgroundLocation] Stopping tracking');
await _positionSubscription?.cancel();
_positionSubscription = null;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyEnabled, false);
debugPrint('✅ [BackgroundLocation] Tracking stopped');
}
/// Update the distance threshold for location updates
/// Note: This will restart tracking with the new threshold
Future<void> updateDistanceThreshold(double distance) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_prefKeyDistance, distance);
debugPrint(
'📏 [BackgroundLocation] Distance threshold updated to ${distance}m',
);
// Restart tracking if currently enabled
final isEnabled = prefs.getBool(_prefKeyEnabled) ?? false;
if (isEnabled && _bleService != null) {
await stopTracking();
await startTracking(distanceThreshold: distance);
}
}
}

View File

@@ -0,0 +1,308 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
/// Type of response expected from a command
enum CommandResponseType {
/// No response expected (fire-and-forget)
none,
/// Wait for RESP_CODE_OK (0) or RESP_CODE_ERR (1)
ack,
/// Wait for specific response code with data
data,
}
/// Represents a queued BLE command
class QueuedCommand<T> {
/// The command data to send
final Uint8List data;
/// Command code (first byte of data)
final int commandCode;
/// Type of response expected
final CommandResponseType responseType;
/// Expected response code (for data type commands)
final int? expectedResponseCode;
/// Completer to signal command completion
final Completer<T> completer;
/// Timeout duration for this command
final Duration timeout;
/// Timestamp when command was enqueued
final DateTime enqueuedAt;
QueuedCommand({
required this.data,
required this.commandCode,
required this.responseType,
this.expectedResponseCode,
required this.completer,
required this.timeout,
}) : enqueuedAt = DateTime.now();
}
/// BLE command queue with mutex lock and inter-command delays
///
/// Ensures that:
/// - Only one command executes at a time
/// - 100ms delay between all commands
/// - Commands can wait for ACK or specific responses
/// - Timeouts are enforced
class BleCommandQueue {
// Queue of pending commands
final List<QueuedCommand> _queue = [];
// Mutex lock using Completer
Completer<void> _lock = Completer<void>()..complete();
// Whether queue is currently processing
bool _isProcessing = false;
// Pending responses mapped by command code
final Map<int, QueuedCommand> _pendingResponses = {};
// Last command execution timestamp
DateTime? _lastCommandTime;
// Minimum delay between commands (milliseconds)
static const int _minDelayMs = 100;
// Callbacks
VoidCallback? onQueueEmpty;
void Function(int queueSize)? onQueueSizeChanged;
/// Enqueue a command and wait for it to complete
///
/// [data] - The command data to send
/// [commandCode] - Command code (first byte)
/// [responseType] - Type of response expected
/// [expectedResponseCode] - For data responses, the expected response code
/// [timeout] - Maximum time to wait for response
///
/// Returns a Future that completes when the command receives its response
/// or throws TimeoutException if timeout expires.
Future<T> enqueue<T>({
required Uint8List data,
required int commandCode,
required CommandResponseType responseType,
int? expectedResponseCode,
Duration? timeout,
}) async {
// Determine timeout based on response type
final cmdTimeout =
timeout ??
(responseType == CommandResponseType.data
? const Duration(seconds: 10)
: const Duration(seconds: 5));
// Create queued command
final command = QueuedCommand<T>(
data: data,
commandCode: commandCode,
responseType: responseType,
expectedResponseCode: expectedResponseCode,
completer: Completer<T>(),
timeout: cmdTimeout,
);
// Add to queue
_queue.add(command);
onQueueSizeChanged?.call(_queue.length);
debugPrint(
'📋 [CommandQueue] Enqueued command 0x${commandCode.toRadixString(16).padLeft(2, '0')} (queue size: ${_queue.length})',
);
// Start processing if not already running
if (!_isProcessing) {
_processQueue();
}
// Wait for command to complete or timeout
return command.completer.future.timeout(
cmdTimeout,
onTimeout: () {
debugPrint(
'⏱️ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out after ${cmdTimeout.inSeconds}s',
);
_pendingResponses.remove(commandCode);
throw TimeoutException(
'Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out',
);
},
);
}
/// Process the command queue
Future<void> _processQueue() async {
if (_isProcessing) return;
_isProcessing = true;
while (_queue.isNotEmpty) {
// Wait for lock
await _lock.future;
// Get next command
final command = _queue.removeAt(0);
onQueueSizeChanged?.call(_queue.length);
try {
// Enforce minimum delay between commands
if (_lastCommandTime != null) {
final elapsed = DateTime.now().difference(_lastCommandTime!);
final remainingDelay = _minDelayMs - elapsed.inMilliseconds;
if (remainingDelay > 0) {
debugPrint(
'⏸️ [CommandQueue] Waiting ${remainingDelay}ms before next command',
);
await Future.delayed(Duration(milliseconds: remainingDelay));
}
}
// Create new lock for next command
_lock = Completer<void>();
// Register for response if needed
if (command.responseType != CommandResponseType.none) {
final responseKey = command.responseType == CommandResponseType.ack
? command.commandCode
: (command.expectedResponseCode ?? command.commandCode);
_pendingResponses[responseKey] = command;
}
// Execute command (handled by BleCommandSender)
// The completer will be completed by completeCommand() when response arrives
debugPrint(
'📤 [CommandQueue] Executing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')}',
);
// For fire-and-forget commands, complete immediately
if (command.responseType == CommandResponseType.none) {
command.completer.complete(null);
}
// Update last command time
_lastCommandTime = DateTime.now();
// Release lock after minimum delay
Future.delayed(const Duration(milliseconds: _minDelayMs), () {
if (!_lock.isCompleted) {
_lock.complete();
}
});
} catch (e) {
debugPrint('❌ [CommandQueue] Error processing command: $e');
if (!command.completer.isCompleted) {
command.completer.completeError(e);
}
// Release lock on error
if (!_lock.isCompleted) {
_lock.complete();
}
}
}
_isProcessing = false;
onQueueEmpty?.call();
debugPrint('✅ [CommandQueue] Queue empty');
}
/// Complete a pending command with response data
///
/// Called by BleResponseHandler when a response is received
void completeCommand<T>(int responseCode, T data) {
final command = _pendingResponses.remove(responseCode);
if (command != null) {
debugPrint(
'✅ [CommandQueue] Completing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')} with response 0x${responseCode.toRadixString(16).padLeft(2, '0')}',
);
if (!command.completer.isCompleted) {
command.completer.complete(data);
}
}
}
/// Complete a pending command with error
///
/// Called by BleResponseHandler when RESP_CODE_ERR is received
void completeCommandWithError(
int commandCode,
String error, {
int? errorCode,
}) {
final command = _pendingResponses.remove(commandCode);
if (command != null) {
debugPrint(
'❌ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} failed: $error (code: $errorCode)',
);
if (!command.completer.isCompleted) {
command.completer.completeError(
Exception('Command failed: $error (error code: $errorCode)'),
);
}
}
}
/// Complete all currently pending commands with an error
///
/// Used when RESP_CODE_ERR arrives without a way to identify which command
/// caused it. Since the queue processes one command at a time, at most one
/// command is pending at any given moment.
void completeCurrentCommandWithError(String error, {int? errorCode}) {
for (final entry in _pendingResponses.entries.toList()) {
final command = _pendingResponses.remove(entry.key);
if (command != null && !command.completer.isCompleted) {
debugPrint(
'❌ [CommandQueue] Command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')} failed: $error (code: $errorCode)',
);
command.completer.completeError(
Exception('Command failed: $error (error code: $errorCode)'),
);
}
}
}
/// Get current queue size
int get queueSize => _queue.length;
/// Get number of pending responses
int get pendingResponseCount => _pendingResponses.length;
/// Check if queue is empty
bool get isEmpty => _queue.isEmpty;
/// Check if queue is processing
bool get isProcessing => _isProcessing;
/// Clear all pending commands (use with caution)
void clear() {
debugPrint(
'🗑️ [CommandQueue] Clearing queue (${_queue.length} commands, ${_pendingResponses.length} pending responses)',
);
// Complete all pending commands with error
for (final command in _pendingResponses.values) {
if (!command.completer.isCompleted) {
command.completer.completeError(Exception('Queue cleared'));
}
}
_queue.clear();
_pendingResponses.clear();
onQueueSizeChanged?.call(0);
}
/// Dispose resources
void dispose() {
clear();
if (!_lock.isCompleted) {
_lock.complete();
}
}
}

View File

@@ -0,0 +1,230 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../meshcore_opcode_names.dart';
import '../../models/ble_packet_log.dart';
import 'ble_command_queue.dart';
/// Callback types for sender events
typedef OnErrorCallback = void Function(String error);
/// Sends commands to the BLE device
class BleCommandSender {
BluetoothCharacteristic? _rxCharacteristic;
int _txPacketCount = 0;
final List<BlePacketLog> _packetLogs = [];
static const int _maxLogSize = 1000;
// Command queue for serialization and response waiting
final BleCommandQueue _commandQueue = BleCommandQueue();
// Callbacks
OnErrorCallback? onError;
VoidCallback? onTxActivity;
// Getters
int get txPacketCount => _txPacketCount;
List<BlePacketLog> get packetLogs => List.unmodifiable(_packetLogs);
BleCommandQueue get commandQueue => _commandQueue;
/// Set the RX characteristic to write to
void setRxCharacteristic(BluetoothCharacteristic? characteristic) {
_rxCharacteristic = characteristic;
}
/// Write data to RX characteristic (fire-and-forget, no response expected)
///
/// This method is for commands that don't expect any response.
/// The command is queued and executed with proper spacing, but we don't wait
/// for any acknowledgment.
Future<void> writeData(Uint8List data) async {
if (_rxCharacteristic == null) {
throw Exception('Not connected');
}
final commandCode = data.isNotEmpty ? data[0] : 0;
// Enqueue the command (fire-and-forget)
await _commandQueue.enqueue<void>(
data: data,
commandCode: commandCode,
responseType: CommandResponseType.none,
);
// Actually send the data
await _sendToDevice(data);
}
/// Write data and wait for ACK (RESP_CODE_OK or RESP_CODE_ERR)
///
/// This method should be used for setup commands that return RESP_CODE_OK (0)
/// on success or RESP_CODE_ERR (1) on failure.
///
/// Examples: setAdvertLatLon, setAdvertName, setRadioParams, etc.
Future<void> writeDataAndWaitForAck(Uint8List data) async {
if (_rxCharacteristic == null) {
throw Exception('Not connected');
}
final commandCode = data.isNotEmpty ? data[0] : 0;
// Enqueue command but don't await yet — data must be sent to the device
// before it can respond with an ACK. Awaiting before send would deadlock.
final ackFuture = _commandQueue.enqueue<void>(
data: data,
commandCode: commandCode,
responseType: CommandResponseType.ack,
);
// Actually send the data
await _sendToDevice(data);
// Now wait for the ACK response
return ackFuture;
}
/// Write data and wait for specific response
///
/// This method should be used for query commands that return specific data.
///
/// Examples:
/// - CMD_DEVICE_QUERY → RESP_CODE_DEVICE_INFO
/// - CMD_APP_START → RESP_CODE_SELF_INFO
/// - CMD_GET_CONTACTS → RESP_CODE_CONTACTS_START
Future<T> writeDataAndWaitForResponse<T>(
Uint8List data,
int expectedResponseCode,
) async {
if (_rxCharacteristic == null) {
throw Exception('Not connected');
}
final commandCode = data.isNotEmpty ? data[0] : 0;
// Enqueue the command (wait for specific response)
final responseFuture = _commandQueue.enqueue<T>(
data: data,
commandCode: commandCode,
responseType: CommandResponseType.data,
expectedResponseCode: expectedResponseCode,
);
// Actually send the data
await _sendToDevice(data);
// Wait for response
return responseFuture;
}
/// Internal method to actually send data to the BLE device
Future<void> _sendToDevice(Uint8List data) async {
if (_rxCharacteristic == null) {
throw Exception('Not connected');
}
try {
// Extract command code from first byte
final commandCode = data.isNotEmpty ? data[0] : null;
final opcodeName = commandCode != null
? MeshCoreOpcodeNames.getCommandName(commandCode)
: 'UNKNOWN';
final opcodeHex = commandCode != null
? '0x${commandCode.toRadixString(16).padLeft(2, '0').toUpperCase()}'
: 'N/A';
debugPrint('📤 [TX] Sending command: $opcodeName ($opcodeHex)');
debugPrint(' Data size: ${data.length} bytes');
debugPrint(
' Hex: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}',
);
// Check if the characteristic supports write without response
final supportsWriteWithoutResponse =
_rxCharacteristic!.properties.writeWithoutResponse;
final supportsWrite = _rxCharacteristic!.properties.write;
if (supportsWriteWithoutResponse) {
await _rxCharacteristic!.write(data, withoutResponse: true);
} else if (supportsWrite) {
await _rxCharacteristic!.write(data, withoutResponse: false);
} else {
throw Exception('Characteristic does not support write operations');
}
// Log TX packet
_logPacket(data, PacketDirection.tx, responseCode: commandCode);
// Increment TX packet counter and trigger activity indicator
_txPacketCount++;
onTxActivity?.call();
debugPrint('✅ [TX] Command sent successfully');
} catch (e) {
debugPrint('❌ [TX] Write error: $e');
onError?.call('Write error: $e');
rethrow;
}
}
/// Log a packet
void _logPacket(
Uint8List data,
PacketDirection direction, {
int? responseCode,
}) {
// Add new packet
_packetLogs.add(
BlePacketLog(
timestamp: DateTime.now(),
rawData: data,
direction: direction,
responseCode: responseCode,
description: _getPacketDescription(responseCode),
),
);
// Limit log size to prevent memory issues
if (_packetLogs.length > _maxLogSize) {
_packetLogs.removeAt(0);
}
}
/// Get human-readable description of packet
String? _getPacketDescription(int? code) {
// TX packets - command codes
switch (code) {
case 4: // cmdGetContacts
return 'Get Contacts';
case 2: // cmdSendTxtMsg
return 'Send Text Message';
case 3: // cmdSendChannelTxtMsg
return 'Send Channel Message';
case 39: // cmdSendTelemetryReq
return 'Request Telemetry';
case 22: // cmdDeviceQuery
return 'Device Query';
case 1: // cmdAppStart
return 'App Start';
case 27: // cmdSendStatusReq
return 'Status Request';
default:
return null;
}
}
/// Reset packet counter
void resetCounter() {
_txPacketCount = 0;
}
/// Clear packet logs
void clearPacketLogs() {
_packetLogs.clear();
}
/// Dispose resources
void dispose() {
_commandQueue.dispose();
_rxCharacteristic = null;
_packetLogs.clear();
}
}

View File

@@ -0,0 +1,398 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../meshcore_constants.dart';
/// Callback types for connection events
typedef OnConnectionStateCallback = void Function(bool isConnected);
typedef OnErrorCallback = void Function(String error);
typedef OnReconnectionAttemptCallback =
void Function(int attemptNumber, int maxAttempts);
typedef OnRssiUpdateCallback = void Function(int rssi);
/// Manages BLE connection lifecycle with automatic reconnection
class BleConnectionManager {
BluetoothDevice? _device;
BluetoothCharacteristic? _rxCharacteristic;
BluetoothCharacteristic? _txCharacteristic;
bool _isConnected = false;
// Reconnection state
bool _reconnectionEnabled = true;
bool _isReconnecting = false;
int _reconnectionAttempt = 0;
Timer? _reconnectionTimer;
StreamSubscription<BluetoothConnectionState>? _connectionStateSubscription;
// RSSI monitoring
Timer? _rssiTimer;
int? _lastRssi;
// SAR-optimized reconnection: ~15 minutes total
// Pattern: Fast retries first (for temporary issues), then slower retries (for extended disconnections)
static const int _maxReconnectionAttempts = 30;
static const List<int> _reconnectionDelaysMs = [
2000, // 2s - immediate retry
3000, // 3s - quick retry
5000, // 5s - fast retry
10000, // 10s - moderate retry
15000, // 15s - longer retry
30000, // 30s - extended retry
30000, // 30s - keep trying every 30s after this
]; // Total: ~15 minutes of reconnection attempts
// Callbacks
OnConnectionStateCallback? onConnectionStateChanged;
OnErrorCallback? onError;
OnReconnectionAttemptCallback? onReconnectionAttempt;
OnRssiUpdateCallback? onRssiUpdate;
// Getters
bool get isConnected => _isConnected;
bool get isReconnecting => _isReconnecting;
int get reconnectionAttempt => _reconnectionAttempt;
int get maxReconnectionAttempts => _maxReconnectionAttempts;
BluetoothDevice? get device => _device;
BluetoothCharacteristic? get rxCharacteristic => _rxCharacteristic;
BluetoothCharacteristic? get txCharacteristic => _txCharacteristic;
/// Scan for MeshCore devices
Stream<ScanResult> scanForDevices({
Duration timeout = const Duration(seconds: 10),
}) async* {
try {
debugPrint('🔍 [BLE] Starting scan for MeshCore devices...');
debugPrint(' Service UUID: ${MeshCoreConstants.bleServiceUuid}');
debugPrint(' Timeout: ${timeout.inSeconds}s');
await FlutterBluePlus.startScan(
timeout: timeout,
withServices: [Guid(MeshCoreConstants.bleServiceUuid)],
);
debugPrint('✅ [BLE] Scan started successfully');
int deviceCount = 0;
await for (final scanResult in FlutterBluePlus.scanResults) {
debugPrint(
'📡 [BLE] Scan results batch received: ${scanResult.length} results',
);
for (final result in scanResult) {
debugPrint(
' Device: ${result.device.platformName} (${result.device.remoteId})',
);
debugPrint(' RSSI: ${result.rssi}');
debugPrint(' Service UUIDs: ${result.advertisementData.serviceUuids}');
if (result.advertisementData.serviceUuids.contains(
Guid(MeshCoreConstants.bleServiceUuid),
)) {
deviceCount++;
debugPrint(' ✅ MeshCore device found! Total: $deviceCount');
yield result;
} else {
debugPrint(' ❌ Not a MeshCore device (service UUID mismatch)');
}
}
}
debugPrint('🏁 [BLE] Scan completed. Found $deviceCount MeshCore devices');
} catch (e) {
debugPrint('❌ [BLE] Scan error: $e');
onError?.call('Scan error: $e');
}
}
/// Connect to a MeshCore device
Future<bool> connect(BluetoothDevice device) async {
try {
debugPrint(
'🔵 [BLE] Starting connection to device: ${device.platformName} (${device.remoteId})',
);
_device = device;
// Connect to device
debugPrint('🔵 [BLE] Calling device.connect() with 15s timeout...');
await device.connect(
license: License.free,
timeout: const Duration(seconds: 15),
mtu: 512,
);
debugPrint('✅ [BLE] Device connected successfully');
// Discover services
debugPrint('🔵 [BLE] Discovering services...');
final services = await device.discoverServices();
debugPrint('✅ [BLE] Found ${services.length} services');
// Log all discovered services for debugging
for (final service in services) {
debugPrint(' 📋 Service: ${service.uuid}');
for (final char in service.characteristics) {
debugPrint(' - Characteristic: ${char.uuid}');
}
}
// Find MeshCore service
debugPrint(
'🔵 [BLE] Looking for MeshCore service: ${MeshCoreConstants.bleServiceUuid}',
);
BluetoothService? meshCoreService;
for (final service in services) {
if (service.uuid.toString().toLowerCase() ==
MeshCoreConstants.bleServiceUuid.toLowerCase()) {
meshCoreService = service;
debugPrint('✅ [BLE] Found MeshCore service');
break;
}
}
if (meshCoreService == null) {
debugPrint('❌ [BLE] MeshCore service not found!');
throw Exception('MeshCore service not found');
}
// Find RX and TX characteristics
debugPrint('🔵 [BLE] Looking for RX and TX characteristics...');
debugPrint(' RX UUID: ${MeshCoreConstants.bleCharacteristicRxUuid}');
debugPrint(' TX UUID: ${MeshCoreConstants.bleCharacteristicTxUuid}');
for (final characteristic in meshCoreService.characteristics) {
final uuid = characteristic.uuid.toString().toLowerCase();
debugPrint(' 📋 Checking characteristic: $uuid');
if (uuid == MeshCoreConstants.bleCharacteristicRxUuid.toLowerCase()) {
_rxCharacteristic = characteristic;
debugPrint(' ✅ Found RX characteristic');
} else if (uuid ==
MeshCoreConstants.bleCharacteristicTxUuid.toLowerCase()) {
_txCharacteristic = characteristic;
debugPrint(' ✅ Found TX characteristic');
}
}
if (_rxCharacteristic == null || _txCharacteristic == null) {
debugPrint('❌ [BLE] Required characteristics not found!');
debugPrint(' RX found: ${_rxCharacteristic != null}');
debugPrint(' TX found: ${_txCharacteristic != null}');
throw Exception('Required characteristics not found');
}
// Enable notifications on TX characteristic
debugPrint('🔵 [BLE] Enabling notifications on TX characteristic...');
await _txCharacteristic!.setNotifyValue(true);
debugPrint('✅ [BLE] Notifications enabled');
_isConnected = true;
_reconnectionAttempt =
0; // Reset reconnection counter on successful connection
debugPrint('🔵 [BLE] Notifying connection state change: connected');
onConnectionStateChanged?.call(true);
// Monitor connection state for automatic reconnection
_setupConnectionMonitoring();
// Start RSSI monitoring
_startRssiMonitoring();
debugPrint('✅✅✅ [BLE] Connection completed successfully!');
return true;
} catch (e) {
debugPrint('❌❌❌ [BLE] Connection failed: $e');
debugPrint('Stack trace: ${StackTrace.current}');
onError?.call('Connection error: $e');
_isConnected = false;
onConnectionStateChanged?.call(false);
return false;
}
}
/// Disconnect from device
Future<void> disconnect() async {
try {
debugPrint('🔴 [BLE] Disconnect requested by user');
// Disable reconnection before disconnecting
_reconnectionEnabled = false;
_cancelReconnection();
_stopRssiMonitoring();
await _device?.disconnect();
_isConnected = false;
_device = null;
_rxCharacteristic = null;
_txCharacteristic = null;
onConnectionStateChanged?.call(false);
} catch (e) {
onError?.call('Disconnect error: $e');
}
}
/// Setup connection monitoring for automatic reconnection
void _setupConnectionMonitoring() {
debugPrint(
'🔵 [BLE] Setting up connection monitoring for device: ${_device?.platformName}',
);
// Cancel any existing subscription
_connectionStateSubscription?.cancel();
// Monitor connection state changes
_connectionStateSubscription = _device?.connectionState.listen((state) {
debugPrint('🔔 [BLE] Connection state changed: $state');
if (state == BluetoothConnectionState.disconnected) {
debugPrint('⚠️ [BLE] Device disconnected unexpectedly!');
_isConnected = false;
onConnectionStateChanged?.call(false);
// Attempt automatic reconnection if enabled
if (_reconnectionEnabled && !_isReconnecting) {
debugPrint('🔄 [BLE] Starting automatic reconnection...');
_attemptReconnection();
}
} else if (state == BluetoothConnectionState.connected) {
debugPrint('✅ [BLE] Device connected');
_isConnected = true;
_reconnectionAttempt = 0;
_isReconnecting = false;
onConnectionStateChanged?.call(true);
}
});
}
/// Attempt to reconnect to the device
Future<void> _attemptReconnection() async {
if (_device == null || _isReconnecting || !_reconnectionEnabled) {
return;
}
_isReconnecting = true;
_reconnectionAttempt++;
debugPrint(
'🔄 [BLE] Reconnection attempt $_reconnectionAttempt of $_maxReconnectionAttempts',
);
onReconnectionAttempt?.call(_reconnectionAttempt, _maxReconnectionAttempts);
if (_reconnectionAttempt > _maxReconnectionAttempts) {
debugPrint(
'❌ [BLE] Max reconnection attempts reached after ~15 minutes. Giving up.',
);
_isReconnecting = false;
onError?.call(
'Connection lost. Unable to reconnect after 15 minutes ($_maxReconnectionAttempts attempts).',
);
return;
}
// Calculate delay with exponential backoff (uses last delay for attempts beyond array length)
final delayIndex = (_reconnectionAttempt - 1).clamp(
0,
_reconnectionDelaysMs.length - 1,
);
final delayMs = _reconnectionDelaysMs[delayIndex];
debugPrint(
'🔄 [BLE] Waiting ${(delayMs / 1000).toStringAsFixed(0)}s before reconnection attempt $_reconnectionAttempt...',
);
// Wait before attempting reconnection
_reconnectionTimer = Timer(Duration(milliseconds: delayMs), () async {
if (!_reconnectionEnabled) {
debugPrint('🔄 [BLE] Reconnection cancelled by user');
_isReconnecting = false;
return;
}
try {
debugPrint('🔄 [BLE] Attempting to reconnect...');
// Try to reconnect
final success = await connect(_device!);
if (success) {
debugPrint('✅ [BLE] Reconnection successful!');
_isReconnecting = false;
_reconnectionAttempt = 0;
} else {
debugPrint('❌ [BLE] Reconnection attempt $_reconnectionAttempt failed');
_isReconnecting = false;
// Try again if we haven't reached max attempts
if (_reconnectionAttempt < _maxReconnectionAttempts) {
_attemptReconnection();
} else {
onError?.call(
'Connection lost. Unable to reconnect after 15 minutes ($_maxReconnectionAttempts attempts).',
);
}
}
} catch (e) {
debugPrint('❌ [BLE] Reconnection attempt $_reconnectionAttempt error: $e');
_isReconnecting = false;
// Try again if we haven't reached max attempts
if (_reconnectionAttempt < _maxReconnectionAttempts) {
_attemptReconnection();
} else {
onError?.call(
'Connection lost. Unable to reconnect after 15 minutes: $e',
);
}
}
});
}
/// Cancel ongoing reconnection attempts
void _cancelReconnection() {
debugPrint('🔴 [BLE] Cancelling reconnection attempts');
_reconnectionTimer?.cancel();
_reconnectionTimer = null;
_isReconnecting = false;
_reconnectionAttempt = 0;
_connectionStateSubscription?.cancel();
_connectionStateSubscription = null;
}
/// Enable automatic reconnection (useful after user manually disconnects)
void enableReconnection() {
debugPrint('🔵 [BLE] Re-enabling automatic reconnection');
_reconnectionEnabled = true;
}
/// Start monitoring RSSI in the background
void _startRssiMonitoring() {
debugPrint('📡 [BLE] Starting RSSI monitoring (every 5 seconds)');
_stopRssiMonitoring(); // Cancel any existing timer
_rssiTimer = Timer.periodic(const Duration(seconds: 5), (timer) async {
if (_device != null && _isConnected) {
try {
final rssi = await _device!.readRssi();
if (_lastRssi != rssi) {
_lastRssi = rssi;
onRssiUpdate?.call(rssi);
}
} catch (e) {
debugPrint('⚠️ [BLE] Failed to read RSSI: $e');
}
}
});
}
/// Stop RSSI monitoring
void _stopRssiMonitoring() {
_rssiTimer?.cancel();
_rssiTimer = null;
_lastRssi = null;
debugPrint('📡 [BLE] RSSI monitoring stopped');
}
/// Dispose resources
void dispose() {
debugPrint('🔴 [BLE] Disposing BLE connection manager');
_cancelReconnection();
_stopRssiMonitoring();
_device = null;
_rxCharacteristic = null;
_txCharacteristic = null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,151 @@
import 'dart:typed_data';
import 'dart:convert';
/// Buffer reader for parsing MeshCore protocol binary data
class BufferReader {
final Uint8List _buffer;
/// Current read position in the buffer
int offset = 0;
BufferReader(this._buffer);
/// Get remaining bytes count
int get remainingBytesCount => _buffer.length - offset;
/// Check if there are bytes remaining
bool get hasRemaining => offset < _buffer.length;
/// Read a single byte (uint8)
int readByte() {
if (offset >= _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
return _buffer[offset++];
}
/// Read a signed byte (int8)
int readInt8() {
final value = readByte();
return value > 127 ? value - 256 : value;
}
/// Read unsigned 16-bit integer (little-endian)
int readUInt16LE() {
if (offset + 2 > _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
final value = _buffer[offset] | (_buffer[offset + 1] << 8);
offset += 2;
return value;
}
/// Read signed 16-bit integer (little-endian)
int readInt16LE() {
final value = readUInt16LE();
return value > 32767 ? value - 65536 : value;
}
/// Read unsigned 16-bit integer (big-endian)
int readUInt16BE() {
if (offset + 2 > _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
final value = (_buffer[offset] << 8) | _buffer[offset + 1];
offset += 2;
return value;
}
/// Read signed 16-bit integer (big-endian)
int readInt16BE() {
final value = readUInt16BE();
return value > 32767 ? value - 65536 : value;
}
/// Read unsigned 32-bit integer (little-endian)
int readUInt32LE() {
if (offset + 4 > _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
final value = _buffer[offset] |
(_buffer[offset + 1] << 8) |
(_buffer[offset + 2] << 16) |
(_buffer[offset + 3] << 24);
offset += 4;
return value;
}
/// Read signed 32-bit integer (little-endian)
int readInt32LE() {
final value = readUInt32LE();
return value > 2147483647 ? value - 4294967296 : value;
}
/// Read a fixed number of bytes
Uint8List readBytes(int length) {
if (offset + length > _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
final bytes = _buffer.sublist(offset, offset + length);
offset += length;
return bytes;
}
/// Read remaining bytes
Uint8List readRemainingBytes() {
final bytes = _buffer.sublist(offset);
offset = _buffer.length;
return bytes;
}
/// Read null-terminated string (C-string) with max length
String readCString(int maxLength) {
if (offset + maxLength > _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
final bytes = _buffer.sublist(offset, offset + maxLength);
offset += maxLength;
// Find null terminator
int nullIndex = bytes.indexOf(0);
if (nullIndex == -1) {
nullIndex = maxLength;
}
// Decode string up to null terminator
return utf8.decode(bytes.sublist(0, nullIndex));
}
/// Read length-prefixed string (remaining bytes as UTF-8)
String readString() {
final bytes = readRemainingBytes();
return utf8.decode(bytes);
}
/// Peek at next byte without advancing offset
int peekByte() {
if (offset >= _buffer.length) {
throw Exception('Buffer overflow: attempting to peek beyond buffer length');
}
return _buffer[offset];
}
/// Skip bytes
void skip(int count) {
if (offset + count > _buffer.length) {
throw Exception('Buffer overflow: attempting to skip beyond buffer length');
}
offset += count;
}
/// Reset offset to beginning
void reset() {
offset = 0;
}
@override
String toString() {
return 'BufferReader(length: ${_buffer.length}, offset: $offset, remaining: $remainingBytesCount)';
}
}

View File

@@ -0,0 +1,129 @@
import 'dart:typed_data';
import 'dart:convert';
/// Buffer writer for creating MeshCore protocol binary data
class BufferWriter {
final List<int> _buffer = [];
/// Get current buffer length
int get length => _buffer.length;
/// Write a single byte (uint8)
void writeByte(int value) {
if (value < 0 || value > 255) {
throw ArgumentError('Byte value must be between 0 and 255');
}
_buffer.add(value);
}
/// Write a signed byte (int8)
void writeInt8(int value) {
if (value < -128 || value > 127) {
throw ArgumentError('Int8 value must be between -128 and 127');
}
_buffer.add(value < 0 ? value + 256 : value);
}
/// Write unsigned 16-bit integer (little-endian)
void writeUInt16LE(int value) {
if (value < 0 || value > 65535) {
throw ArgumentError('UInt16 value must be between 0 and 65535');
}
_buffer.add(value & 0xFF);
_buffer.add((value >> 8) & 0xFF);
}
/// Write signed 16-bit integer (little-endian)
void writeInt16LE(int value) {
if (value < -32768 || value > 32767) {
throw ArgumentError('Int16 value must be between -32768 and 32767');
}
final unsigned = value < 0 ? value + 65536 : value;
writeUInt16LE(unsigned);
}
/// Write unsigned 32-bit integer (little-endian)
void writeUInt32LE(int value) {
if (value < 0 || value > 4294967295) {
throw ArgumentError('UInt32 value must be between 0 and 4294967295');
}
_buffer.add(value & 0xFF);
_buffer.add((value >> 8) & 0xFF);
_buffer.add((value >> 16) & 0xFF);
_buffer.add((value >> 24) & 0xFF);
}
/// Write signed 32-bit integer (little-endian)
void writeInt32LE(int value) {
if (value < -2147483648 || value > 2147483647) {
throw ArgumentError('Int32 value must be between -2147483648 and 2147483647');
}
final unsigned = value < 0 ? value + 4294967296 : value;
writeUInt32LE(unsigned);
}
/// Write bytes from Uint8List
void writeBytes(Uint8List bytes) {
_buffer.addAll(bytes);
}
/// Write bytes from `List<int>`
void writeBytesFromList(List<int> bytes) {
_buffer.addAll(bytes);
}
/// Write null-terminated string (C-string) with fixed length
/// Pads with zeros if string is shorter than maxLength
void writeCString(String str, int maxLength) {
final bytes = utf8.encode(str);
// Ensure we don't exceed max length
final length = bytes.length < maxLength ? bytes.length : maxLength;
// Write string bytes
for (int i = 0; i < length; i++) {
_buffer.add(bytes[i]);
}
// Pad with zeros
for (int i = length; i < maxLength; i++) {
_buffer.add(0);
}
}
/// Write length-prefixed string
void writeString(String str) {
final bytes = utf8.encode(str);
_buffer.addAll(bytes);
}
/// Write string with length prefix (1 byte)
void writeLengthPrefixedString(String str) {
final bytes = utf8.encode(str);
if (bytes.length > 255) {
throw ArgumentError('String too long for length-prefixed format (max 255 bytes)');
}
writeByte(bytes.length);
_buffer.addAll(bytes);
}
/// Get buffer as Uint8List
Uint8List toBytes() {
return Uint8List.fromList(_buffer);
}
/// Clear the buffer
void clear() {
_buffer.clear();
}
/// Get buffer as hex string (for debugging)
String toHexString() {
return _buffer.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
}
@override
String toString() {
return 'BufferWriter(length: $length, hex: ${toHexString()})';
}
}

View File

@@ -0,0 +1,54 @@
import 'dart:io' show Platform;
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
/// Service for accessing build information from native platform code
/// Currently supports Android only - returns "unknown" for other platforms
class BuildInfoService {
static final BuildInfoService _instance = BuildInfoService._internal();
factory BuildInfoService() => _instance;
BuildInfoService._internal();
static const MethodChannel _channel = MethodChannel('com.meshcore.sar/build_info');
String? _cachedCommitHash;
/// Get the commit hash that was embedded during build time
/// Returns "unknown" if:
/// - Not running on Android
/// - Platform channel call fails
/// - Build was not configured with COMMIT_HASH
Future<String> getCommitHash() async {
// Return cached value if available
if (_cachedCommitHash != null) {
return _cachedCommitHash!;
}
// Only Android has the platform channel implementation
if (!Platform.isAndroid) {
debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"');
_cachedCommitHash = 'unknown';
return _cachedCommitHash!;
}
try {
final String commitHash = await _channel.invokeMethod('getCommitHash');
_cachedCommitHash = commitHash;
debugPrint('[BuildInfoService] Commit hash: $commitHash');
return commitHash;
} on PlatformException catch (e) {
debugPrint('[BuildInfoService] Failed to get commit hash: ${e.message}');
_cachedCommitHash = 'unknown';
return _cachedCommitHash!;
} catch (e) {
debugPrint('[BuildInfoService] Unexpected error getting commit hash: $e');
_cachedCommitHash = 'unknown';
return _cachedCommitHash!;
}
}
/// Clear cached commit hash (useful for testing)
void clearCache() {
_cachedCommitHash = null;
}
}

View File

@@ -0,0 +1,321 @@
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import '../models/contact_telemetry.dart';
import 'buffer_reader.dart';
import 'meshcore_constants.dart';
/// Cayenne LPP (Low Power Payload) data parser
/// Used for decoding telemetry sensor data from MeshCore devices
class CayenneLppParser {
/// Parse Cayenne LPP data into ContactTelemetry
static ContactTelemetry parse(Uint8List data) {
debugPrint(' [CayenneLPP] Parsing LPP data...');
debugPrint(' Data length: ${data.length} bytes');
debugPrint(
' Data (hex): ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}',
);
final reader = BufferReader(data);
LatLng? gpsLocation;
double? batteryPercentage;
double? batteryMilliVolts;
double? temperature;
double? humidity;
double? pressure;
final extraSensorData = <String, dynamic>{};
int fieldCount = 0;
while (reader.hasRemaining) {
try {
fieldCount++;
debugPrint(
' [Field $fieldCount] Position: ${data.length - reader.remainingBytesCount}',
);
final channel = reader.readByte();
debugPrint(' Channel: $channel');
final type = reader.readByte();
debugPrint(
' Type: $type (0x${type.toRadixString(16).padLeft(2, '0')})',
);
switch (type) {
case MeshCoreConstants.lppDigitalInput:
final value = reader.readByte();
debugPrint(' Digital Input: $value');
extraSensorData['digital_input_$channel'] = value;
break;
case MeshCoreConstants.lppDigitalOutput:
final value = reader.readByte();
debugPrint(' Digital Output: $value');
extraSensorData['digital_output_$channel'] = value;
break;
case MeshCoreConstants.lppAnalogInput:
final rawValue = reader.readInt16BE();
final value = rawValue / 100.0;
debugPrint(' Analog Input (raw): $rawValue');
debugPrint(' Analog Input (volts): ${value}V');
extraSensorData['analog_input_$channel'] = value;
// If this is a battery reading
if (channel == 0 || channel == 1) {
batteryMilliVolts = value * 1000;
batteryPercentage = _calculateBatteryPercentage(value);
debugPrint(
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
);
}
break;
case MeshCoreConstants.lppAnalogOutput:
final rawValue = reader.readInt16BE();
final value = rawValue / 100.0;
debugPrint(' Analog Output (raw): $rawValue');
debugPrint(' Analog Output (volts): ${value}V');
extraSensorData['analog_output_$channel'] = value;
break;
case MeshCoreConstants.lppIlluminanceSensor:
final value = reader.readUInt16BE();
debugPrint(' Illuminance: $value lux');
extraSensorData['illuminance_$channel'] = value;
break;
case MeshCoreConstants.lppPresenceSensor:
final value = reader.readByte();
debugPrint(' Presence: $value');
extraSensorData['presence_$channel'] = value;
break;
case MeshCoreConstants.lppTemperatureSensor:
final rawValue = reader.readInt16BE();
temperature = rawValue / 10.0;
debugPrint(' Temperature (raw): $rawValue');
debugPrint(
' Temperature: ${temperature.toStringAsFixed(1)}°C',
);
break;
case MeshCoreConstants.lppHumiditySensor:
final rawValue = reader.readByte();
humidity = rawValue / 2.0;
debugPrint(' Humidity (raw): $rawValue');
debugPrint(' Humidity: ${humidity.toStringAsFixed(1)}%');
break;
case MeshCoreConstants.lppAccelerometer:
final x = reader.readInt16BE() / 1000.0;
final y = reader.readInt16BE() / 1000.0;
final z = reader.readInt16BE() / 1000.0;
debugPrint(' Accelerometer: x=$x, y=$y, z=$z');
extraSensorData['accelerometer_$channel'] = {
'x': x,
'y': y,
'z': z,
};
break;
case MeshCoreConstants.lppBarometer:
final rawValue = reader.readUInt16BE();
pressure = rawValue / 10.0;
debugPrint(' Barometer (raw): $rawValue');
debugPrint(' Barometer: ${pressure.toStringAsFixed(1)} hPa');
break;
case MeshCoreConstants.lppVoltageSensor:
final rawValue = reader.readUInt16BE();
final value = rawValue / 100.0;
debugPrint(' Voltage (raw): $rawValue');
debugPrint(' Voltage: ${value}V');
// Treat voltage sensor as battery reading
batteryMilliVolts = value * 1000;
batteryPercentage = _calculateBatteryPercentage(value);
debugPrint(
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
);
break;
case MeshCoreConstants.lppGyrometer:
final x = reader.readInt16BE() / 100.0;
final y = reader.readInt16BE() / 100.0;
final z = reader.readInt16BE() / 100.0;
debugPrint(' Gyrometer: x=$x, y=$y, z=$z');
extraSensorData['gyrometer_$channel'] = {'x': x, 'y': y, 'z': z};
break;
case MeshCoreConstants.lppGps:
// Standard Cayenne LPP GPS format (type 0x88):
// - Latitude: 3 bytes, signed 24-bit, big-endian, × 10000
// - Longitude: 3 bytes, signed 24-bit, big-endian, × 10000
// - Altitude: 3 bytes, signed 24-bit, big-endian, × 100
// Total: 9 bytes (not the 12 bytes used in MeshCore advertisements!)
// Read 3-byte signed big-endian integers
final latBytes = reader.readBytes(3);
int rawLat = (latBytes[0] << 16) | (latBytes[1] << 8) | latBytes[2];
// Sign extend from 24-bit to 32-bit
if (rawLat > 0x7FFFFF) rawLat = rawLat - 0x1000000;
final lonBytes = reader.readBytes(3);
int rawLon = (lonBytes[0] << 16) | (lonBytes[1] << 8) | lonBytes[2];
if (rawLon > 0x7FFFFF) rawLon = rawLon - 0x1000000;
final altBytes = reader.readBytes(3);
int rawAlt = (altBytes[0] << 16) | (altBytes[1] << 8) | altBytes[2];
if (rawAlt > 0x7FFFFF) rawAlt = rawAlt - 0x1000000;
// Decode: divide by scaling factors
final lat = rawLat / 10000.0;
final lon = rawLon / 10000.0;
final alt = rawAlt / 100.0;
debugPrint(
' GPS Location (raw 24-bit BE): lat=$rawLat (0x${rawLat.toRadixString(16).padLeft(6, '0')}), lon=$rawLon (0x${rawLon.toRadixString(16).padLeft(6, '0')}), alt=$rawAlt (0x${rawAlt.toRadixString(16).padLeft(6, '0')})',
);
debugPrint(
' GPS Location (decoded): ${lat.toStringAsFixed(6)}°, ${lon.toStringAsFixed(6)}°, altitude=${alt.toStringAsFixed(2)}m',
);
// Validate coordinates are in valid range
if (lat < -90.0 || lat > 90.0) {
debugPrint(' ⚠️ WARNING: Latitude out of range: $lat°');
}
if (lon < -180.0 || lon > 180.0) {
debugPrint(' ⚠️ WARNING: Longitude out of range: $lon°');
}
gpsLocation = LatLng(lat, lon);
extraSensorData['altitude_$channel'] = alt;
break;
default:
debugPrint(
' ⚠️ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes',
);
// Unknown type, skip remaining to avoid parsing errors
reader.skip(reader.remainingBytesCount);
break;
}
} catch (e) {
debugPrint(' ❌ Parsing error: $e');
// If we encounter a parsing error, break and return what we have
break;
}
}
debugPrint(' Parsed $fieldCount fields');
debugPrint(' ✅ [CayenneLPP] Parsing complete');
debugPrint(
' GPS: ${gpsLocation != null ? '${gpsLocation.latitude}°, ${gpsLocation.longitude}°' : 'none'}',
);
debugPrint(
' Battery: ${batteryPercentage != null ? '${batteryPercentage.toStringAsFixed(1)}%' : 'none'}',
);
debugPrint(
' Temperature: ${temperature != null ? '${temperature.toStringAsFixed(1)}°C' : 'none'}',
);
// IMPORTANT: Cayenne LPP format does NOT include a timestamp field.
// We use DateTime.now() as the timestamp, which represents when the data
// was RECEIVED/PARSED by the app, NOT when it was collected by the device.
//
// This means:
// - If the device sends cached/old telemetry data, the timestamp will still
// show as "recent" (a few seconds ago) because it was just received
// - The actual age of the telemetry data cannot be determined from the LPP format
// - Devices may cache telemetry for hours and send it later when requested
final parseTimestamp = DateTime.now();
debugPrint(
' Timestamp: $parseTimestamp (parse time, NOT device collection time)',
);
return ContactTelemetry(
gpsLocation: gpsLocation,
batteryPercentage: batteryPercentage,
batteryMilliVolts: batteryMilliVolts,
temperature: temperature,
humidity: humidity,
pressure: pressure,
timestamp: parseTimestamp,
extraSensorData: extraSensorData.isNotEmpty ? extraSensorData : null,
);
}
/// Calculate battery percentage from voltage (V)
static double _calculateBatteryPercentage(double voltage) {
// Standard lithium battery curve: 3.0V = 0%, 4.2V = 100%
if (voltage <= 3.0) return 0.0;
if (voltage >= 4.2) return 100.0;
return ((voltage - 3.0) / 1.2) * 100.0;
}
/// Create Cayenne LPP data for GPS location
/// Standard Cayenne LPP GPS format (type 0x88):
/// - Latitude: 3 bytes, signed 24-bit, big-endian, × 10000
/// - Longitude: 3 bytes, signed 24-bit, big-endian, × 10000
/// - Altitude: 3 bytes, signed 24-bit, big-endian, × 100
static Uint8List createGpsData({
required double latitude,
required double longitude,
double altitude = 0.0,
int channel = 0,
}) {
final buffer = <int>[];
buffer.add(channel);
buffer.add(MeshCoreConstants.lppGps);
// Latitude (signed 24-bit BE, 3 bytes, 0.0001° precision)
int lat = (latitude * 10000).round();
// Handle negative values (two's complement for 24-bit)
if (lat < 0) lat = lat + 0x1000000;
buffer.add((lat >> 16) & 0xFF); // Byte 0 (MSB)
buffer.add((lat >> 8) & 0xFF); // Byte 1
buffer.add(lat & 0xFF); // Byte 2 (LSB)
// Longitude (signed 24-bit BE, 3 bytes, 0.0001° precision)
int lon = (longitude * 10000).round();
if (lon < 0) lon = lon + 0x1000000;
buffer.add((lon >> 16) & 0xFF); // Byte 0 (MSB)
buffer.add((lon >> 8) & 0xFF); // Byte 1
buffer.add(lon & 0xFF); // Byte 2 (LSB)
// Altitude (signed 24-bit BE, 3 bytes, 0.01m precision)
int alt = (altitude * 100).round();
if (alt < 0) alt = alt + 0x1000000;
buffer.add((alt >> 16) & 0xFF); // Byte 0 (MSB)
buffer.add((alt >> 8) & 0xFF); // Byte 1
buffer.add(alt & 0xFF); // Byte 2 (LSB)
return Uint8List.fromList(buffer);
}
/// Create Cayenne LPP data for temperature
static Uint8List createTemperatureData(double celsius, {int channel = 0}) {
final buffer = <int>[];
buffer.add(channel);
buffer.add(MeshCoreConstants.lppTemperatureSensor);
final temp = (celsius * 10).round();
buffer.add((temp >> 8) & 0xFF);
buffer.add(temp & 0xFF);
return Uint8List.fromList(buffer);
}
/// Create Cayenne LPP data for battery voltage
static Uint8List createBatteryData(double voltage, {int channel = 0}) {
final buffer = <int>[];
buffer.add(channel);
buffer.add(MeshCoreConstants.lppAnalogInput);
final volts = (voltage * 100).round();
buffer.add((volts >> 8) & 0xFF);
buffer.add(volts & 0xFF);
return Uint8List.fromList(buffer);
}
}

View File

@@ -0,0 +1,207 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import '../models/contact_telemetry.dart';
import '../utils/key_comparison.dart';
import 'package:latlong2/latlong.dart';
/// Service for persisting contacts to local storage
class ContactStorageService {
static const String _contactsKey = 'stored_contacts';
static const int _maxStoredContacts = 500; // Store up to 500 contacts
/// Save contacts to persistent storage
Future<void> saveContacts(List<Contact> contacts) async {
try {
final prefs = await SharedPreferences.getInstance();
// Convert contacts to JSON
final jsonList = contacts
.map((contact) => _contactToJson(contact))
.toList();
// Limit to max stored contacts (keep most recent)
final limitedList = jsonList.length > _maxStoredContacts
? jsonList.sublist(jsonList.length - _maxStoredContacts)
: jsonList;
final jsonString = jsonEncode(limitedList);
await prefs.setString(_contactsKey, jsonString);
debugPrint(
'✅ [ContactStorage] Saved ${limitedList.length} contacts to storage',
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error saving contacts: $e');
}
}
/// Load contacts from persistent storage
/// [excludePublicKey] - optional public key to exclude (e.g., device's own key)
Future<List<Contact>> loadContacts({Uint8List? excludePublicKey}) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_contactsKey);
if (jsonString == null || jsonString.isEmpty) {
debugPrint(' [ContactStorage] No stored contacts found');
return [];
}
final jsonList = jsonDecode(jsonString) as List<dynamic>;
final contacts = jsonList
.map((json) => _contactFromJson(json as Map<String, dynamic>))
.where((contact) => contact != null)
.cast<Contact>()
.toList();
// Filter out contacts with the excluded public key
final filteredContacts = excludePublicKey != null
? contacts.where((contact) {
final matches = contact.publicKey.matches(excludePublicKey);
if (matches) {
debugPrint(
' [ContactStorage] Excluding contact with matching public key: ${contact.advName}',
);
}
return !matches;
}).toList()
: contacts;
debugPrint(
'✅ [ContactStorage] Loaded ${filteredContacts.length} contacts from storage'
'${excludePublicKey != null ? ' (${contacts.length - filteredContacts.length} excluded)' : ''}',
);
return filteredContacts;
} catch (e) {
debugPrint('❌ [ContactStorage] Error loading contacts: $e');
return [];
}
}
/// Clear all stored contacts
Future<void> clearContacts() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_contactsKey);
debugPrint('✅ [ContactStorage] Cleared all stored contacts');
} catch (e) {
debugPrint('❌ [ContactStorage] Error clearing contacts: $e');
}
}
/// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_contactsKey);
if (jsonString == null || jsonString.isEmpty) {
return {'contactCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
}
final sizeBytes = jsonString.length;
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return {
'contactCount': jsonList.length,
'storageSizeBytes': sizeBytes,
'storageSizeKB': (sizeBytes / 1024).toStringAsFixed(2),
};
} catch (e) {
debugPrint('❌ [ContactStorage] Error getting storage stats: $e');
return {'contactCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
}
}
/// Convert Contact to JSON
Map<String, dynamic> _contactToJson(Contact contact) {
return {
'publicKey': base64Encode(contact.publicKey),
'type': contact.type.value,
'flags': contact.flags,
'outPathLen': contact.outPathLen,
'outPath': base64Encode(contact.outPath),
'advName': contact.advName,
'lastAdvert': contact.lastAdvert,
'advLat': contact.advLat,
'advLon': contact.advLon,
'lastMod': contact.lastMod,
'telemetry': contact.telemetry != null
? _telemetryToJson(contact.telemetry!)
: null,
};
}
/// Convert JSON to Contact
Contact? _contactFromJson(Map<String, dynamic> json) {
try {
return Contact(
publicKey: Uint8List.fromList(
base64Decode(json['publicKey'] as String),
),
type: ContactType.fromValue(json['type'] as int),
flags: json['flags'] as int,
outPathLen: json['outPathLen'] as int,
outPath: Uint8List.fromList(base64Decode(json['outPath'] as String)),
advName: json['advName'] as String,
lastAdvert: json['lastAdvert'] as int,
advLat: json['advLat'] as int,
advLon: json['advLon'] as int,
lastMod: json['lastMod'] as int,
telemetry: json['telemetry'] != null
? _telemetryFromJson(json['telemetry'] as Map<String, dynamic>)
: null,
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error parsing contact from JSON: $e');
return null;
}
}
/// Convert ContactTelemetry to JSON
Map<String, dynamic> _telemetryToJson(ContactTelemetry telemetry) {
return {
'gpsLocation': telemetry.gpsLocation != null
? {
'latitude': telemetry.gpsLocation!.latitude,
'longitude': telemetry.gpsLocation!.longitude,
}
: null,
'batteryPercentage': telemetry.batteryPercentage,
'batteryMilliVolts': telemetry.batteryMilliVolts,
'temperature': telemetry.temperature,
'humidity': telemetry.humidity,
'pressure': telemetry.pressure,
'timestampMillis': telemetry.timestamp.millisecondsSinceEpoch,
'extraSensorData': telemetry.extraSensorData,
};
}
/// Convert JSON to ContactTelemetry
ContactTelemetry? _telemetryFromJson(Map<String, dynamic> json) {
try {
return ContactTelemetry(
gpsLocation: json['gpsLocation'] != null
? LatLng(
json['gpsLocation']['latitude'] as double,
json['gpsLocation']['longitude'] as double,
)
: null,
batteryPercentage: json['batteryPercentage'] as double?,
batteryMilliVolts: json['batteryMilliVolts'] as double?,
temperature: json['temperature'] as double?,
humidity: json['humidity'] as double?,
pressure: json['pressure'] as double?,
timestamp: DateTime.fromMillisecondsSinceEpoch(
json['timestampMillis'] as int,
),
extraSensorData: json['extraSensorData'] as Map<String, dynamic>?,
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error parsing telemetry from JSON: $e');
return null;
}
}
}

View File

@@ -0,0 +1,341 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:xml/xml.dart';
import 'package:path_provider/path_provider.dart';
import 'package:file_picker/file_picker.dart';
import 'package:share_plus/share_plus.dart';
import '../models/location_trail.dart';
/// Service for importing and exporting location trails in GPX format
class GpxService {
/// Export a LocationTrail to GPX 1.1 format
/// Returns the GPX content as a string
static String exportToGpx(LocationTrail trail, {String? customName}) {
final builder = XmlBuilder();
builder.processing('xml', 'version="1.0" encoding="UTF-8"');
builder.element('gpx', nest: () {
// GPX attributes
builder.attribute('version', '1.1');
builder.attribute('creator', 'MeshCore SAR');
builder.attribute(
'xmlns',
'http://www.topografix.com/GPX/1/1',
);
builder.attribute(
'xmlns:xsi',
'http://www.w3.org/2001/XMLSchema-instance',
);
builder.attribute(
'xsi:schemaLocation',
'http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd',
);
// Metadata section
builder.element('metadata', nest: () {
final name = customName ??
'MeshCore Trail - ${_formatDateTime(trail.startTime)}';
builder.element('name', nest: () => builder.text(name));
builder.element(
'time',
nest: () => builder.text(trail.startTime.toIso8601String()),
);
// Add trail statistics in description
final distance = trail.totalDistance;
final duration = trail.duration;
final description =
'Distance: ${_formatDistance(distance)}, '
'Duration: ${_formatDuration(duration)}, '
'Points: ${trail.points.length}';
builder.element('desc', nest: () => builder.text(description));
});
// Track section
builder.element('trk', nest: () {
final trackName = customName ?? 'MeshCore Trail';
builder.element('name', nest: () => builder.text(trackName));
// Track segment with all points
builder.element('trkseg', nest: () {
for (final point in trail.points) {
builder.element('trkpt', nest: () {
builder.attribute('lat', point.position.latitude.toString());
builder.attribute('lon', point.position.longitude.toString());
// Timestamp (required for proper GPX)
builder.element(
'time',
nest: () => builder.text(point.timestamp.toIso8601String()),
);
// Elevation (optional, set to 0 if not available)
builder.element('ele', nest: () => builder.text('0'));
// Extensions for additional data (accuracy, speed)
if (point.accuracy != null || point.speed != null) {
builder.element('extensions', nest: () {
if (point.accuracy != null) {
builder.element(
'accuracy',
nest: () => builder.text(point.accuracy.toString()),
);
}
if (point.speed != null) {
builder.element(
'speed',
nest: () => builder.text(point.speed.toString()),
);
}
});
}
});
}
});
});
});
final document = builder.buildDocument();
return document.toXmlString(pretty: true, indent: ' ');
}
/// Parse GPX content and return a LocationTrail
/// Throws FormatException if GPX is invalid
static LocationTrail importFromGpx(String gpxContent) {
try {
final document = XmlDocument.parse(gpxContent);
final gpxElement = document.findElements('gpx').firstOrNull;
if (gpxElement == null) {
throw const FormatException('Invalid GPX file: Missing <gpx> element');
}
// Extract track name from metadata or track element (currently unused, kept for future use)
// String? trackName;
// final metadataName =
// gpxElement.findElements('metadata').firstOrNull?.findElements('name').firstOrNull?.innerText;
// final trackNameElement = gpxElement
// .findElements('trk')
// .firstOrNull
// ?.findElements('name')
// .firstOrNull
// ?.innerText;
// trackName = metadataName ?? trackNameElement ?? 'Imported Trail';
// Extract track points
final trackPoints = <TrailPoint>[];
final tracks = gpxElement.findElements('trk');
if (tracks.isEmpty) {
throw const FormatException(
'Invalid GPX file: No <trk> elements found',
);
}
// Process first track only
final track = tracks.first;
final segments = track.findElements('trkseg');
for (final segment in segments) {
final trkpts = segment.findElements('trkpt');
for (final trkpt in trkpts) {
try {
// Extract latitude and longitude (required)
final latStr = trkpt.getAttribute('lat');
final lonStr = trkpt.getAttribute('lon');
if (latStr == null || lonStr == null) {
debugPrint('⚠️ Skipping track point: Missing lat/lon attributes');
continue;
}
final lat = double.parse(latStr);
final lon = double.parse(lonStr);
// Extract timestamp (optional)
final timeStr =
trkpt.findElements('time').firstOrNull?.innerText;
final timestamp = timeStr != null
? DateTime.parse(timeStr)
: DateTime.now();
// Extract elevation (optional, currently unused but parsed for future use)
// final eleStr = trkpt.findElements('ele').firstOrNull?.innerText;
// final elevation = eleStr != null ? double.tryParse(eleStr) : null;
// Extract extensions (accuracy, speed)
double? accuracy;
double? speed;
final extensions =
trkpt.findElements('extensions').firstOrNull;
if (extensions != null) {
final accuracyStr =
extensions.findElements('accuracy').firstOrNull?.innerText;
final speedStr =
extensions.findElements('speed').firstOrNull?.innerText;
accuracy = accuracyStr != null ? double.tryParse(accuracyStr) : null;
speed = speedStr != null ? double.tryParse(speedStr) : null;
}
// Create trail point
trackPoints.add(
TrailPoint(
position: LatLng(lat, lon),
timestamp: timestamp,
accuracy: accuracy,
speed: speed,
),
);
} catch (e) {
debugPrint('⚠️ Error parsing track point: $e');
// Continue with next point
}
}
}
if (trackPoints.isEmpty) {
throw const FormatException(
'Invalid GPX file: No valid track points found',
);
}
// Create LocationTrail from parsed points
final startTime = trackPoints.first.timestamp;
final endTime = trackPoints.last.timestamp;
return LocationTrail(
id: 'imported_${DateTime.now().millisecondsSinceEpoch}',
points: trackPoints,
startTime: startTime,
endTime: endTime,
isActive: false,
);
} on XmlException catch (e) {
throw FormatException('Invalid GPX XML: ${e.message}');
} catch (e) {
throw FormatException('Failed to parse GPX file: $e');
}
}
/// Export trail to file and trigger system share sheet
/// Returns true if successful
static Future<bool> exportTrailToFile(
LocationTrail trail, {
String? customName,
}) async {
try {
// Generate GPX content
debugPrint('📤 Generating GPX content...');
final gpxContent = exportToGpx(trail, customName: customName);
// Create filename with timestamp
final timestamp = DateTime.now();
final filename =
'meshcore_trail_${timestamp.year}-${timestamp.month.toString().padLeft(2, '0')}-${timestamp.day.toString().padLeft(2, '0')}_${timestamp.hour.toString().padLeft(2, '0')}${timestamp.minute.toString().padLeft(2, '0')}${timestamp.second.toString().padLeft(2, '0')}.gpx';
// Save to temporary directory
final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/$filename');
await file.writeAsString(gpxContent);
debugPrint('📤 GPX file saved: ${file.path}');
debugPrint('📤 File size: ${file.lengthSync()} bytes');
// Share the file using system share sheet
final result = await SharePlus.instance.share(
ShareParams(
files: [XFile(file.path, mimeType: 'application/gpx+xml')],
subject: 'MeshCore Trail Export',
text: 'MeshCore SAR location trail (${trail.points.length} points)',
),
);
debugPrint('📤 Share result: ${result.status}');
return result.status == ShareResultStatus.success ||
result.status == ShareResultStatus.unavailable; // unavailable = user dismissed, still OK
} catch (e) {
debugPrint('❌ Failed to export trail: $e');
return false;
}
}
/// Import trail from GPX file using file picker
/// Returns LocationTrail if successful, null if cancelled or failed
static Future<LocationTrail?> importTrailFromFile() async {
try {
// Open file picker for GPX files
debugPrint('📥 Opening file picker for GPX import...');
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['gpx'],
allowMultiple: false,
);
if (result == null || result.files.isEmpty) {
debugPrint('📥 Import cancelled by user');
return null;
}
final file = result.files.first;
debugPrint('📥 Selected file: ${file.name}');
debugPrint('📥 File size: ${file.size} bytes');
// Read file content
String gpxContent;
if (file.path != null) {
// File has path (mobile)
gpxContent = await File(file.path!).readAsString();
} else if (file.bytes != null) {
// File has bytes (web)
gpxContent = String.fromCharCodes(file.bytes!);
} else {
throw Exception('Unable to read file content');
}
// Parse GPX content
debugPrint('📥 Parsing GPX content...');
final trail = importFromGpx(gpxContent);
debugPrint(
'✅ Successfully imported trail: ${trail.points.length} points',
);
return trail;
} catch (e) {
debugPrint('❌ Failed to import trail: $e');
rethrow;
}
}
// Helper: Format distance for display
static String _formatDistance(double meters) {
if (meters < 1000) {
return '${meters.toStringAsFixed(0)} m';
} else {
return '${(meters / 1000).toStringAsFixed(2)} km';
}
}
// Helper: Format duration for display
static String _formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m ${seconds}s';
} else if (minutes > 0) {
return '${minutes}m ${seconds}s';
} else {
return '${seconds}s';
}
}
// Helper: Format DateTime for filename
static String _formatDateTime(DateTime dt) {
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
}
}

View File

@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Service for managing locale preferences
class LocalePreferences {
static const String _localeKey = 'app_locale';
/// Supported locales
static const List<Locale> supportedLocales = [
Locale('en'), // English
Locale('sl'), // Slovenian
Locale('hr'), // Croatian
Locale('de'), // German
Locale('es'), // Spanish
Locale('fr'), // French
Locale('it'), // Italian
];
/// Get the saved locale or return null to use system locale
static Future<Locale?> getLocale() async {
final prefs = await SharedPreferences.getInstance();
final localeCode = prefs.getString(_localeKey);
if (localeCode == null) {
return null; // Use system locale
}
return Locale(localeCode);
}
/// Save the selected locale
static Future<void> setLocale(Locale? locale) async {
final prefs = await SharedPreferences.getInstance();
if (locale == null) {
// Remove preference to use system locale
await prefs.remove(_localeKey);
} else {
await prefs.setString(_localeKey, locale.languageCode);
}
}
/// Get display name for a locale
static String getDisplayName(Locale? locale) {
if (locale == null) {
return 'System Default';
}
switch (locale.languageCode) {
case 'en':
return 'English';
case 'sl':
return 'Slovenščina';
case 'hr':
return 'Hrvatski';
case 'de':
return 'Deutsch';
case 'es':
return 'Español';
case 'fr':
return 'Français';
case 'it':
return 'Italiano';
default:
return locale.languageCode;
}
}
/// Get native display name for a locale (shown in selection dialog)
static String getNativeDisplayName(Locale locale) {
switch (locale.languageCode) {
case 'en':
return 'English';
case 'sl':
return 'Slovenščina';
case 'hr':
return 'Hrvatski';
case 'de':
return 'Deutsch';
case 'es':
return 'Español';
case 'fr':
return 'Français';
case 'it':
return 'Italiano';
default:
return locale.languageCode;
}
}
}

View File

@@ -0,0 +1,517 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'meshcore_ble_service.dart';
/// Centralized location tracking service for MeshCore SAR
///
/// Handles GPS tracking, distance thresholds, background updates,
/// and location broadcasting to the mesh network.
///
/// Features:
/// - Singleton pattern for app-wide access
/// - Configurable distance thresholds (min/max)
/// - Configurable time intervals
/// - Permission handling
/// - SharedPreferences persistence
/// - MeshCore mesh network integration
/// - Real-time position updates via callbacks
class LocationTrackingService {
// ============================================================================
// Singleton Pattern
// ============================================================================
static final LocationTrackingService _instance =
LocationTrackingService._internal();
/// Get the singleton instance
factory LocationTrackingService() => _instance;
LocationTrackingService._internal();
// ============================================================================
// SharedPreferences Keys
// ============================================================================
static const String _prefKeyEnabled = 'background_tracking_enabled';
static const String _prefKeyMinDistance = 'map_gps_min_distance';
static const String _prefKeyMaxDistance = 'map_gps_max_distance';
static const String _prefKeyMinTimeInterval = 'map_gps_min_time_interval';
static const String _prefKeyGpsUpdateDistance = 'map_gps_update_distance';
static const String _prefKeyLastLat = 'background_last_lat';
static const String _prefKeyLastLon = 'background_last_lon';
// ============================================================================
// Configuration Properties
// ============================================================================
/// Minimum distance in meters before broadcasting update
double minDistanceMeters = 5.0;
/// Maximum distance in meters that forces a broadcast regardless of time
double maxDistanceMeters = 100.0;
/// Minimum time interval in seconds between broadcasts
int minTimeIntervalSeconds = 30;
/// GPS update distance filter for position stream
double gpsUpdateDistance = 10.0;
// ============================================================================
// State Properties
// ============================================================================
/// Current GPS position
Position? currentPosition;
/// Whether tracking is currently active
bool isTracking = false;
/// Whether service has been initialized with BLE service
bool _isInitialized = false;
/// Whether the first stable position has been set (without broadcast)
bool _firstPositionSet = false;
// ============================================================================
// Private Properties
// ============================================================================
/// Reference to MeshCore BLE service for broadcasting
MeshCoreBleService? _bleService;
/// Position stream subscription
StreamSubscription<Position>? _positionSubscription;
// ============================================================================
// Callback Properties
// ============================================================================
/// Called when position is updated
void Function(Position)? onPositionUpdate;
/// Called when an error occurs
void Function(String error)? onError;
/// Called when a location broadcast is sent to mesh network
void Function(Position)? onBroadcastSent;
/// Called when tracking state changes
void Function(bool isTracking)? onTrackingStateChanged;
// ============================================================================
// Initialization
// ============================================================================
/// Initialize the service with MeshCore BLE service reference
///
/// Must be called before starting tracking.
Future<bool> initialize(MeshCoreBleService bleService) async {
_bleService = bleService;
_isInitialized = true;
// Load saved settings
await loadSettings();
debugPrint('✅ [LocationTracking] Service initialized');
return true;
}
// ============================================================================
// Permission Handling
// ============================================================================
/// Check if location permissions are granted
Future<bool> checkPermissions() async {
final permission = await Geolocator.checkPermission();
return permission == LocationPermission.always ||
permission == LocationPermission.whileInUse;
}
/// Request location permissions from user
///
/// Returns true if granted, false otherwise.
Future<bool> requestPermissions() async {
// Check if location service is enabled
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
onError?.call('Location services are disabled');
return false;
}
// Check current permission
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
onError?.call('Location permission denied');
return false;
}
}
if (permission == LocationPermission.deniedForever) {
onError?.call(
'Location permission permanently denied. Please enable in settings.',
);
return false;
}
debugPrint('✅ [LocationTracking] Location permissions granted');
return true;
}
// ============================================================================
// GPS Position Methods
// ============================================================================
/// Get current GPS position
///
/// Returns null if position unavailable or permissions denied.
/// [timeLimit] - Maximum time to wait for position (default: 15 seconds)
/// [retryCount] - Number of retry attempts (default: 2)
Future<Position?> getCurrentPosition({
Duration timeLimit = const Duration(seconds: 15),
int retryCount = 2,
}) async {
for (int attempt = 0; attempt <= retryCount; attempt++) {
try {
if (attempt > 0) {
debugPrint('🔄 [LocationTracking] Retry attempt $attempt/$retryCount');
// Exponential backoff: wait 2^attempt seconds before retry
await Future.delayed(Duration(seconds: 1 << attempt));
}
final position = await Geolocator.getCurrentPosition(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.best,
timeLimit: timeLimit,
),
);
currentPosition = position;
if (attempt > 0) {
debugPrint('✅ [LocationTracking] Position acquired after $attempt retries');
}
return position;
} catch (e) {
final isLastAttempt = attempt == retryCount;
if (isLastAttempt) {
debugPrint('❌ [LocationTracking] Failed to get position after $retryCount retries: $e');
// Only call error callback on final failure, and make it user-friendly
if (e.toString().contains('TimeoutException')) {
onError?.call('GPS signal weak. Position stream will continue trying...');
} else {
onError?.call('Failed to get GPS position. Check device settings.');
}
} else {
debugPrint('⚠️ [LocationTracking] Position attempt $attempt failed: $e');
}
if (isLastAttempt) {
return null;
}
}
}
return null;
}
/// Get position stream with configurable distance filter
///
/// [distanceFilter] - Minimum distance in meters between position updates
Stream<Position> getPositionStream({double distanceFilter = 10.0}) {
return Geolocator.getPositionStream(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: distanceFilter.toInt(),
),
);
}
// ============================================================================
// Tracking Control
// ============================================================================
/// Start location tracking
///
/// [distanceThreshold] - GPS update distance filter
///
/// Returns true if successful, false otherwise.
/// Note: This method returns immediately after starting the position stream.
/// Initial position acquisition happens asynchronously in the background.
///
/// GPS tracking works WITHOUT BLE connection - device broadcasts are simply skipped.
Future<bool> startTracking({double? distanceThreshold}) async {
if (!_isInitialized) {
debugPrint(
'⚠️ [LocationTracking] Service not initialized',
);
onError?.call('Location tracking service not initialized');
return false;
}
// Allow tracking without BLE connection - broadcasts will be skipped
if (_bleService == null || !_bleService!.isConnected) {
debugPrint(' [LocationTracking] Starting GPS tracking without BLE connection (broadcasts disabled)');
}
// Check permissions
final hasPermission = await requestPermissions();
if (!hasPermission) {
return false;
}
// Use provided threshold or current setting
final threshold = distanceThreshold ?? gpsUpdateDistance;
gpsUpdateDistance = threshold;
// Save settings
await saveSettings();
// Try to get initial position in background (non-blocking)
// This will populate currentPosition but won't block tracking startup
getCurrentPosition(
timeLimit: const Duration(seconds: 10),
retryCount: 1,
).then((position) {
if (position != null) {
debugPrint('✅ [LocationTracking] Initial position acquired in background');
}
}).catchError((error) {
debugPrint('⚠️ [LocationTracking] Background initial position failed: $error');
// Not critical - position stream will eventually provide position
});
// Start position stream immediately (don't wait for initial position)
try {
_positionSubscription = getPositionStream(distanceFilter: threshold)
.listen(
_handlePositionUpdate,
onError: (error) {
debugPrint('❌ [LocationTracking] Position stream error: $error');
onError?.call('GPS stream error. Retrying...');
},
);
isTracking = true;
onTrackingStateChanged?.call(true);
debugPrint(
'✅ [LocationTracking] Tracking started with ${threshold}m threshold',
);
debugPrint('📡 [LocationTracking] Waiting for GPS signal...');
return true;
} catch (e) {
debugPrint('❌ [LocationTracking] Failed to start tracking: $e');
onError?.call('Failed to start GPS tracking: $e');
return false;
}
}
/// Stop location tracking
Future<void> stopTracking() async {
debugPrint('🛑 [LocationTracking] Stopping tracking');
await _positionSubscription?.cancel();
_positionSubscription = null;
isTracking = false;
onTrackingStateChanged?.call(false);
// Reset first position flag so next connection starts fresh
_firstPositionSet = false;
// Save disabled state
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyEnabled, false);
debugPrint('✅ [LocationTracking] Tracking stopped');
}
/// Update the distance threshold and restart tracking if active
Future<void> updateDistanceThreshold(double meters) async {
gpsUpdateDistance = meters;
await saveSettings();
debugPrint(
'📏 [LocationTracking] Distance threshold updated to ${meters}m',
);
// Restart tracking if currently active
if (isTracking) {
await stopTracking();
await startTracking(distanceThreshold: meters);
}
}
// ============================================================================
// Position Update Handler
// ============================================================================
/// Handle incoming position updates from GPS stream
void _handlePositionUpdate(Position position) {
debugPrint(
'📍 [LocationTracking] New position: ${position.latitude}, ${position.longitude}',
);
// Update current position
currentPosition = position;
// Notify listeners
onPositionUpdate?.call(position);
// SPECIAL CASE: First stable position after connection
// Set lat/lon on device WITHOUT broadcasting to mesh network
if (!_firstPositionSet) {
_setInitialPosition(position);
return;
}
// Check if we should broadcast to mesh network
_checkAndBroadcast(position);
}
/// Set initial position on device without broadcasting
///
/// Called only for the first stable GPS position after connection starts.
/// Updates the device's advertised lat/lon but does NOT send an advertisement.
void _setInitialPosition(Position position) async {
if (_bleService == null || !_bleService!.isConnected) {
debugPrint('⚠️ [LocationTracking] Cannot set initial position: BLE not connected');
return;
}
try {
debugPrint('📍 [LocationTracking] Setting initial position (no broadcast)');
// Update device's advertised location WITHOUT sending advertisement
await _bleService!.setAdvertLatLon(
latitude: position.latitude,
longitude: position.longitude,
);
// Mark first position as set
_firstPositionSet = true;
// Save to preferences
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_prefKeyLastLat, position.latitude);
await prefs.setDouble(_prefKeyLastLon, position.longitude);
debugPrint('✅ [LocationTracking] Initial position set without broadcast');
debugPrint(' Next broadcast allowed in ${minTimeIntervalSeconds}s');
} catch (e) {
debugPrint('⚠️ [LocationTracking] Failed to set initial position: $e');
debugPrint(' Will retry on next GPS update');
// Don't mark as set on failure, so it will retry on next update
// Don't call onError - this is not critical since it will retry automatically
}
}
/// Check if position should be broadcast based on distance and time thresholds
/// DISABLED: Automatic broadcasting removed - use advert button for manual broadcasts
void _checkAndBroadcast(Position position) {
// Automatic broadcasting disabled
// Use the manual advert button instead
debugPrint(' ⏸️ [LocationTracking] Automatic broadcasting disabled (use advert button)');
}
// ============================================================================
// Mesh Network Broadcasting
// ============================================================================
/// Manually broadcast current location immediately
///
/// Useful for "Send Location Now" button functionality.
/// Note: Manual broadcasts bypass automatic throttling and can be sent anytime.
/// However, they still update the last broadcast time to maintain proper spacing
/// for subsequent automatic broadcasts.
Future<bool> broadcastLocationNow() async {
if (!_isInitialized || _bleService == null) {
onError?.call('Location tracking service not initialized');
return false;
}
if (!_bleService!.isConnected) {
onError?.call('Not connected to mesh device');
return false;
}
try {
// Get current position
final position = await getCurrentPosition();
if (position == null) {
onError?.call('Failed to get current position');
return false;
}
debugPrint('📤 [LocationTracking] Manual broadcast requested');
// Broadcast regardless of automatic throttling thresholds
await _bleService!.setAdvertLatLon(
latitude: position.latitude,
longitude: position.longitude,
);
await _bleService!.sendSelfAdvert(floodMode: true);
debugPrint('✅ [LocationTracking] Manual broadcast successful');
debugPrint(' Automatic broadcasts will resume after ${minTimeIntervalSeconds}s');
onBroadcastSent?.call(position);
return true;
} catch (e) {
debugPrint('❌ [LocationTracking] Manual broadcast failed: $e');
onError?.call('Failed to broadcast location: $e');
return false;
}
}
// ============================================================================
// Settings Persistence
// ============================================================================
/// Load settings from SharedPreferences
Future<void> loadSettings() async {
final prefs = await SharedPreferences.getInstance();
minDistanceMeters = prefs.getDouble(_prefKeyMinDistance) ?? 5.0;
maxDistanceMeters = prefs.getDouble(_prefKeyMaxDistance) ?? 100.0;
minTimeIntervalSeconds = prefs.getInt(_prefKeyMinTimeInterval) ?? 30;
gpsUpdateDistance = prefs.getDouble(_prefKeyGpsUpdateDistance) ?? 10.0;
debugPrint('✅ [LocationTracking] Settings loaded');
debugPrint(' Min distance: ${minDistanceMeters}m');
debugPrint(' Max distance: ${maxDistanceMeters}m');
debugPrint(' Min time interval: ${minTimeIntervalSeconds}s');
debugPrint(' GPS update distance: ${gpsUpdateDistance}m');
}
/// Save settings to SharedPreferences
Future<void> saveSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_prefKeyMinDistance, minDistanceMeters);
await prefs.setDouble(_prefKeyMaxDistance, maxDistanceMeters);
await prefs.setInt(_prefKeyMinTimeInterval, minTimeIntervalSeconds);
await prefs.setDouble(_prefKeyGpsUpdateDistance, gpsUpdateDistance);
await prefs.setBool(_prefKeyEnabled, isTracking);
debugPrint('✅ [LocationTracking] Settings saved');
}
// ============================================================================
// Cleanup
// ============================================================================
/// Dispose resources and cleanup
void dispose() {
debugPrint('🗑️ [LocationTracking] Disposing service');
_positionSubscription?.cancel();
_positionSubscription = null;
_bleService = null;
_isInitialized = false;
isTracking = false;
}
}

View File

@@ -0,0 +1,508 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:geolocator/geolocator.dart';
import '../models/contact.dart';
import '../models/sar_marker.dart';
import '../widgets/map/location_pointer.dart';
/// Centralized service for map marker management.
///
/// This service handles:
/// - Contact marker generation
/// - SAR marker generation
/// - User location marker
/// - Distance calculations (Haversine formula)
/// - Bearing/azimuth calculations
/// - Marker color assignment
/// - Marker icon selection
///
/// Uses singleton pattern for consistent behavior across the app.
class MapMarkerService {
// Singleton pattern
static final MapMarkerService _instance = MapMarkerService._internal();
factory MapMarkerService() => _instance;
MapMarkerService._internal();
/// Generate markers for team member contacts.
///
/// Parameters:
/// - [contacts]: List of contacts with location data
/// - [context]: Build context for theme access
/// - [onTap]: Callback when a marker is tapped
/// - [mapRotation]: Current map rotation in degrees (for counter-rotation)
///
/// Returns a list of markers positioned at contact locations.
List<Marker> generateContactMarkers({
required List<Contact> contacts,
required BuildContext context,
Function(Contact)? onTap,
double mapRotation = 0,
Position? userPosition,
}) {
return contacts.map((contact) {
final location = contact.displayLocation;
if (location == null) return null;
return Marker(
point: location,
width: 80,
height: 100,
rotate: false, // Don't rotate the entire marker with map
child: Transform.rotate(
angle: -mapRotation * pi / 180,
child: GestureDetector(
onTap: onTap != null ? () => onTap(contact) : null,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Location update time indicator
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: getLocationAgeColor(contact),
borderRadius: BorderRadius.circular(3),
),
child: Text(
contact.timeSinceLocationUpdate,
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 2),
// Marker icon
Container(
decoration: BoxDecoration(
color: getContactMarkerColor(contact, context),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.all(6),
child: contact.roleEmoji != null
? Text(
contact.roleEmoji!,
style: const TextStyle(fontSize: 18),
)
: Icon(
getContactMarkerIcon(contact),
color: Colors.white,
size: 18,
),
),
const SizedBox(height: 2),
// Name label (without emoji)
Container(
constraints: const BoxConstraints(maxWidth: 80),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(3),
),
child: Text(
contact.displayName,
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
textAlign: TextAlign.center,
),
),
],
),
),
),
);
}).whereType<Marker>().toList();
}
/// Generate markers for SAR events.
///
/// Parameters:
/// - [sarMarkers]: List of SAR markers to display
/// - [context]: Build context for theme access
/// - [onTap]: Callback when a marker is tapped
/// - [mapRotation]: Current map rotation in degrees (for counter-rotation)
///
/// Returns a list of markers positioned at SAR event locations.
List<Marker> generateSarMarkers({
required List<SarMarker> sarMarkers,
required BuildContext context,
Function(SarMarker)? onTap,
double mapRotation = 0,
}) {
return sarMarkers.map((marker) {
return Marker(
point: marker.location,
width: 90,
height: 100,
rotate: false, // Don't rotate the entire marker with map
child: Transform.rotate(
angle: -mapRotation * pi / 180,
child: GestureDetector(
onTap: onTap != null ? () => onTap(marker) : null,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Time ago label
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: getSarMarkerColor(marker.type),
borderRadius: BorderRadius.circular(3),
),
child: Text(
marker.timeAgo,
style: const TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 2),
// Marker emoji/icon
Container(
decoration: BoxDecoration(
color: getSarMarkerColor(marker.type),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.all(6),
child: Text(
marker.emoji, // Use custom emoji if available
style: const TextStyle(fontSize: 18),
),
),
const SizedBox(height: 2),
// Type label
Container(
constraints: const BoxConstraints(maxWidth: 90),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(3),
),
child: Text(
marker.displayName, // Uses notes if available, otherwise type.displayName
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
textAlign: TextAlign.center,
),
),
],
),
),
),
);
}).toList();
}
/// Generate user location marker with directional pointer.
///
/// Parameters:
/// - [position]: Current GPS position
/// - [heading]: Current heading in degrees (0-360, where 0 = North)
/// Pass null or -1 if heading unavailable
/// - [context]: Build context for theme access
///
/// Returns null if position is unavailable.
Marker? generateUserLocationMarker({
required Position? position,
double? heading,
required BuildContext context,
}) {
if (position == null) return null;
return Marker(
point: LatLng(position.latitude, position.longitude),
width: 60,
height: 60,
rotate: false, // Don't rotate with map - we handle rotation internally
child: LocationPointer(
heading: heading,
color: Theme.of(context).colorScheme.primary,
size: 60,
),
);
}
/// Calculate distance between two lat/lon points using Haversine formula.
///
/// Parameters:
/// - [lat1]: Starting latitude in decimal degrees
/// - [lon1]: Starting longitude in decimal degrees
/// - [lat2]: Ending latitude in decimal degrees
/// - [lon2]: Ending longitude in decimal degrees
///
/// Returns distance in meters.
double calculateDistance({
required double lat1,
required double lon1,
required double lat2,
required double lon2,
}) {
const R = 6371000; // Earth's radius in meters
final dLat = (lat2 - lat1) * pi / 180;
final dLon = (lon2 - lon1) * pi / 180;
final a = sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * pi / 180) *
cos(lat2 * pi / 180) *
sin(dLon / 2) *
sin(dLon / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
return R * c;
}
/// Calculate bearing/azimuth from point 1 to point 2.
///
/// Parameters:
/// - [lat1]: Starting latitude in decimal degrees
/// - [lon1]: Starting longitude in decimal degrees
/// - [lat2]: Ending latitude in decimal degrees
/// - [lon2]: Ending longitude in decimal degrees
///
/// Returns bearing in degrees (0-360), where 0 is North, 90 is East.
double calculateBearing({
required double lat1,
required double lon1,
required double lat2,
required double lon2,
}) {
final dLon = (lon2 - lon1) * pi / 180;
final lat1Rad = lat1 * pi / 180;
final lat2Rad = lat2 * pi / 180;
final y = sin(dLon) * cos(lat2Rad);
final x = cos(lat1Rad) * sin(lat2Rad) -
sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
final bearing = atan2(y, x) * 180 / pi;
return (bearing + 360) % 360;
}
/// Convert bearing to cardinal direction.
///
/// Parameters:
/// - [bearing]: Bearing in degrees (0-360)
///
/// Returns cardinal direction (N, NE, E, SE, S, SW, W, NW).
String bearingToCardinal(double bearing) {
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
final index = ((bearing + 22.5) / 45).floor() % 8;
return directions[index];
}
/// Format distance for display.
///
/// Parameters:
/// - [meters]: Distance in meters
///
/// Returns formatted string (e.g., "123m" or "1.2km").
String formatDistance(double meters) {
if (meters < 1000) {
return '${meters.round()}m';
} else {
return '${(meters / 1000).toStringAsFixed(1)}km';
}
}
/// Get color for SAR marker type.
///
/// Parameters:
/// - [type]: SAR marker type
///
/// Returns color for marker background.
Color getSarMarkerColor(SarMarkerType type) {
switch (type) {
case SarMarkerType.foundPerson:
return Colors.green;
case SarMarkerType.fire:
return Colors.red;
case SarMarkerType.stagingArea:
return Colors.orange;
case SarMarkerType.object:
return Colors.purple;
case SarMarkerType.unknown:
return Colors.grey;
}
}
/// Get color for contact marker based on contact type.
///
/// Parameters:
/// - [contact]: Contact to get color for
/// - [context]: Build context for theme access
///
/// Returns color for marker background.
Color getContactMarkerColor(Contact contact, BuildContext context) {
switch (contact.type) {
case ContactType.chat:
return Theme.of(context).colorScheme.primary; // Blue for team members
case ContactType.repeater:
return Colors.deepPurple; // Purple for repeaters
case ContactType.room:
return Colors.teal; // Teal for rooms
case ContactType.channel:
return Colors.orange; // Orange for channels
case ContactType.none:
return Colors.grey;
}
}
/// Get icon for contact marker based on contact type.
///
/// Parameters:
/// - [contact]: Contact to get icon for
///
/// Returns icon data for marker.
IconData getContactMarkerIcon(Contact contact) {
switch (contact.type) {
case ContactType.chat:
return Icons.person; // Person for team members
case ContactType.repeater:
return Icons.router; // Router icon for repeaters
case ContactType.room:
return Icons.forum; // Forum/chat icon for rooms
case ContactType.channel:
return Icons.public; // Public icon for channels
case ContactType.none:
return Icons.help_outline;
}
}
/// Get color for location age indicator.
///
/// Color indicates how recent the location update is:
/// - Green: < 5 minutes (very recent)
/// - Light blue: 5-30 minutes (recent)
/// - Orange: 30 minutes - 2 hours (getting old)
/// - Red: > 2 hours (stale)
/// - Grey: Unknown
///
/// Parameters:
/// - [contact]: Contact to check location age for
///
/// Returns color for location age indicator.
Color getLocationAgeColor(Contact contact) {
final updateTime = contact.locationUpdateTime;
if (updateTime == null) return Colors.grey;
final diff = DateTime.now().difference(updateTime);
if (diff.inMinutes < 5) return Colors.green; // Very recent
if (diff.inMinutes < 30) return Colors.lightBlue; // Recent
if (diff.inHours < 2) return Colors.orange; // Getting old
return Colors.red; // Stale
}
/// Cluster markers if too many are visible.
///
/// This is a placeholder for future clustering implementation.
/// When implemented, it should group nearby markers into clusters
/// to improve performance and reduce visual clutter.
///
/// Parameters:
/// - [markers]: All markers to potentially cluster
/// - [maxVisibleMarkers]: Maximum number of individual markers to show
///
/// Returns list of markers (clustered or original).
List<Marker> clusterMarkers({
required List<Marker> markers,
required int maxVisibleMarkers,
}) {
// TODO: Implement marker clustering algorithm
// For now, just return all markers
return markers;
}
/// Calculate optimal map center from list of points.
///
/// Parameters:
/// - [contacts]: Contacts with locations
/// - [sarMarkers]: SAR markers with locations
/// - [defaultCenter]: Fallback center if no points available
///
/// Returns center point (average of all locations).
LatLng calculateCenter({
required List<Contact> contacts,
required List<SarMarker> sarMarkers,
LatLng? defaultCenter,
}) {
final allPoints = <LatLng>[];
for (final contact in contacts) {
if (contact.displayLocation != null) {
allPoints.add(contact.displayLocation!);
}
}
for (final marker in sarMarkers) {
allPoints.add(marker.location);
}
if (allPoints.isEmpty) {
return defaultCenter ?? const LatLng(46.0569, 14.5058); // Ljubljana, Slovenia
}
double lat = 0, lng = 0;
for (final point in allPoints) {
lat += point.latitude;
lng += point.longitude;
}
return LatLng(lat / allPoints.length, lng / allPoints.length);
}
/// Check if two positions are close enough to be considered the same location.
///
/// Parameters:
/// - [lat1]: First latitude
/// - [lon1]: First longitude
/// - [lat2]: Second latitude
/// - [lon2]: Second longitude
/// - [thresholdMeters]: Distance threshold in meters (default: 50)
///
/// Returns true if points are within threshold distance.
bool isNearby({
required double lat1,
required double lon1,
required double lat2,
required double lon2,
double thresholdMeters = 50,
}) {
final distance = calculateDistance(
lat1: lat1,
lon1: lon1,
lat2: lat2,
lon2: lon2,
);
return distance <= thresholdMeters;
}
}

View File

@@ -0,0 +1,273 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:mbtiles/mbtiles.dart';
/// Metadata information extracted from an MBTiles file
class MbtilesMetadata {
final String name;
final String? description;
final String? version;
final String? attribution;
final String? bounds; // "minLon,minLat,maxLon,maxLat"
final String? center; // "lon,lat,zoom"
final int? minZoom;
final int? maxZoom;
final String? format; // "pbf", "png", "jpg", etc.
final String? type; // "overlay", "baselayer"
final String? json; // Additional metadata JSON
final File file;
final int fileSize;
const MbtilesMetadata({
required this.name,
this.description,
this.version,
this.attribution,
this.bounds,
this.center,
this.minZoom,
this.maxZoom,
this.format,
this.type,
this.json,
required this.file,
required this.fileSize,
});
/// Check if this is a vector tile MBTiles file
bool get isVector => format == 'pbf' || format == 'mvt';
/// Parse bounds string into [minLon, minLat, maxLon, maxLat]
List<double>? get boundsCoordinates {
if (bounds == null) return null;
try {
final parts = bounds!.split(',');
if (parts.length != 4) return null;
return parts.map((s) => double.parse(s.trim())).toList();
} catch (e) {
debugPrint('Error parsing bounds: $e');
return null;
}
}
/// Parse center string into [lon, lat, zoom]
List<double>? get centerCoordinates {
if (center == null) return null;
try {
final parts = center!.split(',');
if (parts.length < 2) return null;
return parts.map((s) => double.parse(s.trim())).toList();
} catch (e) {
debugPrint('Error parsing center: $e');
return null;
}
}
/// Get file size in human-readable format
String get fileSizeFormatted {
if (fileSize < 1024) {
return '$fileSize B';
} else if (fileSize < 1024 * 1024) {
return '${(fileSize / 1024).toStringAsFixed(1)} KB';
} else if (fileSize < 1024 * 1024 * 1024) {
return '${(fileSize / (1024 * 1024)).toStringAsFixed(1)} MB';
} else {
return '${(fileSize / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
}
}
/// Service for managing MBTiles files for offline vector maps
class MbtilesService {
static const String _mbtilesDirectory = 'offline_maps';
/// Get the directory where MBTiles files are stored
Future<Directory> getMbtilesDirectory() async {
final appDocDir = await getApplicationDocumentsDirectory();
final mbtilesDir = Directory('${appDocDir.path}/$_mbtilesDirectory');
// Create directory if it doesn't exist
if (!await mbtilesDir.exists()) {
await mbtilesDir.create(recursive: true);
}
return mbtilesDir;
}
/// List all MBTiles files in the offline maps directory
Future<List<File>> listMbtilesFiles() async {
final dir = await getMbtilesDirectory();
try {
final files = await dir
.list()
.where((entity) => entity is File && entity.path.endsWith('.mbtiles'))
.map((entity) => entity as File)
.toList();
return files;
} catch (e) {
debugPrint('Error listing MBTiles files: $e');
return [];
}
}
/// Get metadata from an MBTiles file
Future<MbtilesMetadata?> getMetadata(File file) async {
try {
// Check if file exists
if (!await file.exists()) {
debugPrint('MBTiles file does not exist: ${file.path}');
return null;
}
// Get file size
final fileSize = await file.length();
// Open MBTiles file
final mbtiles = MbTiles(mbtilesPath: file.path);
// Get metadata from MBTiles
final metadata = mbtiles.getMetadata();
// Convert bounds object to string if available
String? boundsStr;
if (metadata.bounds != null) {
boundsStr = metadata.bounds.toString();
}
return MbtilesMetadata(
name: metadata.name,
description: metadata.description,
version: metadata.version?.toString(),
attribution: null, // Not available in new API
bounds: boundsStr,
center: null, // Not available in new API
minZoom: metadata.minZoom?.toInt(),
maxZoom: metadata.maxZoom?.toInt(),
format: metadata.format,
type: metadata.type?.name,
json: null, // Not available in new API
file: file,
fileSize: fileSize,
);
} catch (e) {
debugPrint('Error reading MBTiles metadata from ${file.path}: $e');
return null;
}
}
/// Get metadata for all MBTiles files
Future<List<MbtilesMetadata>> getAllMetadata() async {
final files = await listMbtilesFiles();
final metadataList = <MbtilesMetadata>[];
for (final file in files) {
final metadata = await getMetadata(file);
if (metadata != null) {
metadataList.add(metadata);
}
}
return metadataList;
}
/// Import an MBTiles file from an external location
Future<File?> importMbtilesFile(String sourcePath) async {
try {
final sourceFile = File(sourcePath);
// Verify source file exists
if (!await sourceFile.exists()) {
debugPrint('Source file does not exist: $sourcePath');
return null;
}
// Get destination directory
final destDir = await getMbtilesDirectory();
final fileName = _getFileName(sourceFile);
final destPath = '${destDir.path}/$fileName';
// Copy file to destination
final destFile = await sourceFile.copy(destPath);
debugPrint('Imported MBTiles file to: $destPath');
return destFile;
} catch (e) {
debugPrint('Error importing MBTiles file: $e');
return null;
}
}
/// Delete an MBTiles file
Future<bool> deleteMbtilesFile(File file) async {
try {
if (await file.exists()) {
await file.delete();
debugPrint('Deleted MBTiles file: ${file.path}');
return true;
}
return false;
} catch (e) {
debugPrint('Error deleting MBTiles file: $e');
return false;
}
}
/// Check if data in MBTiles is gzip compressed
Future<bool> isGzipCompressed(File file) async {
try {
// Open MBTiles and check a sample tile
final mbtiles = MbTiles(mbtilesPath: file.path);
// Try to get metadata to check for compression hints
final metadata = mbtiles.getMetadata();
final format = metadata.format;
// For Geofabrik files, format is 'pbf' and data is gzipped
// We can infer this from common patterns, but ideally we'd check actual tile data
if (format == 'pbf') {
// Geofabrik MBTiles are typically gzipped
// Could also check tile data headers, but this is a reasonable heuristic
return true;
}
return false;
} catch (e) {
debugPrint('Error checking gzip compression: $e');
return false;
}
}
/// Determine the vector tile schema from metadata
String? getVectorSchema(MbtilesMetadata metadata) {
// Try to infer schema from metadata
final json = metadata.json;
if (json != null) {
if (json.contains('shortbread')) {
return 'shortbread';
} else if (json.contains('openmaptiles')) {
return 'openmaptiles';
}
}
// Check description
final description = metadata.description?.toLowerCase();
if (description != null) {
if (description.contains('shortbread')) {
return 'shortbread';
} else if (description.contains('openmaptiles')) {
return 'openmaptiles';
}
}
// Default to unknown
return null;
}
/// Helper: Get file name from path
String _getFileName(File file) {
return file.path.split(Platform.pathSeparator).last;
}
}

View File

@@ -0,0 +1,740 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../models/contact.dart';
import '../models/message.dart';
import '../models/ble_packet_log.dart';
import 'ble/ble_connection_manager.dart';
import 'ble/ble_command_sender.dart';
import 'ble/ble_response_handler.dart';
import 'protocol/frame_builder.dart';
import 'meshcore_constants.dart';
/// Callback types for MeshCore events
typedef OnContactCallback = void Function(Contact contact);
typedef OnContactsCompleteCallback = void Function(List<Contact> contacts);
typedef OnMessageCallback = void Function(Message message);
typedef OnTelemetryCallback =
void Function(Uint8List publicKey, Uint8List lppData);
typedef OnSelfInfoCallback = void Function(Map<String, dynamic> selfInfo);
typedef OnDeviceInfoCallback = void Function(Map<String, dynamic> deviceInfo);
typedef OnNoMoreMessagesCallback = void Function();
typedef OnMessageWaitingCallback = void Function();
typedef OnLoginSuccessCallback =
void Function(
Uint8List publicKeyPrefix,
int permissions,
bool isAdmin,
int tag,
);
typedef OnLoginFailCallback = void Function(Uint8List publicKeyPrefix);
typedef OnAdvertReceivedCallback = void Function(Uint8List publicKey);
typedef OnPathUpdatedCallback = void Function(Uint8List publicKey);
typedef OnMessageSentCallback = void Function(
int expectedAckTag,
int suggestedTimeoutMs,
bool isFloodMode,
Uint8List? contactPublicKey,
);
typedef OnMessageDeliveredCallback =
void Function(int ackCode, int roundTripTimeMs);
typedef OnMessageEchoDetectedCallback =
void Function(String messageId, int echoCount, int snrRaw, int rssiDbm);
typedef OnStatusResponseCallback =
void Function(Uint8List publicKeyPrefix, Uint8List statusData);
typedef OnBinaryResponseCallback =
void Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData);
typedef OnBatteryAndStorageCallback =
void Function(int millivolts, int? usedKb, int? totalKb);
typedef OnErrorCallback = void Function(String error, {int? errorCode});
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
typedef OnChannelInfoCallback =
void Function(int channelIdx, String channelName, Uint8List secret, int? flags);
typedef OnConnectionStateCallback = void Function(bool isConnected);
typedef OnReconnectionAttemptCallback =
void Function(int attemptNumber, int maxAttempts);
typedef OnRssiUpdateCallback = void Function(int rssi);
/// MeshCore BLE Service - coordinates BLE communication components
class MeshCoreBleService {
// Component instances
final BleConnectionManager _connectionManager = BleConnectionManager();
final BleCommandSender _commandSender = BleCommandSender();
final BleResponseHandler _responseHandler = BleResponseHandler();
// Keepalive timer for iOS background mode
Timer? _keepaliveTimer;
static const Duration _keepaliveInterval = Duration(seconds: 20);
// Event callbacks
OnConnectionStateCallback? onConnectionStateChanged;
OnReconnectionAttemptCallback? onReconnectionAttempt;
OnRssiUpdateCallback? onRssiUpdate;
OnContactCallback? onContactReceived;
OnContactsCompleteCallback? onContactsComplete;
OnMessageCallback? onMessageReceived;
OnTelemetryCallback? onTelemetryReceived;
OnSelfInfoCallback? onSelfInfoReceived;
OnDeviceInfoCallback? onDeviceInfoReceived;
OnNoMoreMessagesCallback? onNoMoreMessages;
OnMessageWaitingCallback? onMessageWaiting;
OnLoginSuccessCallback? onLoginSuccess;
OnLoginFailCallback? onLoginFail;
OnAdvertReceivedCallback? onAdvertReceived;
OnPathUpdatedCallback? onPathUpdated;
OnMessageSentCallback? onMessageSent;
OnMessageDeliveredCallback? onMessageDelivered;
OnMessageEchoDetectedCallback? onMessageEchoDetected;
OnStatusResponseCallback? onStatusResponse;
OnBinaryResponseCallback? onBinaryResponse;
OnBatteryAndStorageCallback? onBatteryAndStorage;
OnErrorCallback? onError;
OnContactNotFoundCallback? onContactNotFound;
OnChannelInfoCallback? onChannelInfoReceived;
// Activity callbacks (for blinking indicators)
VoidCallback? onRxActivity;
VoidCallback? onTxActivity;
// Constructor
MeshCoreBleService() {
_setupCallbacks();
}
// Setup callbacks between components
void _setupCallbacks() {
// Connection manager callbacks
_connectionManager.onConnectionStateChanged = (isConnected) {
if (isConnected) {
_startKeepalive();
} else {
_stopKeepalive();
}
onConnectionStateChanged?.call(isConnected);
};
_connectionManager.onError = (error) {
onError?.call(error);
};
_connectionManager.onReconnectionAttempt = (attemptNumber, maxAttempts) {
debugPrint(
'🔄 [Service] Reconnection attempt $attemptNumber/$maxAttempts',
);
onReconnectionAttempt?.call(attemptNumber, maxAttempts);
};
_connectionManager.onRssiUpdate = (rssi) {
onRssiUpdate?.call(rssi);
};
// Command sender callbacks
_commandSender.onError = (error) {
onError?.call(error);
};
_commandSender.onTxActivity = () {
onTxActivity?.call();
};
// Response handler callbacks
_responseHandler.onContactReceived = (contact) {
debugPrint('🔔 [BleService] onContactReceived - "${contact.advName}" - forwarding to ConnectionProvider');
onContactReceived?.call(contact);
};
_responseHandler.onContactsComplete = (contacts) {
debugPrint('🔔 [BleService] onContactsComplete - ${contacts.length} contacts - forwarding to ConnectionProvider');
onContactsComplete?.call(contacts);
};
_responseHandler.onMessageReceived = (message) {
debugPrint('🔔 [BleService] onMessageReceived - forwarding to ConnectionProvider');
onMessageReceived?.call(message);
};
_responseHandler.onTelemetryReceived = (publicKey, lppData) {
debugPrint('🔔 [BleService] onTelemetryReceived - ${lppData.length} bytes - forwarding to ConnectionProvider');
onTelemetryReceived?.call(publicKey, lppData);
};
_responseHandler.onSelfInfoReceived = (selfInfo) {
// Extract our node hash (first byte of public key) for echo detection
if (selfInfo['publicKey'] != null) {
final publicKey = selfInfo['publicKey'] as Uint8List;
if (publicKey.isNotEmpty) {
_responseHandler.setOurNodeHash(publicKey[0]);
}
}
onSelfInfoReceived?.call(selfInfo);
};
_responseHandler.onDeviceInfoReceived = (deviceInfo) {
onDeviceInfoReceived?.call(deviceInfo);
};
_responseHandler.onNoMoreMessages = () {
onNoMoreMessages?.call();
};
_responseHandler.onMessageWaiting = () {
onMessageWaiting?.call();
};
_responseHandler.onLoginSuccess =
(publicKeyPrefix, permissions, isAdmin, tag) {
onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag);
};
_responseHandler.onLoginFail = (publicKeyPrefix) {
onLoginFail?.call(publicKeyPrefix);
};
_responseHandler.onAdvertReceived = (publicKey) {
debugPrint('🔔 [BleService] onAdvertReceived - forwarding to ConnectionProvider');
onAdvertReceived?.call(publicKey);
};
_responseHandler.onPathUpdated = (publicKey) {
debugPrint('🔔 [BleService] onPathUpdated - forwarding to ConnectionProvider');
onPathUpdated?.call(publicKey);
};
_responseHandler.onMessageSent =
(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) {
onMessageSent?.call(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey);
};
_responseHandler.onMessageDelivered = (ackCode, roundTripTimeMs) {
onMessageDelivered?.call(ackCode, roundTripTimeMs);
};
_responseHandler.onMessageEchoDetected =
(messageId, echoCount, snrRaw, rssiDbm) {
onMessageEchoDetected?.call(messageId, echoCount, snrRaw, rssiDbm);
};
_responseHandler.onStatusResponse = (publicKeyPrefix, statusData) {
onStatusResponse?.call(publicKeyPrefix, statusData);
};
_responseHandler.onBinaryResponse = (publicKeyPrefix, tag, responseData) {
onBinaryResponse?.call(publicKeyPrefix, tag, responseData);
};
_responseHandler.onBatteryAndStorage = (millivolts, usedKb, totalKb) {
onBatteryAndStorage?.call(millivolts, usedKb, totalKb);
};
_responseHandler.onError = (error, {int? errorCode}) {
onError?.call(error, errorCode: errorCode);
};
_responseHandler.onContactNotFound = (contactPublicKey) {
onContactNotFound?.call(contactPublicKey);
};
_responseHandler.onChannelInfoReceived = (int channelIdx, String channelName, Uint8List secret, int? flags) {
onChannelInfoReceived?.call(channelIdx, channelName, secret, flags);
};
_responseHandler.onRxActivity = () {
onRxActivity?.call();
};
}
// Getters
bool get isConnected => _connectionManager.isConnected;
bool get isReconnecting => _connectionManager.isReconnecting;
int get reconnectionAttempt => _connectionManager.reconnectionAttempt;
int get maxReconnectionAttempts => _connectionManager.maxReconnectionAttempts;
int get rxPacketCount => _responseHandler.rxPacketCount;
int get txPacketCount => _commandSender.txPacketCount;
List<BlePacketLog> get packetLogs {
// Merge logs from both sender and handler
final allLogs = [
..._commandSender.packetLogs,
..._responseHandler.packetLogs,
];
allLogs.sort((a, b) => a.timestamp.compareTo(b.timestamp));
return allLogs;
}
/// Scan for MeshCore devices
Stream<ScanResult> scanForDevices({
Duration timeout = const Duration(seconds: 10),
}) {
return _connectionManager.scanForDevices(timeout: timeout);
}
/// Connect to a MeshCore device
Future<bool> connect(BluetoothDevice device) async {
final success = await _connectionManager.connect(device);
if (success) {
try {
// Setup command sender with RX characteristic
_commandSender.setRxCharacteristic(_connectionManager.rxCharacteristic);
// Wire up command queue between sender and response handler
_responseHandler.setCommandQueue(_commandSender.commandQueue);
// Setup response handler with TX characteristic
if (_connectionManager.txCharacteristic != null) {
_responseHandler.subscribeToNotifications(
_connectionManager.txCharacteristic!,
);
}
// Send initial device query and wait for responses
await _sendDeviceQuery();
debugPrint('✅ [Service] Device initialization complete');
return true;
} catch (e) {
debugPrint('❌ [Service] Device initialization failed: $e');
// Disconnect on initialization failure
await disconnect();
onError?.call('Device initialization failed: $e');
return false;
}
}
return success;
}
/// Disconnect from device
Future<void> disconnect() async {
await _connectionManager.disconnect();
}
/// Send initial device query and sync clock
Future<void> _sendDeviceQuery() async {
// STEP 1: Send device query FIRST to get device capabilities
// This is the first command to send per protocol documentation
debugPrint(
'🔍 [Service] Querying device information (CMD_DEVICE_QUERY)...',
);
final deviceInfo = await _commandSender
.writeDataAndWaitForResponse<Map<String, dynamic>>(
FrameBuilder.buildDeviceQuery(),
MeshCoreConstants.respDeviceInfo,
);
debugPrint(
'✅ [Service] Device info received: firmware=${deviceInfo['firmwareVersion']}',
);
// STEP 2: Send app start to initialize the app session
// This is the first command after connection per protocol documentation
debugPrint('🚀 [Service] Sending app start (CMD_APP_START)...');
await _commandSender.writeDataAndWaitForResponse<Map<String, dynamic>>(
FrameBuilder.buildAppStart(),
MeshCoreConstants.respSelfInfo,
);
debugPrint('✅ [Service] Self info received: node initialized');
// STEP 3: Set device clock AFTER initialization
// This ensures the device has correct timestamps for all subsequent operations
// Note: This command does not return an ACK, so we use writeData (fire-and-forget)
debugPrint('⏰ [Service] Setting device clock (CMD_SET_DEVICE_TIME)...');
await _commandSender.writeData(FrameBuilder.buildSetDeviceTime());
debugPrint('✅ [Service] Device clock sent (no ACK expected)');
// STEP 4: Sync any waiting messages immediately after connection
// This ensures we receive messages that arrived while disconnected
debugPrint('📬 [Service] Syncing messages (CMD_SYNC_NEXT_MESSAGE)...');
await syncNextMessage();
debugPrint('✅ [Service] Message sync initiated');
}
/// Refresh device info (public method)
Future<void> refreshDeviceInfo() async {
await _sendDeviceQuery();
}
/// Get contacts from device
Future<void> getContacts() async {
await _commandSender.writeData(FrameBuilder.buildGetContacts());
}
/// Get a single contact by public key from device
///
/// This is more efficient than getContacts() when you only need to refresh
/// one specific contact (e.g., after receiving an advertisement).
///
/// The contact will be delivered via the onContactReceived callback.
Future<void> getContactByKey(Uint8List publicKey) async {
debugPrint('🔍 [BLE] Requesting single contact by key:');
debugPrint(
' Public key prefix: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...',
);
await _commandSender.writeData(FrameBuilder.buildGetContactByKey(publicKey));
}
/// Manually add or update a contact on the companion radio
Future<void> addOrUpdateContact(Contact contact) async {
debugPrint('📝 [BLE] Adding/updating contact on companion radio:');
debugPrint(' Name: ${contact.advName}');
debugPrint(
' Public key prefix: ${contact.publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
debugPrint(' Type: ${contact.type} (${contact.type.value})');
await _commandSender.writeData(FrameBuilder.buildAddUpdateContact(contact));
debugPrint('✅ [BLE] CMD_ADD_UPDATE_CONTACT sent');
}
/// Send text message to contact (DM)
Future<void> sendTextMessage({
required Uint8List contactPublicKey,
required String text,
int textType = 0,
int attempt = 0,
}) async {
if (text.length > 160) {
throw ArgumentError('Text message exceeds 160 character limit');
}
// Track the last contact for auto-recovery if contact not found
_responseHandler.setLastContactPublicKey(contactPublicKey);
await _commandSender.writeData(
FrameBuilder.buildSendTxtMsg(
contactPublicKey: contactPublicKey,
text: text,
textType: textType,
attempt: attempt,
),
);
}
/// Send flood-mode text message to channel
/// Track a sent channel message for echo detection
void trackSentChannelMessage(String messageId) {
debugPrint(
'🔵 [MeshCoreBleService] trackSentChannelMessage called for: $messageId',
);
_responseHandler.trackSentMessage(messageId, null);
}
/// Send a text message to a channel (flood-mode broadcast)
///
/// Channel messages are ephemeral and use flood routing (no ACKs).
/// Use channel 0 for the default public channel.
///
/// Note: Uses fire-and-forget mode since channel messages don't return
/// delivery confirmation (they're broadcast to all nodes).
Future<void> sendChannelMessage({
required int channelIdx,
required String text,
int textType = 0,
}) async {
if (text.length > 160) {
throw ArgumentError('Channel message too long (max ~160 characters)');
}
// Channel messages use fire-and-forget (no ACK expected)
// The firmware responds with RESP_CODE_OK but we don't wait for it
await _commandSender.writeData(
FrameBuilder.buildSendChannelTxtMsg(
channelIdx: channelIdx,
text: text,
textType: textType,
),
);
}
/// Request telemetry (GPS, battery) from contact
Future<void> requestTelemetry(
Uint8List contactPublicKey, {
bool zeroHop = false,
}) async {
await _commandSender.writeData(
FrameBuilder.buildSendTelemetryReq(contactPublicKey, zeroHop: zeroHop),
);
}
/// Send binary request to contact
Future<void> sendBinaryRequest({
required Uint8List contactPublicKey,
required Uint8List requestData,
}) async {
await _commandSender.writeData(
FrameBuilder.buildSendBinaryReq(
contactPublicKey: contactPublicKey,
requestData: requestData,
),
);
}
/// Get battery voltage and storage information
Future<void> getBatteryAndStorage() async {
await _commandSender.writeData(FrameBuilder.buildGetBatteryAndStorage());
}
/// Legacy method name for backward compatibility
@Deprecated('Use getBatteryAndStorage() instead')
Future<void> getBatteryVoltage() async {
await getBatteryAndStorage();
}
/// Sync next message from device queue
Future<void> syncNextMessage() async {
await _commandSender.writeData(FrameBuilder.buildSyncNextMessage());
}
/// Get device time from companion radio
Future<void> getDeviceTime() async {
await _commandSender.writeData(FrameBuilder.buildGetDeviceTime());
}
/// Set device time
Future<void> setDeviceTime() async {
await _commandSender.writeData(FrameBuilder.buildSetDeviceTime());
}
/// Send self advertisement packet to mesh network
Future<void> sendSelfAdvert({bool floodMode = true}) async {
await _commandSender.writeData(
FrameBuilder.buildSendSelfAdvert(floodMode: floodMode),
);
}
/// Set advertised name
Future<void> setAdvertName(String name) async {
await _commandSender.writeDataAndWaitForAck(
FrameBuilder.buildSetAdvertName(name),
);
}
/// Set advertised latitude and longitude
Future<void> setAdvertLatLon({
required double latitude,
required double longitude,
}) async {
// This command updates device's advertised location
// Fire-and-forget - no ACK needed since actual broadcast happens via sendSelfAdvert
await _commandSender.writeData(
FrameBuilder.buildSetAdvertLatLon(
latitude: latitude,
longitude: longitude,
),
);
}
/// Set radio parameters
Future<void> setRadioParams({
required int frequency,
required int bandwidth,
required int spreadingFactor,
required int codingRate,
}) async {
await _commandSender.writeDataAndWaitForAck(
FrameBuilder.buildSetRadioParams(
frequency: frequency,
bandwidth: bandwidth,
spreadingFactor: spreadingFactor,
codingRate: codingRate,
),
);
}
/// Set transmit power
Future<void> setTxPower(int powerDbm) async {
await _commandSender.writeDataAndWaitForAck(
FrameBuilder.buildSetTxPower(powerDbm),
);
}
/// Set other parameters
Future<void> setOtherParams({
required int manualAddContacts,
required int telemetryModes,
required int advertLocationPolicy,
int multiAcks = 0,
}) async {
await _commandSender.writeDataAndWaitForAck(
FrameBuilder.buildSetOtherParams(
manualAddContacts: manualAddContacts,
telemetryModes: telemetryModes,
advertLocationPolicy: advertLocationPolicy,
multiAcks: multiAcks,
),
);
}
/// Send login request to room or repeater
Future<void> loginToRoom({
required Uint8List roomPublicKey,
required String password,
}) async {
if (password.length > 15) {
throw ArgumentError('Password exceeds 15 character limit');
}
debugPrint('🔐 [BLE] Preparing login request:');
debugPrint(
' Room public key prefix: ${roomPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
debugPrint(
' Password: ${"*" * password.length} (${password.length} chars)',
);
await _commandSender.writeData(
FrameBuilder.buildSendLogin(
roomPublicKey: roomPublicKey,
password: password,
),
);
}
/// Send status request to repeater or sensor node
Future<void> sendStatusRequest(Uint8List contactPublicKey) async {
debugPrint('📊 [BLE] Preparing status request:');
debugPrint(
' Target node public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
await _commandSender.writeData(
FrameBuilder.buildSendStatusReq(contactPublicKey),
);
}
/// Reset path for a contact - forces next message to flood and re-learn route
Future<void> resetPath(Uint8List contactPublicKey) async {
debugPrint('🔄 [BLE] Resetting path for contact:');
debugPrint(
' Contact public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
await _commandSender.writeData(
FrameBuilder.buildResetPath(contactPublicKey),
);
}
/// Remove a contact from the companion radio
Future<void> removeContact(Uint8List contactPublicKey) async {
debugPrint('🗑️ [BLE] Removing contact from companion radio:');
debugPrint(
' Public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
await _commandSender.writeData(
FrameBuilder.buildRemoveContact(contactPublicKey),
);
debugPrint('✅ [BLE] CMD_REMOVE_CONTACT sent');
}
/// Get information for a specific channel
Future<void> getChannel(int channelIdx) async {
await _commandSender.writeData(FrameBuilder.buildGetChannel(channelIdx));
}
/// Set the name and secret for a specific channel
///
/// The secret must be exactly 16 bytes (128-bit encryption key).
/// For the default public channel (channel 0), use [MeshCoreConstants.defaultPublicChannelSecret].
///
/// Note: Some firmware versions don't send ACK for SET_CHANNEL, so we use
/// fire-and-forget and then verify with GET_CHANNEL.
Future<void> setChannel({
required int channelIdx,
required String channelName,
required List<int> secret,
}) async {
debugPrint('📻 [BLE] Setting channel:');
debugPrint(' Channel index: $channelIdx');
debugPrint(' Channel name: $channelName');
debugPrint(' Secret length: ${secret.length} bytes');
debugPrint(' Secret hex: ${secret.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}');
// Send SET_CHANNEL command (fire-and-forget, no ACK expected)
final setChannelData = FrameBuilder.buildSetChannel(
channelIdx: channelIdx,
channelName: channelName,
secret: secret,
);
debugPrint(' SET_CHANNEL data (${setChannelData.length} bytes): ${setChannelData.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
await _commandSender.writeData(setChannelData);
debugPrint('✅ [BLE] CMD_SET_CHANNEL sent');
// Wait a bit for the device to process
await Future.delayed(const Duration(milliseconds: 200));
// Verify the channel was set by reading it back
debugPrint('🔍 [BLE] Verifying channel was set...');
await getChannel(channelIdx);
}
/// Delete a channel by clearing its slot
///
/// This removes the channel from the device by setting it to an empty name and zeroed secret.
/// The channel slot becomes available for reuse.
///
/// Note: Channel 0 (public channel) cannot be deleted.
Future<void> deleteChannel(int channelIdx) async {
if (channelIdx == 0) {
throw ArgumentError('Cannot delete channel 0 (public channel)');
}
debugPrint('🗑️ [BLE] Deleting channel $channelIdx...');
// Clear channel by setting empty name and zeroed secret
await setChannel(
channelIdx: channelIdx,
channelName: '',
secret: List.filled(16, 0),
);
debugPrint('✅ [BLE] Channel $channelIdx deleted');
}
/// Sync all channels from the device (channels 1-39)
/// Skips channel 0 (public channel) which is implicit and not stored on device
Future<void> syncAllChannels({int maxChannels = 40}) async {
debugPrint('📻 [Service] Syncing channels (1-${maxChannels - 1})...');
// Start from 1 to skip channel 0 (public channel)
// Channel 0 is implicit and handled separately via configurePublicChannel()
for (int i = 1; i < maxChannels; i++) {
await getChannel(i);
// Small delay to avoid overwhelming the device
await Future.delayed(const Duration(milliseconds: 50));
}
debugPrint('✅ [Service] Channel sync complete');
}
/// Clear packet logs
void clearPacketLogs() {
_commandSender.clearPacketLogs();
_responseHandler.clearPacketLogs();
}
/// Reset packet counters
void resetCounters() {
_commandSender.resetCounter();
_responseHandler.resetCounter();
}
/// Start keepalive timer for iOS background mode
/// Periodically syncs messages to keep BLE connection alive and check for new messages
/// This serves dual purpose: prevents iOS from killing idle BLE connections AND
/// provides fallback message sync when push notifications (PUSH_CODE_MSG_WAITING) don't trigger
void _startKeepalive() {
_stopKeepalive(); // Stop any existing timer
debugPrint('🔄 [BLE] Starting keepalive timer (${_keepaliveInterval.inSeconds}s interval)');
_keepaliveTimer = Timer.periodic(_keepaliveInterval, (timer) async {
if (!isConnected) {
debugPrint('⚠️ [BLE] Keepalive: Not connected, stopping timer');
_stopKeepalive();
return;
}
try {
// Sync messages to keep connection alive AND check for new messages
// This is a fallback in case PUSH_CODE_MSG_WAITING doesn't fire
// If no messages waiting, device responds with RESP_CODE_NO_MORE_MSG
await syncNextMessage();
debugPrint('💚 [BLE] Keepalive: Connection maintained & messages synced');
} catch (e) {
debugPrint('⚠️ [BLE] Keepalive error: $e');
// Don't stop timer on error - iOS might throttle commands temporarily
}
});
}
/// Stop keepalive timer
void _stopKeepalive() {
if (_keepaliveTimer != null) {
debugPrint('🛑 [BLE] Stopping keepalive timer');
_keepaliveTimer?.cancel();
_keepaliveTimer = null;
}
}
/// Dispose resources
void dispose() {
_stopKeepalive(); // Clean up keepalive timer
_connectionManager.dispose();
_commandSender.dispose();
_responseHandler.dispose();
}
}

View File

@@ -0,0 +1,153 @@
/// MeshCore BLE and Protocol Constants
class MeshCoreConstants {
// Supported protocol version
static const int supportedCompanionProtocolVersion = 1;
// BLE Service and Characteristic UUIDs
static const String bleServiceUuid =
'6E400001-B5A3-F393-E0A9-E50E24DCCA9E';
static const String bleCharacteristicRxUuid =
'6E400002-B5A3-F393-E0A9-E50E24DCCA9E'; // Write
static const String bleCharacteristicTxUuid =
'6E400003-B5A3-F393-E0A9-E50E24DCCA9E'; // Notify
// Command Codes (App -> Device)
static const int cmdAppStart = 1;
static const int cmdSendTxtMsg = 2;
static const int cmdSendChannelTxtMsg = 3;
static const int cmdGetContacts = 4;
static const int cmdGetDeviceTime = 5;
static const int cmdSetDeviceTime = 6;
static const int cmdSendSelfAdvert = 7;
static const int cmdSetAdvertName = 8;
static const int cmdAddUpdateContact = 9;
static const int cmdSyncNextMessage = 10;
static const int cmdSetRadioParams = 11;
static const int cmdSetTxPower = 12;
static const int cmdResetPath = 13;
static const int cmdSetAdvertLatLon = 14;
static const int cmdRemoveContact = 15;
static const int cmdShareContact = 16;
static const int cmdExportContact = 17;
static const int cmdImportContact = 18;
static const int cmdReboot = 19;
static const int cmdGetBatteryVoltage = 20;
static const int cmdSetTuningParams = 21;
static const int cmdDeviceQuery = 22;
static const int cmdExportPrivateKey = 23;
static const int cmdImportPrivateKey = 24;
static const int cmdSendRawData = 25;
static const int cmdSendLogin = 26;
static const int cmdSendStatusReq = 27;
static const int cmdGetContactByKey = 30;
static const int cmdGetChannel = 31;
static const int cmdSetChannel = 32;
static const int cmdSignStart = 33;
static const int cmdSignData = 34;
static const int cmdSignFinish = 35;
static const int cmdSendTracePath = 36;
static const int cmdSetOtherParams = 38;
static const int cmdSendTelemetryReq = 39;
static const int cmdSendBinaryReq = 50;
// Response Codes (Device -> App)
static const int respOk = 0;
static const int respErr = 1;
static const int respContactsStart = 2;
static const int respContact = 3;
static const int respEndOfContacts = 4;
static const int respSelfInfo = 5;
static const int respSent = 6;
static const int respContactMsgRecv = 7;
static const int respChannelMsgRecv = 8;
static const int respCurrTime = 9;
static const int respNoMoreMessages = 10;
static const int respExportContact = 11;
static const int respBatteryVoltage = 12;
static const int respDeviceInfo = 13;
static const int respPrivateKey = 14;
static const int respDisabled = 15;
static const int respChannelInfo = 18;
static const int respSignStart = 19;
static const int respSignature = 20;
static const int respCustomVars = 21;
static const int respAdvertPath = 22;
static const int respTuningParams = 21; // Same as respCustomVars per protocol
// Push Codes (Device -> App, unsolicited)
static const int pushAdvert = 0x80;
static const int pushPathUpdated = 0x81;
static const int pushSendConfirmed = 0x82;
static const int pushMsgWaiting = 0x83;
static const int pushRawData = 0x84;
static const int pushLoginSuccess = 0x85;
static const int pushLoginFail = 0x86;
static const int pushStatusResponse = 0x87;
static const int pushLogRxData = 0x88;
static const int pushTraceData = 0x89;
static const int pushNewAdvert = 0x8A;
static const int pushTelemetryResponse = 0x8B;
static const int pushBinaryResponse = 0x8C;
// Error Codes
static const int errUnsupportedCmd = 1;
static const int errNotFound = 2;
static const int errTableFull = 3;
static const int errBadState = 4;
static const int errFileIoError = 5;
static const int errIllegalArg = 6;
// Advert Types
static const int advTypeNone = 0;
static const int advTypeChat = 1;
static const int advTypeRepeater = 2;
static const int advTypeRoom = 3;
// Self Advert Types
static const int selfAdvertZeroHop = 0;
static const int selfAdvertFlood = 1;
// Text Types
static const int txtTypePlain = 0;
static const int txtTypeCliData = 1;
static const int txtTypeSignedPlain = 2;
// Binary Request Types
static const int binaryReqGetTelemetryData = 0x03;
static const int binaryReqGetAvgMinMax = 0x04;
static const int binaryReqGetAccessList = 0x05;
static const int binaryReqGetNeighbours = 0x06;
// Default Public Channel Secret (128-bit)
// This is the well-known pre-shared key for the public channel (channel 0)
// Hex: 8b3387e9c5cdea6ac9e5edbaa115cd72
// Base64: izOH6cXN6mrJ5e26oRXNcg==
// Source: https://github.com/meshcore-dev/MeshCore/blob/main/docs/faq.md
static const List<int> defaultPublicChannelSecret = [
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72,
];
// Cayenne LPP Data Types
static const int lppDigitalInput = 0;
static const int lppDigitalOutput = 1;
static const int lppAnalogInput = 2;
static const int lppAnalogOutput = 3;
static const int lppIlluminanceSensor = 101;
static const int lppPresenceSensor = 102;
static const int lppTemperatureSensor = 103;
static const int lppHumiditySensor = 104;
static const int lppAccelerometer = 113;
static const int lppBarometer = 115;
static const int lppVoltageSensor = 116;
static const int lppGyrometer = 134;
static const int lppGps = 136;
// MTU and timing
static const int maxMtuSize = 512;
static const int defaultTimeout = 5000; // 5 seconds
static const int reconnectDelay = 2000; // 2 seconds
static const int telemetryUpdateInterval = 300000; // 5 minutes
MeshCoreConstants._(); // Private constructor to prevent instantiation
}

View File

@@ -0,0 +1,190 @@
import 'meshcore_constants.dart';
/// Maps MeshCore protocol opcodes to human-readable names
class MeshCoreOpcodeNames {
/// Get command name from opcode
static String getCommandName(int opcode) {
switch (opcode) {
case MeshCoreConstants.cmdAppStart:
return 'APP_START';
case MeshCoreConstants.cmdSendTxtMsg:
return 'SEND_TXT_MSG';
case MeshCoreConstants.cmdSendChannelTxtMsg:
return 'SEND_CHANNEL_TXT_MSG';
case MeshCoreConstants.cmdGetContacts:
return 'GET_CONTACTS';
case MeshCoreConstants.cmdGetDeviceTime:
return 'GET_DEVICE_TIME';
case MeshCoreConstants.cmdSetDeviceTime:
return 'SET_DEVICE_TIME';
case MeshCoreConstants.cmdSendSelfAdvert:
return 'SEND_SELF_ADVERT';
case MeshCoreConstants.cmdSetAdvertName:
return 'SET_ADVERT_NAME';
case MeshCoreConstants.cmdAddUpdateContact:
return 'ADD_UPDATE_CONTACT';
case MeshCoreConstants.cmdSyncNextMessage:
return 'SYNC_NEXT_MESSAGE';
case MeshCoreConstants.cmdSetRadioParams:
return 'SET_RADIO_PARAMS';
case MeshCoreConstants.cmdSetTxPower:
return 'SET_TX_POWER';
case MeshCoreConstants.cmdResetPath:
return 'RESET_PATH';
case MeshCoreConstants.cmdSetAdvertLatLon:
return 'SET_ADVERT_LAT_LON';
case MeshCoreConstants.cmdRemoveContact:
return 'REMOVE_CONTACT';
case MeshCoreConstants.cmdShareContact:
return 'SHARE_CONTACT';
case MeshCoreConstants.cmdExportContact:
return 'EXPORT_CONTACT';
case MeshCoreConstants.cmdImportContact:
return 'IMPORT_CONTACT';
case MeshCoreConstants.cmdReboot:
return 'REBOOT';
case MeshCoreConstants.cmdGetBatteryVoltage:
return 'GET_BATTERY_VOLTAGE';
case MeshCoreConstants.cmdSetTuningParams:
return 'SET_TUNING_PARAMS';
case MeshCoreConstants.cmdDeviceQuery:
return 'DEVICE_QUERY';
case MeshCoreConstants.cmdExportPrivateKey:
return 'EXPORT_PRIVATE_KEY';
case MeshCoreConstants.cmdImportPrivateKey:
return 'IMPORT_PRIVATE_KEY';
case MeshCoreConstants.cmdSendRawData:
return 'SEND_RAW_DATA';
case MeshCoreConstants.cmdSendLogin:
return 'SEND_LOGIN';
case MeshCoreConstants.cmdSendStatusReq:
return 'SEND_STATUS_REQ';
case MeshCoreConstants.cmdGetContactByKey:
return 'GET_CONTACT_BY_KEY';
case MeshCoreConstants.cmdGetChannel:
return 'GET_CHANNEL';
case MeshCoreConstants.cmdSetChannel:
return 'SET_CHANNEL';
case MeshCoreConstants.cmdSignStart:
return 'SIGN_START';
case MeshCoreConstants.cmdSignData:
return 'SIGN_DATA';
case MeshCoreConstants.cmdSignFinish:
return 'SIGN_FINISH';
case MeshCoreConstants.cmdSendTracePath:
return 'SEND_TRACE_PATH';
case MeshCoreConstants.cmdSetOtherParams:
return 'SET_OTHER_PARAMS';
case MeshCoreConstants.cmdSendTelemetryReq:
return 'SEND_TELEMETRY_REQ';
case MeshCoreConstants.cmdSendBinaryReq:
return 'SEND_BINARY_REQ';
default:
return 'CMD_UNKNOWN';
}
}
/// Get response name from opcode
static String getResponseName(int opcode) {
switch (opcode) {
case MeshCoreConstants.respOk:
return 'OK';
case MeshCoreConstants.respErr:
return 'ERROR';
case MeshCoreConstants.respContactsStart:
return 'CONTACTS_START';
case MeshCoreConstants.respContact:
return 'CONTACT';
case MeshCoreConstants.respEndOfContacts:
return 'END_OF_CONTACTS';
case MeshCoreConstants.respSelfInfo:
return 'SELF_INFO';
case MeshCoreConstants.respSent:
return 'SENT';
case MeshCoreConstants.respContactMsgRecv:
return 'CONTACT_MSG_RECV';
case MeshCoreConstants.respChannelMsgRecv:
return 'CHANNEL_MSG_RECV';
case MeshCoreConstants.respCurrTime:
return 'CURR_TIME';
case MeshCoreConstants.respNoMoreMessages:
return 'NO_MORE_MESSAGES';
case MeshCoreConstants.respExportContact:
return 'EXPORT_CONTACT';
case MeshCoreConstants.respBatteryVoltage:
return 'BATTERY_VOLTAGE';
case MeshCoreConstants.respDeviceInfo:
return 'DEVICE_INFO';
case MeshCoreConstants.respPrivateKey:
return 'PRIVATE_KEY';
case MeshCoreConstants.respDisabled:
return 'DISABLED';
case MeshCoreConstants.respChannelInfo:
return 'CHANNEL_INFO';
case MeshCoreConstants.respSignStart:
return 'SIGN_START';
case MeshCoreConstants.respSignature:
return 'SIGNATURE';
default:
return 'RESP_UNKNOWN';
}
}
/// Get push notification name from opcode
static String getPushName(int opcode) {
switch (opcode) {
case MeshCoreConstants.pushAdvert:
return 'ADVERT';
case MeshCoreConstants.pushPathUpdated:
return 'PATH_UPDATED';
case MeshCoreConstants.pushSendConfirmed:
return 'SEND_CONFIRMED';
case MeshCoreConstants.pushMsgWaiting:
return 'MSG_WAITING';
case MeshCoreConstants.pushRawData:
return 'RAW_DATA';
case MeshCoreConstants.pushLoginSuccess:
return 'LOGIN_SUCCESS';
case MeshCoreConstants.pushLoginFail:
return 'LOGIN_FAIL';
case MeshCoreConstants.pushStatusResponse:
return 'STATUS_RESPONSE';
case MeshCoreConstants.pushLogRxData:
return 'LOG_RX_DATA';
case MeshCoreConstants.pushTraceData:
return 'TRACE_DATA';
case MeshCoreConstants.pushNewAdvert:
return 'NEW_ADVERT';
case MeshCoreConstants.pushTelemetryResponse:
return 'TELEMETRY_RESPONSE';
case MeshCoreConstants.pushBinaryResponse:
return 'BINARY_RESPONSE';
default:
return 'PUSH_UNKNOWN';
}
}
/// Get opcode name for any code (tries to determine type automatically)
static String getOpcodeName(int opcode, {bool isTx = false}) {
// If TX (sent to device), it's a command
if (isTx) {
return getCommandName(opcode);
}
// If RX (received from device), determine if it's a push or response
if (opcode >= 0x80) {
return getPushName(opcode);
} else {
return getResponseName(opcode);
}
}
/// Get full opcode description with code in hex
static String getOpcodeDescription(int opcode, {bool isTx = false}) {
final name = getOpcodeName(opcode, isTx: isTx);
final hex = '0x${opcode.toRadixString(16).padLeft(2, '0').toUpperCase()}';
return '$name ($hex)';
}
MeshCoreOpcodeNames._(); // Private constructor to prevent instantiation
}

View File

@@ -0,0 +1,71 @@
import 'package:shared_preferences/shared_preferences.dart';
/// Service for managing message destination preferences
/// Stores the last selected recipient (channel, contact, or room) for sending messages
class MessageDestinationPreferences {
static const String _destinationTypeKey = 'message_destination_type';
static const String _recipientPublicKeyKey = 'message_recipient_public_key';
/// Destination types
static const String destinationTypeChannel = 'channel';
static const String destinationTypeContact = 'contact';
static const String destinationTypeRoom = 'room';
/// Get the saved destination configuration
/// Returns a map with 'type' and optional 'publicKey'
/// Returns null if no preference is saved (defaults to public channel)
static Future<Map<String, String>?> getDestination() async {
final prefs = await SharedPreferences.getInstance();
final type = prefs.getString(_destinationTypeKey);
if (type == null) {
return null; // Use default (public channel)
}
final publicKey = prefs.getString(_recipientPublicKeyKey);
return {
'type': type,
if (publicKey != null) 'publicKey': publicKey,
};
}
/// Save the selected destination
/// [type] - one of: destinationTypeChannel, destinationTypeContact, destinationTypeRoom
/// [recipientPublicKey] - hex string of recipient's public key (required for contact/room)
static Future<void> setDestination(
String type, {
String? recipientPublicKey,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_destinationTypeKey, type);
if (recipientPublicKey != null) {
await prefs.setString(_recipientPublicKeyKey, recipientPublicKey);
} else {
await prefs.remove(_recipientPublicKeyKey);
}
}
/// Clear the saved destination (resets to default public channel)
static Future<void> clearDestination() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_destinationTypeKey);
await prefs.remove(_recipientPublicKeyKey);
}
/// Get display name for destination type
static String getDestinationTypeName(String type) {
switch (type) {
case destinationTypeChannel:
return 'Channel';
case destinationTypeContact:
return 'Contact';
case destinationTypeRoom:
return 'Room';
default:
return 'Unknown';
}
}
}

View File

@@ -0,0 +1,254 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/message.dart';
import 'package:latlong2/latlong.dart';
/// Service for persisting messages to local storage
class MessageStorageService {
static const String _messagesKey = 'stored_messages';
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
/// Save messages to persistent storage
Future<void> saveMessages(List<Message> messages) async {
try {
final prefs = await SharedPreferences.getInstance();
// Convert messages to JSON
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
// Limit to max stored messages (keep most recent)
final limitedList = jsonList.length > _maxStoredMessages
? jsonList.sublist(jsonList.length - _maxStoredMessages)
: jsonList;
final jsonString = jsonEncode(limitedList);
await prefs.setString(_messagesKey, jsonString);
debugPrint(
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
);
} catch (e) {
debugPrint('❌ [MessageStorage] Error saving messages: $e');
}
}
/// Load messages from persistent storage
Future<List<Message>> loadMessages() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
if (jsonString == null || jsonString.isEmpty) {
debugPrint(' [MessageStorage] No stored messages found');
return [];
}
final jsonList = jsonDecode(jsonString) as List<dynamic>;
final messages = jsonList
.map((json) => _messageFromJson(json as Map<String, dynamic>))
.where((msg) => msg != null)
.cast<Message>()
.toList();
debugPrint(
'✅ [MessageStorage] Loaded ${messages.length} messages from storage',
);
return messages;
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading messages: $e');
return [];
}
}
/// Clear all stored messages
Future<void> clearMessages() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_messagesKey);
debugPrint('✅ [MessageStorage] Cleared all stored messages');
} catch (e) {
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
}
}
/// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
if (jsonString == null || jsonString.isEmpty) {
return {'messageCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
}
final sizeBytes = jsonString.length;
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return {
'messageCount': jsonList.length,
'storageSizeBytes': sizeBytes,
'storageSizeKB': (sizeBytes / 1024).toStringAsFixed(2),
};
} catch (e) {
debugPrint('❌ [MessageStorage] Error getting storage stats: $e');
return {'messageCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
}
}
/// Convert Message to JSON
Map<String, dynamic> _messageToJson(Message message) {
return {
'id': message.id,
'messageType': message.messageType.name,
'senderPublicKeyPrefix': message.senderPublicKeyPrefix != null
? base64Encode(message.senderPublicKeyPrefix!)
: null,
'channelIdx': message.channelIdx,
'pathLen': message.pathLen,
'textType': message.textType.value,
'senderTimestamp': message.senderTimestamp,
'text': message.text,
'isSarMarker': message.isSarMarker,
'sarGpsLat': message.sarGpsCoordinates?.latitude,
'sarGpsLon': message.sarGpsCoordinates?.longitude,
'sarNotes': message.sarNotes,
'sarCustomEmoji': message.sarCustomEmoji,
'sarColorIndex': message.sarColorIndex,
'receivedAtMillis': message.receivedAt.millisecondsSinceEpoch,
'senderName': message.senderName,
'deliveryStatus': message.deliveryStatus.name,
'expectedAckTag': message.expectedAckTag,
'suggestedTimeoutMs': message.suggestedTimeoutMs,
'roundTripTimeMs': message.roundTripTimeMs,
'deliveredAtMillis': message.deliveredAt?.millisecondsSinceEpoch,
'recipientPublicKey': message.recipientPublicKey != null
? base64Encode(message.recipientPublicKey!)
: null,
'isRead': message.isRead,
// Retry state tracking (IMPORTANT for preserving state across app restarts)
'retryAttempt': message.retryAttempt,
'lastRetryAtMillis': message.lastRetryAt?.millisecondsSinceEpoch,
'usedFloodFallback': message.usedFloodFallback,
// Echo detection for channel messages
'echoCount': message.echoCount,
'firstEchoAtMillis': message.firstEchoAt?.millisecondsSinceEpoch,
// Drawing message tracking
'isDrawing': message.isDrawing,
'drawingId': message.drawingId,
// Message grouping (for bulk sends)
'groupId': message.groupId,
'recipients': message.recipients?.map((r) => {
'publicKey': base64Encode(r.publicKey),
'displayName': r.displayName,
'deliveryStatus': r.deliveryStatus.name,
'expectedAckTag': r.expectedAckTag,
'roundTripTimeMs': r.roundTripTimeMs,
'deliveredAtMillis': r.deliveredAt?.millisecondsSinceEpoch,
'sentAtMillis': r.sentAt.millisecondsSinceEpoch,
}).toList(),
};
}
/// Convert JSON to Message
Message? _messageFromJson(Map<String, dynamic> json) {
try {
return Message(
id: json['id'] as String,
messageType: MessageType.values.firstWhere(
(e) => e.name == json['messageType'],
orElse: () => MessageType.contact,
),
senderPublicKeyPrefix: json['senderPublicKeyPrefix'] != null
? Uint8List.fromList(
base64Decode(json['senderPublicKeyPrefix'] as String),
)
: null,
channelIdx: json['channelIdx'] as int?,
pathLen: json['pathLen'] as int,
textType: MessageTextType.fromValue(json['textType'] as int),
senderTimestamp: json['senderTimestamp'] as int,
text: json['text'] as String,
isSarMarker: json['isSarMarker'] as bool? ?? false,
sarGpsCoordinates:
json['sarGpsLat'] != null && json['sarGpsLon'] != null
? LatLng(json['sarGpsLat'] as double, json['sarGpsLon'] as double)
: null,
sarNotes: json['sarNotes'] as String?,
sarCustomEmoji: json['sarCustomEmoji'] as String?,
sarColorIndex: json['sarColorIndex'] as int?,
receivedAt: DateTime.fromMillisecondsSinceEpoch(
json['receivedAtMillis'] as int,
),
senderName: json['senderName'] as String?,
deliveryStatus: json['deliveryStatus'] != null
? MessageDeliveryStatus.values.firstWhere(
(e) => e.name == json['deliveryStatus'],
orElse: () => MessageDeliveryStatus.received,
)
: MessageDeliveryStatus.received,
expectedAckTag: json['expectedAckTag'] as int?,
suggestedTimeoutMs: json['suggestedTimeoutMs'] as int?,
roundTripTimeMs: json['roundTripTimeMs'] as int?,
deliveredAt: json['deliveredAtMillis'] != null
? DateTime.fromMillisecondsSinceEpoch(
json['deliveredAtMillis'] as int,
)
: null,
recipientPublicKey: json['recipientPublicKey'] != null
? Uint8List.fromList(
base64Decode(json['recipientPublicKey'] as String),
)
: null,
isRead: json['isRead'] as bool? ?? false,
// Retry state tracking (preserves retry/flood state across restarts)
retryAttempt: json['retryAttempt'] as int? ?? 0,
lastRetryAt: json['lastRetryAtMillis'] != null
? DateTime.fromMillisecondsSinceEpoch(
json['lastRetryAtMillis'] as int,
)
: null,
usedFloodFallback: json['usedFloodFallback'] as bool? ?? false,
// Echo detection
echoCount: json['echoCount'] as int? ?? 0,
firstEchoAt: json['firstEchoAtMillis'] != null
? DateTime.fromMillisecondsSinceEpoch(
json['firstEchoAtMillis'] as int,
)
: null,
// Drawing message tracking
isDrawing: json['isDrawing'] as bool? ?? false,
drawingId: json['drawingId'] as String?,
// Message grouping
groupId: json['groupId'] as String?,
recipients: json['recipients'] != null
? (json['recipients'] as List<dynamic>)
.map((r) => MessageRecipient(
publicKey: Uint8List.fromList(
base64Decode(r['publicKey'] as String),
),
displayName: r['displayName'] as String,
deliveryStatus: MessageDeliveryStatus.values.firstWhere(
(e) => e.name == r['deliveryStatus'],
orElse: () => MessageDeliveryStatus.sending,
),
expectedAckTag: r['expectedAckTag'] as int?,
roundTripTimeMs: r['roundTripTimeMs'] as int?,
deliveredAt: r['deliveredAtMillis'] != null
? DateTime.fromMillisecondsSinceEpoch(
r['deliveredAtMillis'] as int,
)
: null,
sentAt: DateTime.fromMillisecondsSinceEpoch(
r['sentAtMillis'] as int,
),
))
.toList()
: null,
);
} catch (e) {
debugPrint('❌ [MessageStorage] Error parsing message from JSON: $e');
return null;
}
}
}

View File

@@ -0,0 +1,347 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:nsd/nsd.dart';
/// Discovered SSE server on the network
class DiscoveredServer {
final String ipAddress;
final int port;
final int responseTime; // in milliseconds
final String serverUrl;
DiscoveredServer({
required this.ipAddress,
required this.port,
required this.responseTime,
}) : serverUrl = 'http://$ipAddress:$port';
@override
String toString() {
return 'DiscoveredServer($ipAddress:$port, ${responseTime}ms)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is DiscoveredServer &&
other.ipAddress == ipAddress &&
other.port == port;
}
@override
int get hashCode => Object.hash(ipAddress, port);
}
/// Network Scanner Service
///
/// Discovers SSE servers on the local network using Bonjour/mDNS.
/// Falls back to port scanning (12929) if no services are discovered.
/// Uses parallel scanning (20 IPs at once) for fast discovery.
class NetworkScannerService {
static const int defaultPort = 12929;
static const String serviceType = '_meshcore-sse._tcp';
static const int parallelScans = 20;
static const Duration scanTimeout = Duration(seconds: 2);
static const Duration bonjourTimeout = Duration(seconds: 5);
Discovery? _activeDiscovery;
/// Callback for when a server is discovered
Function(DiscoveredServer)? onServerDiscovered;
/// Callback for scan progress updates
Function(int scanned, int total)? onProgressUpdate;
bool _isScanning = false;
bool get isScanning => _isScanning;
/// Cached discovered servers from the last scan
List<DiscoveredServer> _cachedServers = [];
List<DiscoveredServer> get cachedServers => List.unmodifiable(_cachedServers);
/// Whether we have cached results from a previous scan
bool get hasCachedResults => _cachedServers.isNotEmpty;
/// Get all local IP addresses
Future<Set<String>> _getLocalIpAddresses() async {
final Set<String> localIps = {};
try {
final interfaces = await NetworkInterface.list();
for (final interface in interfaces) {
for (final addr in interface.addresses) {
if (addr.type == InternetAddressType.IPv4) {
localIps.add(addr.address);
}
}
}
} catch (e) {
debugPrint('❌ [NetworkScanner] Error getting local IPs: $e');
}
return localIps;
}
/// Get local network IP range to scan
Future<List<String>> _getLocalNetworkRange() async {
final List<String> ips = [];
try {
// Get all network interfaces
final interfaces = await NetworkInterface.list();
for (final interface in interfaces) {
for (final addr in interface.addresses) {
// Only scan IPv4 addresses that are not loopback
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
final ip = addr.address;
final parts = ip.split('.');
if (parts.length == 4) {
// Generate range for the same subnet (e.g., 192.168.1.1-254)
final subnet = '${parts[0]}.${parts[1]}.${parts[2]}';
// Scan from .1 to .254 (skip .0 and .255)
for (int i = 1; i <= 254; i++) {
ips.add('$subnet.$i');
}
debugPrint('📡 [NetworkScanner] Will scan subnet: $subnet.0/24');
// Only scan first viable subnet
return ips;
}
}
}
}
} catch (e) {
debugPrint('❌ [NetworkScanner] Error getting network interfaces: $e');
}
return ips;
}
/// Check if an IP has an SSE server running
Future<DiscoveredServer?> _checkServer(String ip, int port) async {
try {
final stopwatch = Stopwatch()..start();
final url = Uri.parse('http://$ip:$port/api/status');
final response = await http.get(url).timeout(scanTimeout);
stopwatch.stop();
if (response.statusCode == 200) {
debugPrint('✅ [NetworkScanner] Found server at $ip:$port (${stopwatch.elapsedMilliseconds}ms)');
return DiscoveredServer(
ipAddress: ip,
port: port,
responseTime: stopwatch.elapsedMilliseconds,
);
}
} on TimeoutException {
// Timeout - server not responding, ignore
} on SocketException {
// Connection refused - no server at this IP, ignore
} catch (e) {
// Other errors - ignore
debugPrint('⚠️ [NetworkScanner] Error checking $ip:$port - $e');
}
return null;
}
/// Discover servers using Bonjour/mDNS
Future<List<DiscoveredServer>> _discoverViaBonjourAsync({int? port}) async {
final scanPort = port ?? defaultPort;
final List<DiscoveredServer> discoveredServers = [];
try {
debugPrint('🔍 [NetworkScanner] Starting Bonjour discovery for $serviceType...');
// Get local IP addresses to filter out
final localIps = await _getLocalIpAddresses();
debugPrint('📍 [NetworkScanner] Local IPs: ${localIps.join(", ")}');
// Start discovery with IP lookup
_activeDiscovery = await startDiscovery(
serviceType,
ipLookupType: IpLookupType.any,
);
// Wait for discovery to find services
await Future.delayed(bonjourTimeout);
// Process discovered services
final services = _activeDiscovery?.services ?? [];
debugPrint('📡 [NetworkScanner] Bonjour found ${services.length} services');
for (final service in services) {
if (service.addresses != null && service.addresses!.isNotEmpty) {
for (final address in service.addresses!) {
// Skip if this is a local IP address
if (localIps.contains(address.address)) {
debugPrint('⏭️ [NetworkScanner] Skipping local IP: ${address.address}');
continue;
}
// Verify service is actually reachable
final result = await _checkServer(
address.address,
service.port ?? scanPort,
);
if (result != null) {
discoveredServers.add(result);
onServerDiscovered?.call(result);
}
}
}
}
// Stop discovery
await stopDiscovery(_activeDiscovery!);
_activeDiscovery = null;
debugPrint('✅ [NetworkScanner] Bonjour discovery complete. Found ${discoveredServers.length} servers.');
} catch (e) {
debugPrint('⚠️ [NetworkScanner] Bonjour discovery failed: $e');
if (_activeDiscovery != null) {
try {
await stopDiscovery(_activeDiscovery!);
} catch (_) {}
_activeDiscovery = null;
}
}
return discoveredServers;
}
/// Scan the local network for SSE servers
/// First tries Bonjour/mDNS, then falls back to port scanning if nothing found
Future<List<DiscoveredServer>> scan({int? port}) async {
if (_isScanning) {
debugPrint('⚠️ [NetworkScanner] Scan already in progress');
return [];
}
_isScanning = true;
final scanPort = port ?? defaultPort;
List<DiscoveredServer> discoveredServers = [];
try {
// Try Bonjour/mDNS discovery first
discoveredServers = await _discoverViaBonjourAsync(port: scanPort);
// Fall back to port scanning if Bonjour found nothing
if (discoveredServers.isEmpty) {
debugPrint('🔍 [NetworkScanner] Bonjour found nothing, falling back to port scanning...');
discoveredServers = await _scanByPortAsync(port: scanPort);
}
// Cache the results
_cachedServers = discoveredServers;
} catch (e) {
debugPrint('❌ [NetworkScanner] Scan error: $e');
} finally {
_isScanning = false;
}
return discoveredServers;
}
/// Fallback port scanning method
Future<List<DiscoveredServer>> _scanByPortAsync({int? port}) async {
final scanPort = port ?? defaultPort;
final List<DiscoveredServer> discoveredServers = [];
try {
debugPrint('🔍 [NetworkScanner] Starting port scan on port $scanPort...');
// Get local IP addresses to filter out
final localIps = await _getLocalIpAddresses();
debugPrint('📍 [NetworkScanner] Local IPs: ${localIps.join(", ")}');
final ips = await _getLocalNetworkRange();
if (ips.isEmpty) {
debugPrint('⚠️ [NetworkScanner] No network interfaces found');
return [];
}
debugPrint('📊 [NetworkScanner] Scanning ${ips.length} IPs with $parallelScans parallel connections');
int scannedCount = 0;
// Scan in batches of 20 parallel connections
for (int i = 0; i < ips.length; i += parallelScans) {
final batch = ips.skip(i).take(parallelScans).toList();
// Scan batch in parallel
final futures = batch.map((ip) => _checkServer(ip, scanPort)).toList();
final results = await Future.wait(futures);
// Collect discovered servers (excluding local IPs)
for (int j = 0; j < results.length; j++) {
final result = results[j];
if (result != null) {
// Skip if this is a local IP address
if (localIps.contains(result.ipAddress)) {
debugPrint('⏭️ [NetworkScanner] Skipping local IP: ${result.ipAddress}');
continue;
}
discoveredServers.add(result);
onServerDiscovered?.call(result);
}
}
scannedCount += batch.length;
onProgressUpdate?.call(scannedCount, ips.length);
}
debugPrint('✅ [NetworkScanner] Port scan complete. Found ${discoveredServers.length} servers.');
} catch (e) {
debugPrint('❌ [NetworkScanner] Port scan error: $e');
}
return discoveredServers;
}
/// Clear cached results (useful for forcing a fresh scan)
void clearCache() {
_cachedServers = [];
debugPrint('🗑️ [NetworkScanner] Cache cleared');
}
/// Stop ongoing scan
void stopScan() {
if (_isScanning) {
debugPrint('🛑 [NetworkScanner] Stopping scan...');
_isScanning = false;
}
}
/// Verify that a previously discovered server is still available
/// Returns true if server is reachable, false otherwise
Future<bool> verifyServer(DiscoveredServer server) async {
try {
debugPrint('🔍 [NetworkScanner] Verifying server at ${server.ipAddress}:${server.port}...');
final result = await _checkServer(server.ipAddress, server.port);
if (result != null) {
debugPrint('✅ [NetworkScanner] Server verified at ${server.ipAddress}:${server.port}');
return true;
} else {
debugPrint('❌ [NetworkScanner] Server no longer available at ${server.ipAddress}:${server.port}');
return false;
}
} catch (e) {
debugPrint('❌ [NetworkScanner] Server verification failed: $e');
return false;
}
}
}

View File

@@ -0,0 +1,621 @@
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/data/latest_all.dart' as tz;
import '../models/sar_marker.dart';
import '../l10n/app_localizations.dart';
/// Notification Service - manages urgent notifications for SAR messages
/// Provides critical alert functionality for SAR marker events
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
NotificationService._internal();
final FlutterLocalNotificationsPlugin _notificationsPlugin =
FlutterLocalNotificationsPlugin();
bool _isInitialized = false;
bool _permissionGranted = false;
// Notification IDs
static const int _sarNotificationId = 1000;
static const int _messageNotificationId = 2000;
static const int _updateNotificationId = 3000;
// Notification channels
static const String _urgentChannelId = 'sar_urgent';
static const String _urgentChannelName = 'SAR Urgent Alerts';
static const String _urgentChannelDescription =
'Critical alerts for SAR markers (found persons, fires, staging areas)';
static const String _messagesChannelId = 'messages';
static const String _messagesChannelName = 'Messages';
static const String _messagesChannelDescription =
'Notifications for incoming messages from contacts and channels';
static const String _updateChannelId = 'app_updates';
static const String _updateChannelName = 'App Updates';
static const String _updateChannelDescription =
'Notifications for available app updates';
/// Initialize notification service
Future<void> initialize() async {
if (_isInitialized) return;
try {
debugPrint('📬 [NotificationService] Initializing...');
// Initialize timezone data
tz.initializeTimeZones();
// Android initialization settings
const androidSettings = AndroidInitializationSettings(
'@mipmap/ic_launcher',
);
// iOS initialization settings
final darwinSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
requestCriticalPermission: true, // For urgent SAR notifications
);
// Combined initialization settings
final initSettings = InitializationSettings(
android: androidSettings,
iOS: darwinSettings,
);
// Initialize plugin
await _notificationsPlugin.initialize(
initSettings,
onDidReceiveNotificationResponse: _onNotificationResponse,
);
// Request permissions
await _requestPermissions();
// Create notification channels (Android)
await _createNotificationChannels();
_isInitialized = true;
debugPrint('✅ [NotificationService] Initialized successfully');
debugPrint(' Permission granted: $_permissionGranted');
} catch (e) {
debugPrint('❌ [NotificationService] Initialization error: $e');
}
}
/// Request notification permissions
Future<void> _requestPermissions() async {
try {
// iOS permissions
final iosPlugin = _notificationsPlugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>();
if (iosPlugin != null) {
final granted = await iosPlugin.requestPermissions(
alert: true,
badge: true,
sound: true,
critical:
true, // Request critical alert permission for urgent SAR notifications
);
_permissionGranted = granted ?? false;
debugPrint(
'📱 [NotificationService] iOS permissions granted: $_permissionGranted',
);
return; // Exit early if on iOS
}
// Android 13+ permissions
final androidPlugin = _notificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidPlugin != null) {
final granted = await androidPlugin.requestNotificationsPermission();
_permissionGranted = granted ?? false;
debugPrint(
'🤖 [NotificationService] Android permissions granted: $_permissionGranted',
);
return; // Exit early if on Android
}
// If neither platform plugin is available, assume permissions are granted
// This handles older Android versions that don't require runtime permissions
_permissionGranted = true;
debugPrint(
'✅ [NotificationService] No platform plugin found, assuming permissions granted',
);
} catch (e) {
debugPrint('⚠️ [NotificationService] Error requesting permissions: $e');
}
}
/// Create notification channels for Android
Future<void> _createNotificationChannels() async {
try {
final androidPlugin = _notificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidPlugin == null) return;
// Urgent SAR channel with maximum priority
const urgentChannel = AndroidNotificationChannel(
_urgentChannelId,
_urgentChannelName,
description: _urgentChannelDescription,
importance: Importance.max,
playSound: true,
enableVibration: true,
enableLights: true,
showBadge: true,
sound: RawResourceAndroidNotificationSound('notification'),
);
// Messages channel with high priority
const messagesChannel = AndroidNotificationChannel(
_messagesChannelId,
_messagesChannelName,
description: _messagesChannelDescription,
importance: Importance.high,
playSound: true,
enableVibration: true,
showBadge: true,
);
// App updates channel with default priority
const updateChannel = AndroidNotificationChannel(
_updateChannelId,
_updateChannelName,
description: _updateChannelDescription,
importance: Importance.defaultImportance,
playSound: false,
enableVibration: false,
showBadge: true,
);
await androidPlugin.createNotificationChannel(urgentChannel);
await androidPlugin.createNotificationChannel(messagesChannel);
await androidPlugin.createNotificationChannel(updateChannel);
debugPrint('✅ [NotificationService] Created notification channels');
} catch (e) {
debugPrint('⚠️ [NotificationService] Error creating channels: $e');
}
}
/// Callback for handling notification taps (set by main.dart)
void Function(String?)? onNotificationTapped;
/// Handle notification tap (foreground)
void _onNotificationResponse(NotificationResponse response) {
debugPrint(
'🔔 [NotificationService] Notification tapped: ${response.payload}',
);
// Call the registered callback if available
if (onNotificationTapped != null) {
onNotificationTapped!(response.payload);
}
}
/// Show urgent notification for SAR marker
Future<void> showSarNotification({
required SarMarkerType type,
required String senderName,
required String coordinates,
String? notes,
AppLocalizations? localizations,
}) async {
if (!_isInitialized) {
debugPrint(
'⚠️ [NotificationService] Not initialized, skipping notification',
);
return;
}
if (!_permissionGranted) {
debugPrint(
'⚠️ [NotificationService] Permission not granted, skipping notification',
);
return;
}
try {
// Generate unique notification ID based on timestamp
final notificationId =
_sarNotificationId + (DateTime.now().millisecondsSinceEpoch % 1000);
// Build notification title and body
final title = _buildNotificationTitle(type, localizations);
final body = _buildNotificationBody(
type: type,
senderName: senderName,
coordinates: coordinates,
notes: notes,
localizations: localizations,
);
// Android notification details
final androidDetails = AndroidNotificationDetails(
_urgentChannelId,
_urgentChannelName,
channelDescription: _urgentChannelDescription,
importance: Importance.max,
priority: Priority.high,
ticker: title,
playSound: true,
enableVibration: true,
enableLights: true,
color: Color(_getNotificationColor(type)),
colorized: true,
showWhen: true,
when: DateTime.now().millisecondsSinceEpoch,
category: AndroidNotificationCategory.alarm, // High priority category
fullScreenIntent: true, // Show as full screen on some devices
styleInformation: BigTextStyleInformation(
body,
contentTitle: title,
summaryText: _getSummaryText(type, localizations),
),
);
// iOS notification details
final darwinDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
sound: 'default',
badgeNumber: 1,
threadIdentifier: 'sar_markers',
categoryIdentifier: 'SAR_ALERT',
interruptionLevel:
InterruptionLevel.critical, // Critical alert (bypasses silent mode)
);
// Combined notification details
final notificationDetails = NotificationDetails(
android: androidDetails,
iOS: darwinDetails,
);
// Show notification
await _notificationsPlugin.show(
notificationId,
title,
body,
notificationDetails,
payload: 'sar:${type.name}:$coordinates',
);
debugPrint('✅ [NotificationService] Showed SAR notification: $title');
debugPrint(' Type: ${type.displayName}');
debugPrint(' Sender: $senderName');
debugPrint(' Coordinates: $coordinates');
} catch (e) {
debugPrint('❌ [NotificationService] Error showing notification: $e');
}
}
/// Build notification title based on SAR marker type
String _buildNotificationTitle(
SarMarkerType type,
AppLocalizations? localizations,
) {
if (localizations == null) {
return '${type.emoji} ${type.displayName} Detected';
}
switch (type) {
case SarMarkerType.foundPerson:
return '${type.emoji} ${localizations.sarMarkerFoundPerson}';
case SarMarkerType.fire:
return '${type.emoji} ${localizations.sarMarkerFire}';
case SarMarkerType.stagingArea:
return '${type.emoji} ${localizations.sarMarkerStagingArea}';
case SarMarkerType.object:
return '${type.emoji} ${localizations.sarMarkerObject}';
case SarMarkerType.unknown:
return '${type.emoji} ${localizations.sarAlert}';
}
}
/// Build notification body with all details
String _buildNotificationBody({
required SarMarkerType type,
required String senderName,
required String coordinates,
String? notes,
AppLocalizations? localizations,
}) {
final buffer = StringBuffer();
// Sender
if (localizations != null) {
buffer.write('${localizations.from}: $senderName\n');
buffer.write('${localizations.coordinates}: $coordinates');
} else {
buffer.write('From: $senderName\n');
buffer.write('Coordinates: $coordinates');
}
// Optional notes
if (notes != null && notes.isNotEmpty) {
buffer.write('\n\n$notes');
}
return buffer.toString();
}
/// Get summary text for notification
String _getSummaryText(SarMarkerType type, AppLocalizations? localizations) {
if (localizations == null) {
return 'Tap to view on map';
}
return localizations.tapToViewOnMap;
}
/// Get notification color based on SAR marker type
int _getNotificationColor(SarMarkerType type) {
// Return ARGB color codes
switch (type) {
case SarMarkerType.foundPerson:
return 0xFF4CAF50; // Green
case SarMarkerType.fire:
return 0xFFF44336; // Red
case SarMarkerType.stagingArea:
return 0xFFFF9800; // Orange
case SarMarkerType.object:
return 0xFF2196F3; // Blue
case SarMarkerType.unknown:
return 0xFF9E9E9E; // Gray
}
}
/// Show notification for regular message (contact or channel)
Future<void> showMessageNotification({
required String senderName,
required String messageText,
required bool isChannelMessage,
String? channelName,
AppLocalizations? localizations,
}) async {
if (!_isInitialized) {
debugPrint(
'⚠️ [NotificationService] Not initialized, skipping notification',
);
return;
}
if (!_permissionGranted) {
debugPrint(
'⚠️ [NotificationService] Permission not granted, skipping notification',
);
return;
}
try {
// Generate unique notification ID based on timestamp
final notificationId =
_messageNotificationId +
(DateTime.now().millisecondsSinceEpoch % 1000);
// Build notification title and body
final title = isChannelMessage
? (localizations != null
? '${localizations.channel}: ${channelName ?? "Public"}'
: 'Channel: ${channelName ?? "Public"}')
: (localizations != null
? '${localizations.newMessage} ${localizations.from} $senderName'
: 'New message from $senderName');
final body = messageText.length > 200
? '${messageText.substring(0, 200)}...'
: messageText;
// Android notification details
final androidDetails = AndroidNotificationDetails(
_messagesChannelId,
_messagesChannelName,
channelDescription: _messagesChannelDescription,
importance: Importance.high,
priority: Priority.high,
ticker: title,
playSound: true,
enableVibration: true,
showWhen: true,
when: DateTime.now().millisecondsSinceEpoch,
styleInformation: BigTextStyleInformation(
body,
contentTitle: title,
summaryText: senderName,
),
);
// iOS notification details
final darwinDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
sound: 'default',
threadIdentifier: isChannelMessage
? 'channel_messages'
: 'direct_messages',
subtitle: senderName,
);
// Combined notification details
final notificationDetails = NotificationDetails(
android: androidDetails,
iOS: darwinDetails,
);
// Show notification
await _notificationsPlugin.show(
notificationId,
title,
body,
notificationDetails,
payload: 'message:${isChannelMessage ? "channel" : "contact"}',
);
debugPrint('✅ [NotificationService] Showed message notification');
debugPrint(' Sender: $senderName');
debugPrint(' Type: ${isChannelMessage ? "Channel" : "Direct"}');
} catch (e) {
debugPrint(
'❌ [NotificationService] Error showing message notification: $e',
);
}
}
/// Cancel all notifications
Future<void> cancelAll() async {
try {
await _notificationsPlugin.cancelAll();
debugPrint('✅ [NotificationService] Cancelled all notifications');
} catch (e) {
debugPrint('❌ [NotificationService] Error canceling notifications: $e');
}
}
/// Cancel specific notification
Future<void> cancel(int id) async {
try {
await _notificationsPlugin.cancel(id);
debugPrint('✅ [NotificationService] Cancelled notification: $id');
} catch (e) {
debugPrint('❌ [NotificationService] Error canceling notification: $e');
}
}
/// Check if notifications are enabled
Future<bool> areNotificationsEnabled() async {
try {
final androidPlugin = _notificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidPlugin != null) {
final enabled = await androidPlugin.areNotificationsEnabled();
return enabled ?? false;
}
// For iOS, assume enabled if permission was granted
return _permissionGranted;
} catch (e) {
debugPrint(
'⚠️ [NotificationService] Error checking notification status: $e',
);
return false;
}
}
/// Get pending notifications
Future<List<PendingNotificationRequest>> getPendingNotifications() async {
try {
return await _notificationsPlugin.pendingNotificationRequests();
} catch (e) {
debugPrint(
'⚠️ [NotificationService] Error getting pending notifications: $e',
);
return [];
}
}
/// Show notification for available app update
Future<void> showUpdateNotification({
required String currentVersion,
required String latestVersion,
required String downloadUrl,
AppLocalizations? localizations,
}) async {
if (!_isInitialized) {
debugPrint(
'⚠️ [NotificationService] Not initialized, skipping notification',
);
return;
}
if (!_permissionGranted) {
debugPrint(
'⚠️ [NotificationService] Permission not granted, skipping notification',
);
return;
}
try {
// Build notification title and body
final title = localizations?.updateAvailable ?? 'App Update Available';
final body = localizations != null
? '${localizations.currentVersion}: $currentVersion\n'
'${localizations.latestVersion}: $latestVersion'
: 'Current: $currentVersion\nLatest: $latestVersion';
// Android notification details
final androidDetails = AndroidNotificationDetails(
_updateChannelId,
_updateChannelName,
channelDescription: _updateChannelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
ticker: title,
playSound: false,
enableVibration: false,
showWhen: true,
when: DateTime.now().millisecondsSinceEpoch,
icon: '@mipmap/ic_launcher',
color: const Color(0xFF2196F3), // Blue
colorized: true,
category: AndroidNotificationCategory.recommendation,
styleInformation: BigTextStyleInformation(
body,
contentTitle: title,
summaryText: localizations?.downloadUpdate ?? 'Tap to download',
),
// Make notification ongoing so it doesn't get dismissed easily
ongoing: false,
autoCancel: true,
);
// iOS notification details
final darwinDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: false,
threadIdentifier: 'app_updates',
categoryIdentifier: 'APP_UPDATE',
subtitle: 'New version: $latestVersion',
);
// Combined notification details
final notificationDetails = NotificationDetails(
android: androidDetails,
iOS: darwinDetails,
);
// Show notification
await _notificationsPlugin.show(
_updateNotificationId,
title,
body,
notificationDetails,
payload: 'update:$downloadUrl',
);
debugPrint('✅ [NotificationService] Showed update notification');
debugPrint(' Current: $currentVersion');
debugPrint(' Latest: $latestVersion');
debugPrint(' Download URL: $downloadUrl');
} catch (e) {
debugPrint(
'❌ [NotificationService] Error showing update notification: $e',
);
}
}
}

View File

@@ -0,0 +1,292 @@
import 'dart:convert';
import 'dart:typed_data';
import '../../models/contact.dart';
import '../buffer_writer.dart';
import '../meshcore_constants.dart';
/// Builds outgoing BLE frames for the MeshCore device
class FrameBuilder {
/// Build DeviceQuery command
static Uint8List buildDeviceQuery() {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdDeviceQuery);
writer.writeByte(MeshCoreConstants.supportedCompanionProtocolVersion);
return writer.toBytes();
}
/// Build AppStart command
static Uint8List buildAppStart() {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdAppStart);
writer.writeByte(1); // appVer
writer.writeBytes(Uint8List(6)); // reserved
writer.writeString('MeshCore SAR'); // appName
return writer.toBytes();
}
/// Build GetContacts command
static Uint8List buildGetContacts() {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdGetContacts);
return writer.toBytes();
}
/// Build GetContactByKey command - retrieves a single contact by public key
static Uint8List buildGetContactByKey(Uint8List publicKey) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdGetContactByKey); // 0x1E (30)
writer.writeBytes(publicKey); // 32 bytes
return writer.toBytes();
}
/// Build AddUpdateContact command
static Uint8List buildAddUpdateContact(Contact contact) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdAddUpdateContact); // 0x09
writer.writeBytes(contact.publicKey); // 32 bytes
writer.writeByte(contact.type.value); // ADV_TYPE_*
writer.writeByte(contact.flags); // flags
writer.writeInt8(contact.outPathLen); // path length (signed byte)
writer.writeBytes(contact.outPath); // 64 bytes
// Write name as null-terminated string in 32-byte field
final nameBytes = Uint8List(32);
final encoded = utf8.encode(contact.advName);
final copyLen = encoded.length > 31 ? 31 : encoded.length;
nameBytes.setRange(0, copyLen, encoded);
writer.writeBytes(nameBytes);
writer.writeUInt32LE(contact.lastAdvert); // timestamp
writer.writeInt32LE(contact.advLat); // latitude * 1E6
writer.writeInt32LE(contact.advLon); // longitude * 1E6
return writer.toBytes();
}
/// Build SendTxtMsg command
static Uint8List buildSendTxtMsg({
required Uint8List contactPublicKey,
required String text,
int textType = 0,
int attempt = 0,
}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendTxtMsg); // 0x02
writer.writeByte(textType); // TXT_TYPE_*
writer.writeByte(attempt); // 0-3
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
writer.writeBytes(contactPublicKey.sublist(0, 6));
writer.writeString(text);
return writer.toBytes();
}
/// Build SendChannelTxtMsg command
static Uint8List buildSendChannelTxtMsg({
required int channelIdx,
required String text,
int textType = 0,
}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendChannelTxtMsg); // 0x03
writer.writeByte(textType); // TXT_TYPE_*
writer.writeByte(channelIdx); // 0 for 'public' channel
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
writer.writeString(text);
return writer.toBytes();
}
/// Build SendTelemetryReq command
/// Requests telemetry (GPS, battery) from a contact
static Uint8List buildSendTelemetryReq(Uint8List contactPublicKey, {bool zeroHop = false}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendTelemetryReq);
writer.writeByte(zeroHop ? 0 : 255);
writer.writeByte(0); // reserved
writer.writeByte(0); // reserved
writer.writeBytes(contactPublicKey);
return writer.toBytes();
}
/// Build SendBinaryReq command
static Uint8List buildSendBinaryReq({
required Uint8List contactPublicKey,
required Uint8List requestData,
}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendBinaryReq); // 0x32 (50)
writer.writeBytes(contactPublicKey); // 32 bytes
writer.writeBytes(requestData); // request code + params
return writer.toBytes();
}
/// Build GetBatteryVoltage command
static Uint8List buildGetBatteryAndStorage() {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdGetBatteryVoltage);
return writer.toBytes();
}
/// Build SyncNextMessage command
static Uint8List buildSyncNextMessage() {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSyncNextMessage);
return writer.toBytes();
}
/// Build GetDeviceTime command
static Uint8List buildGetDeviceTime() {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdGetDeviceTime);
return writer.toBytes();
}
/// Build SetDeviceTime command
static Uint8List buildSetDeviceTime() {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSetDeviceTime);
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
return writer.toBytes();
}
/// Build SendSelfAdvert command
static Uint8List buildSendSelfAdvert({bool floodMode = true}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendSelfAdvert);
writer.writeByte(floodMode ? MeshCoreConstants.selfAdvertFlood : MeshCoreConstants.selfAdvertZeroHop);
return writer.toBytes();
}
/// Build SetAdvertName command
static Uint8List buildSetAdvertName(String name) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSetAdvertName);
writer.writeString(name);
return writer.toBytes();
}
/// Build SetAdvertLatLon command
static Uint8List buildSetAdvertLatLon({
required double latitude,
required double longitude,
}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSetAdvertLatLon);
writer.writeInt32LE((latitude * 1000000).round());
writer.writeInt32LE((longitude * 1000000).round());
return writer.toBytes();
}
/// Build SetRadioParams command
static Uint8List buildSetRadioParams({
required int frequency,
required int bandwidth,
required int spreadingFactor,
required int codingRate,
}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSetRadioParams);
writer.writeUInt32LE(frequency);
writer.writeUInt16LE(bandwidth);
writer.writeByte(spreadingFactor);
writer.writeByte(codingRate);
return writer.toBytes();
}
/// Build SetTxPower command
static Uint8List buildSetTxPower(int powerDbm) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSetTxPower);
writer.writeByte(powerDbm);
return writer.toBytes();
}
/// Build SetOtherParams command
static Uint8List buildSetOtherParams({
required int manualAddContacts,
required int telemetryModes,
required int advertLocationPolicy,
int multiAcks = 0,
}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSetOtherParams);
writer.writeByte(manualAddContacts);
writer.writeByte(telemetryModes);
writer.writeByte(advertLocationPolicy);
writer.writeByte(multiAcks);
return writer.toBytes();
}
/// Build SendLogin command
static Uint8List buildSendLogin({
required Uint8List roomPublicKey,
required String password,
}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendLogin); // 0x1A
writer.writeBytes(roomPublicKey); // 32 bytes
writer.writeString(password); // Max 15 bytes, null-terminated
return writer.toBytes();
}
/// Build SendStatusReq command
static Uint8List buildSendStatusReq(Uint8List contactPublicKey) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendStatusReq); // 0x1B
writer.writeBytes(contactPublicKey); // 32 bytes
return writer.toBytes();
}
/// Build ResetPath command - clears learned path for a contact
static Uint8List buildResetPath(Uint8List contactPublicKey) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdResetPath); // 0x0D (13)
writer.writeBytes(contactPublicKey); // 32 bytes
return writer.toBytes();
}
/// Build RemoveContact command - removes a contact from the device
static Uint8List buildRemoveContact(Uint8List contactPublicKey) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdRemoveContact); // 0x0F (15)
writer.writeBytes(contactPublicKey); // 32 bytes
return writer.toBytes();
}
/// Build GetChannel command - retrieves information for a specific channel
static Uint8List buildGetChannel(int channelIdx) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdGetChannel); // 0x1F (31)
writer.writeByte(channelIdx); // 0-39 typically
return writer.toBytes();
}
/// Build SetChannel command - sets the name and secret for a specific channel
///
/// Format: [cmd(1)][channel_idx(1)][name(32)][secret(16)]
/// Secret must be exactly 16 bytes (128-bit key)
static Uint8List buildSetChannel({
required int channelIdx,
required String channelName,
required List<int> secret,
}) {
if (secret.length != 16) {
throw ArgumentError('Channel secret must be exactly 16 bytes (got ${secret.length})');
}
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSetChannel); // 0x20 (32)
writer.writeByte(channelIdx); // 0-39 typically
// Write channel name as null-terminated string in 32-byte field
final nameBytes = Uint8List(32);
final encoded = utf8.encode(channelName);
final copyLen = encoded.length > 31 ? 31 : encoded.length;
nameBytes.setRange(0, copyLen, encoded);
writer.writeBytes(nameBytes);
// Write 16-byte secret
writer.writeBytes(Uint8List.fromList(secret));
return writer.toBytes();
}
}

View File

@@ -0,0 +1,436 @@
import 'dart:convert';
import 'dart:typed_data';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../buffer_reader.dart';
import '../meshcore_constants.dart';
/// Parses incoming BLE frames from the MeshCore device
class FrameParser {
/// Parse ContactsStart response
static int parseContactsStart(BufferReader reader) {
return reader.readUInt32LE();
}
/// Parse Contact response
static Contact parseContact(BufferReader reader) {
final publicKey = reader.readBytes(32);
final typeByte = reader.readByte();
final type = ContactType.fromValue(typeByte);
final flags = reader.readByte();
final outPathLen = reader.readInt8();
final outPath = reader.readBytes(64);
final advName = reader.readCString(32);
final lastAdvert = reader.readUInt32LE();
final advLat = reader.readInt32LE();
final advLon = reader.readInt32LE();
final lastMod = reader.readUInt32LE();
return Contact(
publicKey: publicKey,
type: type,
flags: flags,
outPathLen: outPathLen,
outPath: outPath,
advName: advName,
lastAdvert: lastAdvert,
advLat: advLat,
advLon: advLon,
lastMod: lastMod,
);
}
/// Parse Sent confirmation response
static Map<String, dynamic> parseSentConfirmation(BufferReader reader) {
if (reader.remainingBytesCount >= 9) {
final sendType = reader.readByte();
final isFloodMode = sendType == 1;
final expectedAckOrTagBytes = reader.readBytes(4);
final expectedAckTag = ByteData.sublistView(Uint8List.fromList(expectedAckOrTagBytes))
.getUint32(0, Endian.little);
final suggestedTimeout = reader.readUInt32LE();
return {
'expectedAckTag': expectedAckTag,
'suggestedTimeout': suggestedTimeout,
'isFloodMode': isFloodMode,
};
}
return {};
}
/// Parse ContactMessage response
static Message parseContactMessage(BufferReader reader) {
final pubKeyPrefix = reader.readBytes(6);
final pathLen = reader.readByte();
final txtTypeByte = reader.readByte();
final txtType = MessageTextType.fromValue(txtTypeByte);
final senderTimestamp = reader.readUInt32LE();
String text;
if (txtType == MessageTextType.signedPlain) {
// Signed message format: [4-byte sender prefix][UTF-8 text]
if (reader.remainingBytesCount >= 4) {
reader.readBytes(4); // Skip extra sender prefix
text = reader.hasRemaining ? reader.readString() : '';
} else {
text = reader.readString();
}
} else {
text = reader.readString();
}
return Message(
id: '${DateTime.now().millisecondsSinceEpoch}_${pubKeyPrefix.map((b) => b.toRadixString(16)).join()}',
messageType: MessageType.contact,
senderPublicKeyPrefix: pubKeyPrefix,
pathLen: pathLen,
textType: txtType,
senderTimestamp: senderTimestamp,
text: text,
receivedAt: DateTime.now(),
);
}
/// Parse ChannelMessage response
static Message parseChannelMessage(BufferReader reader) {
final channelIdx = reader.readByte(); // unsigned 0-255, not signed
final pathLen = reader.readByte();
final txtTypeByte = reader.readByte();
final txtType = MessageTextType.fromValue(txtTypeByte);
final senderTimestamp = reader.readUInt32LE();
String text;
if (txtType == MessageTextType.signedPlain) {
if (reader.remainingBytesCount >= 4) {
reader.readBytes(4); // Skip extra sender prefix
text = reader.hasRemaining ? reader.readString() : '';
} else {
text = reader.readString();
}
} else {
text = reader.readString();
}
// Parse sender name from channel message format: "<sender_name>: <actual_message>"
String? senderName;
String actualMessage = text;
if (text.contains(': ')) {
final colonIndex = text.indexOf(': ');
senderName = text.substring(0, colonIndex);
actualMessage = text.substring(colonIndex + 2); // Skip ": "
}
return Message(
id: '${DateTime.now().millisecondsSinceEpoch}_ch$channelIdx',
messageType: MessageType.channel,
channelIdx: channelIdx,
pathLen: pathLen,
textType: txtType,
senderTimestamp: senderTimestamp,
text: actualMessage, // Store the actual message without sender prefix
senderName: senderName, // Store extracted sender name
receivedAt: DateTime.now(),
);
}
/// Parse TelemetryResponse push
static Map<String, dynamic> parseTelemetryResponse(BufferReader reader) {
reader.readByte(); // reserved
final pubKeyPrefix = reader.readBytes(6);
final lppSensorData = reader.readRemainingBytes();
return {
'publicKeyPrefix': pubKeyPrefix,
'lppSensorData': lppSensorData,
};
}
/// Parse BinaryResponse push
static Map<String, dynamic> parseBinaryResponse(BufferReader reader) {
reader.readByte(); // reserved
final tag = reader.readUInt32LE();
final responseData = reader.readRemainingBytes();
return {
'publicKeyPrefix': Uint8List(6), // Empty prefix
'tag': tag,
'responseData': responseData,
};
}
/// Parse DeviceInfo response
static Map<String, dynamic> parseDeviceInfo(BufferReader reader) {
if (reader.remainingBytesCount < 1) {
return {};
}
final firmwareVersion = reader.readByte();
int? maxContacts;
int? maxChannels;
int? blePin;
if (reader.remainingBytesCount >= 6) {
final maxContactsDiv2 = reader.readByte();
maxContacts = maxContactsDiv2 * 2;
maxChannels = reader.readByte();
blePin = reader.readUInt32LE();
}
String? firmwareBuildDate;
if (reader.remainingBytesCount >= 12) {
final buildDateBytes = reader.readBytes(12);
firmwareBuildDate =
String.fromCharCodes(buildDateBytes.takeWhile((b) => b != 0));
}
String? manufacturerModel;
if (reader.remainingBytesCount >= 40) {
final modelBytes = reader.readBytes(40);
manufacturerModel =
String.fromCharCodes(modelBytes.takeWhile((b) => b != 0));
}
String? semanticVersion;
if (reader.remainingBytesCount >= 20) {
final versionBytes = reader.readBytes(20);
semanticVersion =
String.fromCharCodes(versionBytes.takeWhile((b) => b != 0));
}
return {
'firmwareVersion': firmwareVersion,
'maxContacts': maxContacts,
'maxChannels': maxChannels,
'blePin': blePin,
'firmwareBuildDate': firmwareBuildDate,
'manufacturerModel': manufacturerModel,
'semanticVersion': semanticVersion,
};
}
/// Parse SelfInfo response
static Map<String, dynamic> parseSelfInfo(BufferReader reader) {
if (reader.remainingBytesCount < 54) {
reader.readRemainingBytes();
return {};
}
final deviceType = reader.readByte();
final txPower = reader.readByte();
final maxTxPower = reader.readByte();
final publicKey = reader.readBytes(32);
final advLatBytes = reader.readBytes(4);
final advLat = ByteData.sublistView(Uint8List.fromList(advLatBytes))
.getInt32(0, Endian.little);
final advLonBytes = reader.readBytes(4);
final advLon = ByteData.sublistView(Uint8List.fromList(advLonBytes))
.getInt32(0, Endian.little);
reader.readByte(); // multiAcks (reserved for future use)
reader.readByte(); // advertLocPolicy (reserved for future use)
reader.readByte(); // telemetryModes (reserved for future use)
final manualAddContacts = reader.readByte();
final radioFreqBytes = reader.readBytes(4);
final radioFreq = ByteData.sublistView(Uint8List.fromList(radioFreqBytes))
.getUint32(0, Endian.little);
final radioBwBytes = reader.readBytes(4);
final radioBw = ByteData.sublistView(Uint8List.fromList(radioBwBytes))
.getUint32(0, Endian.little);
final radioSf = reader.readByte();
final radioCr = reader.readByte();
String? selfName;
if (reader.hasRemaining) {
final nameBytes = reader.readRemainingBytes();
selfName = utf8.decode(nameBytes.takeWhile((b) => b != 0).toList());
}
return {
'deviceType': deviceType,
'txPower': txPower,
'maxTxPower': maxTxPower,
'publicKey': publicKey,
'advLat': advLat,
'advLon': advLon,
'manualAddContacts': manualAddContacts == 1,
'radioFreq': radioFreq,
'radioBw': radioBw,
'radioSf': radioSf,
'radioCr': radioCr,
'selfName': selfName,
};
}
/// Parse Advert push
static Uint8List? parseAdvert(BufferReader reader) {
if (reader.remainingBytesCount >= 32) {
return reader.readBytes(32);
}
return null;
}
/// Parse PathUpdated push
static Uint8List? parsePathUpdated(BufferReader reader) {
if (reader.remainingBytesCount >= 32) {
return reader.readBytes(32);
}
return null;
}
/// Parse SendConfirmed push
static Map<String, dynamic> parseSendConfirmed(BufferReader reader) {
if (reader.remainingBytesCount >= 8) {
final ackCodeBytes = reader.readBytes(4);
final ackCode = ByteData.sublistView(Uint8List.fromList(ackCodeBytes))
.getUint32(0, Endian.little);
final roundTripTime = reader.readUInt32LE();
return {
'ackCode': ackCode,
'roundTripTime': roundTripTime,
};
}
return {};
}
/// Parse LoginSuccess push
static Map<String, dynamic> parseLoginSuccess(BufferReader reader) {
if (reader.remainingBytesCount >= 11) {
final permissions = reader.readByte();
final isAdmin = (permissions & 0x01) != 0;
final publicKeyPrefix = reader.readBytes(6);
final tag = reader.readInt32LE();
int? newPermissions;
if (reader.hasRemaining) {
newPermissions = reader.readByte();
}
return {
'publicKeyPrefix': publicKeyPrefix,
'permissions': permissions,
'isAdmin': isAdmin,
'tag': tag,
'newPermissions': newPermissions,
};
}
return {};
}
/// Parse LoginFail push
static Uint8List? parseLoginFail(BufferReader reader) {
if (reader.remainingBytesCount >= 7) {
reader.readByte(); // reserved
return reader.readBytes(6);
}
return null;
}
/// Parse StatusResponse push
static Map<String, dynamic> parseStatusResponse(BufferReader reader) {
if (reader.remainingBytesCount >= 7) {
reader.readByte(); // reserved
final publicKeyPrefix = reader.readBytes(6);
final statusData = reader.readRemainingBytes();
return {
'publicKeyPrefix': publicKeyPrefix,
'statusData': statusData,
};
}
return {};
}
/// Parse CurrentTime response
static int? parseCurrentTime(BufferReader reader) {
if (reader.remainingBytesCount >= 4) {
return reader.readUInt32LE();
}
return null;
}
/// Parse BatteryAndStorage response
static Map<String, dynamic> parseBatteryAndStorage(BufferReader reader) {
if (reader.remainingBytesCount >= 2) {
final millivolts = reader.readUInt16LE();
int? usedKb;
int? totalKb;
if (reader.remainingBytesCount >= 8) {
usedKb = reader.readUInt32LE();
totalKb = reader.readUInt32LE();
} else if (reader.remainingBytesCount >= 4) {
usedKb = reader.readUInt32LE();
}
return {
'millivolts': millivolts,
'usedKb': usedKb,
'totalKb': totalKb,
};
}
return {};
}
/// Parse Error response
static int? parseError(BufferReader reader) {
if (reader.hasRemaining) {
return reader.readByte();
}
return null;
}
/// Parse ChannelInfo response
static Map<String, dynamic> parseChannelInfo(BufferReader reader) {
// Format: [channel_idx(1)][name(32)][secret(16)][flags(1)?]
// Minimum: 1 + 32 + 16 = 49 bytes (flags is optional)
if (reader.remainingBytesCount < 49) {
return {};
}
final channelIdx = reader.readByte();
final channelName = reader.readCString(32);
final secret = reader.readBytes(16);
// Flags field is optional (some firmware versions don't include it)
int? flags;
if (reader.remainingBytesCount >= 1) {
flags = reader.readByte();
}
return {
'channelIdx': channelIdx,
'channelName': channelName,
'secret': secret,
'flags': flags,
};
}
/// Get error message from error code
static String getErrorMessage(int errorCode) {
switch (errorCode) {
case MeshCoreConstants.errUnsupportedCmd:
return 'Unsupported command';
case MeshCoreConstants.errNotFound:
return 'Not found';
case MeshCoreConstants.errTableFull:
return 'Table full';
case MeshCoreConstants.errBadState:
return 'Bad state';
case MeshCoreConstants.errFileIoError:
return 'File I/O error';
case MeshCoreConstants.errIllegalArg:
return 'Illegal argument';
default:
return 'Error code: $errorCode';
}
}
}

View File

@@ -0,0 +1,244 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/sar_template.dart';
import '../utils/sar_message_parser.dart';
/// SAR Template Service - Manages SAR templates with persistence
class SarTemplateService extends ChangeNotifier {
static final SarTemplateService _instance = SarTemplateService._internal();
factory SarTemplateService() => _instance;
SarTemplateService._internal();
static const String _storageKey = 'sar_templates';
List<SarTemplate> _templates = [];
bool _initialized = false;
/// Get all templates
List<SarTemplate> get templates => List.unmodifiable(_templates);
/// Get default templates
List<SarTemplate> get defaultTemplates =>
_templates.where((t) => t.isDefault).toList();
/// Get custom templates
List<SarTemplate> get customTemplates =>
_templates.where((t) => !t.isDefault).toList();
/// Check if initialized
bool get isInitialized => _initialized;
/// Initialize service and load templates
Future<void> initialize() async {
if (_initialized) return;
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_storageKey);
if (jsonString != null && jsonString.isNotEmpty) {
// Load saved templates
final List<dynamic> jsonList = json.decode(jsonString);
_templates = jsonList.map((json) => SarTemplate.fromJson(json)).toList();
// Ensure defaults exist (in case user deleted them or version upgrade)
_ensureDefaultTemplates();
} else {
// First time - initialize with defaults
_templates = SarTemplate.defaults;
await _saveToStorage();
}
_initialized = true;
notifyListeners();
debugPrint('SarTemplateService initialized with ${_templates.length} templates');
} catch (e) {
debugPrint('Error initializing SAR templates: $e');
// Fallback to defaults on error
_templates = SarTemplate.defaults;
_initialized = true;
notifyListeners();
}
}
/// Ensure default templates exist
void _ensureDefaultTemplates() {
final defaults = SarTemplate.defaults;
final existingDefaultIds = _templates.where((t) => t.isDefault).map((t) => t.id).toSet();
// Add missing defaults
for (final defaultTemplate in defaults) {
if (!existingDefaultIds.contains(defaultTemplate.id)) {
_templates.insert(0, defaultTemplate);
}
}
}
/// Save templates to storage
Future<void> _saveToStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonList = _templates.map((t) => t.toJson()).toList();
final jsonString = json.encode(jsonList);
await prefs.setString(_storageKey, jsonString);
debugPrint('Saved ${_templates.length} SAR templates to storage');
} catch (e) {
debugPrint('Error saving SAR templates: $e');
rethrow;
}
}
/// Add new template
Future<void> addTemplate(SarTemplate template) async {
_templates.add(template);
await _saveToStorage();
notifyListeners();
debugPrint('Added SAR template: ${template.name}');
}
/// Update existing template
Future<void> updateTemplate(String id, SarTemplate updatedTemplate) async {
final index = _templates.indexWhere((t) => t.id == id);
if (index != -1) {
_templates[index] = updatedTemplate;
await _saveToStorage();
notifyListeners();
debugPrint('Updated SAR template: ${updatedTemplate.name}');
} else {
throw Exception('Template with id $id not found');
}
}
/// Delete template
Future<void> deleteTemplate(String id) async {
final template = _templates.firstWhere((t) => t.id == id);
_templates.removeWhere((t) => t.id == id);
await _saveToStorage();
notifyListeners();
debugPrint('Deleted SAR template: ${template.name}');
}
/// Get template by ID
SarTemplate? getTemplateById(String id) {
try {
return _templates.firstWhere((t) => t.id == id);
} catch (e) {
return null;
}
}
/// Import templates from clipboard
/// Expects SAR message format (one per line):
/// S:🧑:0,0:Person found
/// S:🔥:0,0:Active fire
Future<int> importFromClipboard() async {
try {
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
if (clipboardData == null || clipboardData.text == null || clipboardData.text!.trim().isEmpty) {
throw Exception('Clipboard is empty');
}
return importFromText(clipboardData.text!);
} catch (e) {
debugPrint('Error importing from clipboard: $e');
rethrow;
}
}
/// Import templates from text (SAR message format)
Future<int> importFromText(String text) async {
try {
final lines = text.split('\n').where((line) => line.trim().isNotEmpty).toList();
int importedCount = 0;
final List<String> errors = [];
for (final line in lines) {
final trimmed = line.trim();
if (!trimmed.startsWith('S:')) {
errors.add('Invalid format: $trimmed');
continue;
}
// Validate with parser
if (!SarMessageParser.isValidFormat(trimmed)) {
final error = SarMessageParser.getFormatError(trimmed);
errors.add(error ?? 'Invalid SAR message format');
continue;
}
try {
final template = SarTemplate.fromSarMessage(trimmed);
// Check for duplicates (same emoji + name)
final isDuplicate = _templates.any((t) =>
t.emoji == template.emoji && t.name == template.name
);
if (!isDuplicate) {
_templates.add(template);
importedCount++;
}
} catch (e) {
errors.add('Error parsing line: $trimmed - $e');
}
}
if (importedCount > 0) {
await _saveToStorage();
notifyListeners();
}
if (errors.isNotEmpty) {
debugPrint('Import errors: ${errors.join(', ')}');
}
debugPrint('Imported $importedCount SAR templates');
return importedCount;
} catch (e) {
debugPrint('Error importing templates: $e');
rethrow;
}
}
/// Export all templates to clipboard (SAR message format)
Future<void> exportToClipboard() async {
try {
final sarMessages = _templates.map((t) => t.toSarMessage()).join('\n');
await Clipboard.setData(ClipboardData(text: sarMessages));
debugPrint('Exported ${_templates.length} templates to clipboard');
} catch (e) {
debugPrint('Error exporting to clipboard: $e');
rethrow;
}
}
/// Export templates to text (SAR message format)
String exportToText() {
return _templates.map((t) => t.toSarMessage()).join('\n');
}
/// Reset to default templates
Future<void> resetToDefaults() async {
_templates = SarTemplate.defaults;
await _saveToStorage();
notifyListeners();
debugPrint('Reset to default SAR templates');
}
/// Clear all templates (including defaults)
Future<void> clearAll() async {
_templates.clear();
await _saveToStorage();
notifyListeners();
debugPrint('Cleared all SAR templates');
}
/// Get count of templates
int get templateCount => _templates.length;
/// Check if template exists
bool hasTemplate(String id) {
return _templates.any((t) => t.id == id);
}
}

View File

@@ -0,0 +1,625 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io' as io;
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart' as io_client;
import '../models/message.dart';
import '../models/contact.dart';
import 'package:latlong2/latlong.dart';
/// SSE Client Service
///
/// Connects to a remote SSE server to receive messages and contacts in real-time.
/// This enables multiple app instances to share a single MeshCore BLE device
/// without direct BLE connections.
class SseClientService {
String? _serverUrl;
String? _authToken;
http.Client? _httpClient;
StreamSubscription? _messageSubscription;
StreamSubscription? _contactSubscription;
bool _isConnected = false;
bool _isConnecting = false;
bool _hasConnectedBefore = false; // Track if we've ever successfully connected
Timer? _reconnectTimer;
Timer? _heartbeatTimer;
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 10;
static const Duration _reconnectDelay = Duration(seconds: 5);
/// Callback for when a message is received
Function(Message)? onMessageReceived;
/// Callback for when a contact is received
Function(Contact)? onContactReceived;
/// Callback for connection state changes
Function(bool isConnected)? onConnectionStateChanged;
/// Callback for errors
Function(String error)? onError;
/// Check if client is connected
bool get isConnected => _isConnected;
/// Check if client is currently connecting
bool get isConnecting => _isConnecting;
/// Get current reconnection attempt number
int get reconnectionAttempts => _reconnectAttempts;
/// Get maximum reconnection attempts
int get maxReconnectionAttempts => _maxReconnectAttempts;
/// Get server URL
String? get serverUrl => _serverUrl;
/// Connect to SSE server
Future<void> connect({
required String serverUrl,
String? authToken,
}) async {
if (_isConnected) {
debugPrint('⚠️ [SseClient] Already connected');
return;
}
_serverUrl = serverUrl;
_authToken = authToken;
_isConnecting = true;
debugPrint('🔌 [SseClient] Connecting to $serverUrl (attempt ${_reconnectAttempts + 1}/$_maxReconnectAttempts)');
try {
// Create a new HTTP client with custom configuration for SSE streaming
// Using IOClient with custom HttpClient for better control over connection settings
final ioHttpClient = io.HttpClient();
ioHttpClient.connectionTimeout = const Duration(seconds: 10);
ioHttpClient.idleTimeout = const Duration(hours: 1); // Keep SSE connections alive
_httpClient = io_client.IOClient(ioHttpClient);
// Test server availability
await _checkServerStatus();
// Fetch initial message history
await _fetchMessageHistory();
// Fetch initial contact list
await _fetchContacts();
// Subscribe to SSE streams
debugPrint('🔗 [SseClient] Subscribing to message stream...');
debugPrint('🔗 [SseClient] Using HTTP client type: ${_httpClient.runtimeType}');
await _subscribeToMessages();
debugPrint('🔗 [SseClient] Subscribing to contact stream...');
await _subscribeToContacts();
debugPrint('🔗 [SseClient] All subscriptions complete');
_isConnected = true;
_isConnecting = false;
_hasConnectedBefore = true; // Mark that we've successfully connected
_reconnectAttempts = 0;
debugPrint('🔔 [SseClient] Calling onConnectionStateChanged(true)');
onConnectionStateChanged?.call(true);
// Start heartbeat to detect connection loss
_startHeartbeat();
debugPrint('✅ [SseClient] Connected successfully');
} catch (e) {
_isConnecting = false;
_httpClient?.close();
_httpClient = null;
debugPrint('❌ [SseClient] Connection failed: $e');
onError?.call('Connection failed: $e');
// Only auto-reconnect if we've successfully connected before
// Initial connection failures should be handled by the user
if (_hasConnectedBefore) {
_scheduleReconnect();
}
}
}
/// Disconnect from SSE server
Future<void> disconnect() async {
debugPrint('🔌 [SseClient] Disconnecting...');
_isConnected = false;
_isConnecting = false;
_hasConnectedBefore = false; // Reset on manual disconnect
_reconnectTimer?.cancel();
_heartbeatTimer?.cancel();
await _messageSubscription?.cancel();
await _contactSubscription?.cancel();
_httpClient?.close();
_serverUrl = null;
_authToken = null;
_httpClient = null;
onConnectionStateChanged?.call(false);
debugPrint('✅ [SseClient] Disconnected');
}
/// Check server status
Future<void> _checkServerStatus() async {
final url = Uri.parse('$_serverUrl/api/status');
try {
final response = await http.get(url, headers: _getHeaders()).timeout(
const Duration(seconds: 5),
);
if (response.statusCode != 200) {
throw Exception('Server returned ${response.statusCode}');
}
final data = jsonDecode(response.body);
debugPrint('📊 [SseClient] Server status: ${data['status']}');
debugPrint(' Connected clients: ${data['connectedClients']}');
debugPrint(' Messages: ${data['messageCount']}');
debugPrint(' Contacts: ${data['contactCount']}');
} catch (e) {
// Wrap the error with more user-friendly message
throw Exception(_formatConnectionError(e));
}
}
/// Format connection error to be more user-friendly
String _formatConnectionError(dynamic error) {
final errorStr = error.toString();
// Extract the actual server URL being connected to
final serverUri = Uri.tryParse(_serverUrl ?? '');
final host = serverUri?.host ?? 'unknown';
final port = serverUri?.port ?? 0;
if (errorStr.contains('Connection refused')) {
return 'Server not available at $host:$port. The server may be offline or not running.';
} else if (errorStr.contains('TimeoutException') || errorStr.contains('timed out')) {
return 'Connection to $host:$port timed out. Check your network connection.';
} else if (errorStr.contains('SocketException')) {
return 'Network error connecting to $host:$port. Check your network connection.';
} else if (errorStr.contains('Failed host lookup')) {
return 'Could not resolve hostname: $host';
}
// Return the original error if we can't make it more user-friendly
return errorStr;
}
/// Fetch message history on connect
Future<void> _fetchMessageHistory() async {
try {
final url = Uri.parse('$_serverUrl/api/messages/history');
final response = await http.get(url, headers: _getHeaders()).timeout(
const Duration(seconds: 10),
);
if (response.statusCode != 200) {
throw Exception('Failed to fetch message history: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final messages = data['messages'] as List;
debugPrint('📥 [SseClient] Received ${messages.length} messages from history');
for (final msgJson in messages) {
try {
final message = _messageFromJson(msgJson);
onMessageReceived?.call(message);
} catch (e) {
debugPrint('⚠️ [SseClient] Failed to parse message: $e');
}
}
} catch (e) {
debugPrint('❌ [SseClient] Error fetching message history: $e');
// Don't throw - continue with connection even if history fetch fails
}
}
/// Fetch contacts on connect
Future<void> _fetchContacts() async {
try {
final url = Uri.parse('$_serverUrl/api/contacts');
final response = await http.get(url, headers: _getHeaders()).timeout(
const Duration(seconds: 10),
);
if (response.statusCode != 200) {
throw Exception('Failed to fetch contacts: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final contacts = data['contacts'] as List;
debugPrint('📥 [SseClient] Received ${contacts.length} contacts');
for (final contactJson in contacts) {
try {
final contact = _contactFromJson(contactJson);
onContactReceived?.call(contact);
} catch (e) {
debugPrint('⚠️ [SseClient] Failed to parse contact: $e');
}
}
} catch (e) {
debugPrint('❌ [SseClient] Error fetching contacts: $e');
// Don't throw - continue with connection even if contacts fetch fails
}
}
/// Subscribe to SSE message stream
Future<void> _subscribeToMessages() async {
try {
if (_httpClient == null) {
throw Exception('HTTP client not initialized');
}
debugPrint('📡 [SseClient] Creating message stream request...');
final url = Uri.parse('$_serverUrl/sse/messages');
final request = http.Request('GET', url);
request.headers.addAll(_getHeaders());
request.headers['Accept'] = 'text/event-stream';
request.headers['Cache-Control'] = 'no-cache';
debugPrint('📡 [SseClient] Sending message stream request to $url');
debugPrint('📡 [SseClient] Request headers: ${request.headers}');
final streamedResponse = await _httpClient!.send(request).timeout(
const Duration(seconds: 10),
onTimeout: () {
debugPrint('❌ [SseClient] Timeout waiting for response headers');
throw TimeoutException('Message stream connection timed out after 10 seconds');
},
);
debugPrint('📡 [SseClient] Received response with status: ${streamedResponse.statusCode}');
debugPrint('📡 [SseClient] Response headers: ${streamedResponse.headers}');
debugPrint('📡 [SseClient] Response content length: ${streamedResponse.contentLength}');
debugPrint('📡 [SseClient] Response is redirect: ${streamedResponse.isRedirect}');
if (streamedResponse.statusCode != 200) {
throw Exception('SSE messages subscription failed: ${streamedResponse.statusCode}');
}
debugPrint('📡 [SseClient] Message stream response received, status: ${streamedResponse.statusCode}');
debugPrint('📡 [SseClient] Setting up stream listener...');
_messageSubscription = streamedResponse.stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(
(line) {
debugPrint('📨 [SseClient] Received line: "$line"');
_handleSseLine(line, 'message');
},
onError: (error, stackTrace) {
debugPrint('❌ [SseClient] Message stream error: $error');
debugPrint(' Stack trace: $stackTrace');
_handleDisconnect();
},
onDone: () {
debugPrint('⚠️ [SseClient] Message stream closed (onDone called)');
_handleDisconnect();
},
cancelOnError: false,
);
debugPrint('✅ [SseClient] Message stream listener set up successfully');
} catch (e) {
debugPrint('❌ [SseClient] Error subscribing to message stream: $e');
rethrow;
}
}
/// Subscribe to SSE contact stream
Future<void> _subscribeToContacts() async {
try {
if (_httpClient == null) {
throw Exception('HTTP client not initialized');
}
debugPrint('📡 [SseClient] Creating contact stream request...');
final url = Uri.parse('$_serverUrl/sse/contacts');
final request = http.Request('GET', url);
request.headers.addAll(_getHeaders());
request.headers['Accept'] = 'text/event-stream';
request.headers['Cache-Control'] = 'no-cache';
debugPrint('📡 [SseClient] Sending contact stream request to $url');
final streamedResponse = await _httpClient!.send(request).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('Contact stream connection timed out after 10 seconds');
},
);
if (streamedResponse.statusCode != 200) {
throw Exception('SSE contacts subscription failed: ${streamedResponse.statusCode}');
}
debugPrint('📡 [SseClient] Contact stream response received, status: ${streamedResponse.statusCode}');
debugPrint('📡 [SseClient] Setting up contact stream listener...');
_contactSubscription = streamedResponse.stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(
(line) {
debugPrint('📨 [SseClient] Received contact line: "$line"');
_handleSseLine(line, 'contact');
},
onError: (error, stackTrace) {
debugPrint('❌ [SseClient] Contact stream error: $error');
debugPrint(' Stack trace: $stackTrace');
_handleDisconnect();
},
onDone: () {
debugPrint('⚠️ [SseClient] Contact stream closed (onDone called)');
_handleDisconnect();
},
cancelOnError: false,
);
debugPrint('✅ [SseClient] Contact stream listener set up successfully');
} catch (e) {
debugPrint('❌ [SseClient] Error subscribing to contact stream: $e');
rethrow;
}
}
/// Handle SSE line
String _eventType = '';
void _handleSseLine(String line, String streamType) {
if (line.isEmpty) {
// Event complete, reset
_eventType = '';
return;
}
if (line.startsWith('event:')) {
_eventType = line.substring(6).trim();
} else if (line.startsWith('data:')) {
final jsonData = line.substring(5).trim();
try {
final data = jsonDecode(jsonData) as Map<String, dynamic>;
if (streamType == 'message' && _eventType == 'message') {
final message = _messageFromJson(data);
onMessageReceived?.call(message);
} else if (streamType == 'contact' && _eventType == 'contact') {
final contact = _contactFromJson(data);
onContactReceived?.call(contact);
}
} catch (e) {
debugPrint('⚠️ [SseClient] Failed to parse SSE data: $e');
}
}
}
/// Handle disconnect
void _handleDisconnect() {
if (!_isConnected) return;
_isConnected = false;
onConnectionStateChanged?.call(false);
_scheduleReconnect();
}
/// Schedule reconnection attempt
void _scheduleReconnect() {
if (_reconnectAttempts >= _maxReconnectAttempts) {
debugPrint('❌ [SseClient] Max reconnection attempts reached');
onError?.call('Max reconnection attempts reached');
return;
}
_reconnectAttempts++;
final delay = _reconnectDelay * _reconnectAttempts;
debugPrint('🔄 [SseClient] Scheduling reconnect attempt $_reconnectAttempts in ${delay.inSeconds}s');
_reconnectTimer?.cancel();
_reconnectTimer = Timer(delay, () {
if (_serverUrl != null) {
connect(serverUrl: _serverUrl!, authToken: _authToken);
}
});
}
/// Start heartbeat to detect connection loss
void _startHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (timer) async {
try {
await _checkServerStatus();
} catch (e) {
debugPrint('⚠️ [SseClient] Heartbeat failed: $e');
_handleDisconnect();
}
});
}
/// Send message to server
Future<bool> sendMessage({
required String recipientPublicKey,
required String text,
}) async {
if (!_isConnected || _serverUrl == null) {
throw Exception('Not connected to server');
}
try {
final url = Uri.parse('$_serverUrl/api/messages');
final response = await http.post(
url,
headers: {
..._getHeaders(),
'Content-Type': 'application/json',
},
body: jsonEncode({
'recipientPublicKey': recipientPublicKey,
'text': text,
}),
).timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Send message failed: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return data['success'] as bool? ?? false;
} catch (e) {
debugPrint('❌ [SseClient] Error sending message: $e');
rethrow;
}
}
/// Send channel message to server
Future<void> sendChannelMessage({
required int channelIdx,
required String text,
}) async {
if (!_isConnected || _serverUrl == null) {
throw Exception('Not connected to server');
}
try {
final url = Uri.parse('$_serverUrl/api/messages/channel');
final response = await http.post(
url,
headers: {
..._getHeaders(),
'Content-Type': 'application/json',
},
body: jsonEncode({
'channelIdx': channelIdx,
'text': text,
}),
).timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Send channel message failed: ${response.statusCode}');
}
} catch (e) {
debugPrint('❌ [SseClient] Error sending channel message: $e');
rethrow;
}
}
/// Request contact sync
Future<void> syncContacts() async {
if (!_isConnected || _serverUrl == null) {
throw Exception('Not connected to server');
}
try {
final url = Uri.parse('$_serverUrl/api/contacts/sync');
final response = await http.post(
url,
headers: _getHeaders(),
).timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Contact sync failed: ${response.statusCode}');
}
debugPrint('✅ [SseClient] Contact sync requested');
} catch (e) {
debugPrint('❌ [SseClient] Error syncing contacts: $e');
rethrow;
}
}
/// Get headers for HTTP requests
Map<String, String> _getHeaders() {
final headers = <String, String>{};
if (_authToken != null) {
headers['Authorization'] = 'Bearer $_authToken';
}
return headers;
}
/// Convert JSON to Message
Message _messageFromJson(Map<String, dynamic> json) {
return Message(
id: json['id'] as String,
messageType: MessageType.values.firstWhere(
(e) => e.name == json['messageType'],
orElse: () => MessageType.contact,
),
senderPublicKeyPrefix: json['senderPublicKeyPrefix'] != null
? Uint8List.fromList((json['senderPublicKeyPrefix'] as List).cast<int>())
: null,
channelIdx: json['channelIdx'] as int?,
pathLen: json['pathLen'] as int,
textType: MessageTextType.fromValue(json['textType'] as int),
senderTimestamp: json['senderTimestamp'] as int,
text: json['text'] as String,
isSarMarker: json['isSarMarker'] as bool? ?? false,
sarGpsCoordinates: json['sarGpsCoordinates'] != null
? LatLng(
(json['sarGpsCoordinates']['latitude'] as num).toDouble(),
(json['sarGpsCoordinates']['longitude'] as num).toDouble(),
)
: null,
sarNotes: json['sarNotes'] as String?,
sarCustomEmoji: json['sarCustomEmoji'] as String?,
sarColorIndex: json['sarColorIndex'] as int?,
receivedAt: DateTime.parse(json['receivedAt'] as String),
senderName: json['senderName'] as String?,
deliveryStatus: MessageDeliveryStatus.values.firstWhere(
(e) => e.name == json['deliveryStatus'],
orElse: () => MessageDeliveryStatus.received,
),
expectedAckTag: json['expectedAckTag'] as int?,
suggestedTimeoutMs: json['suggestedTimeoutMs'] as int?,
roundTripTimeMs: json['roundTripTimeMs'] as int?,
deliveredAt: json['deliveredAt'] != null
? DateTime.parse(json['deliveredAt'] as String)
: null,
recipientPublicKey: json['recipientPublicKey'] != null
? Uint8List.fromList((json['recipientPublicKey'] as List).cast<int>())
: null,
retryAttempt: json['retryAttempt'] as int? ?? 0,
lastRetryAt: json['lastRetryAt'] != null
? DateTime.parse(json['lastRetryAt'] as String)
: null,
usedFloodFallback: json['usedFloodFallback'] as bool? ?? false,
isRead: json['isRead'] as bool? ?? false,
echoCount: json['echoCount'] as int? ?? 0,
firstEchoAt: json['firstEchoAt'] != null
? DateTime.parse(json['firstEchoAt'] as String)
: null,
isDrawing: json['isDrawing'] as bool? ?? false,
drawingId: json['drawingId'] as String?,
);
}
/// Convert JSON to Contact
Contact _contactFromJson(Map<String, dynamic> json) {
return Contact(
publicKey: Uint8List.fromList((json['publicKey'] as List).cast<int>()),
type: ContactType.fromValue(json['type'] as int),
flags: json['flags'] as int,
outPathLen: json['outPathLen'] as int,
outPath: Uint8List.fromList((json['outPath'] as List).cast<int>()),
advName: json['advName'] as String,
lastAdvert: json['lastAdvert'] as int,
advLat: json['advLat'] as int,
advLon: json['advLon'] as int,
lastMod: json['lastMod'] as int,
);
}
/// Dispose resources
void dispose() {
disconnect();
}
}

View File

@@ -0,0 +1,744 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as io;
import 'package:nsd/nsd.dart';
import '../models/message.dart';
import '../models/contact.dart';
import '../models/sse_server_config.dart';
import 'network_scanner_service.dart';
/// SSE Server Service
///
/// Provides a web server with SSE (Server-Sent Events) endpoints for
/// real-time message and contact updates, enabling multiple app instances
/// to share a single MeshCore BLE device.
///
/// Endpoints:
/// - GET /sse/messages - SSE stream for message updates
/// - GET /sse/contacts - SSE stream for contact updates
/// - POST /api/messages - Send message
/// - POST /api/messages/channel - Send channel message
/// - POST /api/contacts/sync - Trigger contact sync
/// - GET /api/messages/history - Get all messages
/// - GET /api/contacts - Get all contacts
/// - GET /api/status - Server health check
class SseServerService {
HttpServer? _server;
SseServerConfig? _config;
Registration? _bonjourRegistration;
/// Active SSE connections for messages
final Set<StreamController<String>> _messageStreams = {};
/// Active SSE connections for contacts
final Set<StreamController<String>> _contactStreams = {};
/// Message history (for new clients)
final List<Message> _messageHistory = [];
/// Contact list (for new clients)
final Map<String, Contact> _contacts = {};
/// Timer for cleaning up dead connections
Timer? _cleanupTimer;
/// Device name (for status endpoint)
String? _deviceName;
/// Set device name
void setDeviceName(String? name) {
_deviceName = name;
debugPrint('📝 [SseServer] Device name set to: $name');
}
/// Callback for when a client requests to send a message
Future<bool> Function(String recipientPublicKey, String text)? onSendMessage;
/// Callback for when a client requests to send a channel message
Future<void> Function(int channelIdx, String text)? onSendChannelMessage;
/// Callback for when a client requests contact sync
Future<void> Function()? onSyncContacts;
/// Check if server is running
bool get isRunning => _server != null;
/// Get current configuration
SseServerConfig? get config => _config;
/// Get number of connected clients
int get connectedClients => _messageStreams.length;
/// CORS middleware
static shelf.Middleware get _corsHeaders {
return shelf.createMiddleware(
responseHandler: (shelf.Response response) {
return response.change(headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Origin, Content-Type, Authorization',
});
},
);
}
/// Start the SSE server
Future<void> startServer(SseServerConfig config) async {
if (_server != null) {
debugPrint('⚠️ [SseServer] Server already running');
return;
}
_config = config;
try {
debugPrint('🚀 [SseServer] Starting server on ${config.host}:${config.port}');
// Create shelf handler with CORS support
final handler = const shelf.Pipeline()
.addMiddleware(_corsHeaders)
.addMiddleware(shelf.logRequests())
.addHandler(_handleRequest);
// Start HTTP server
_server = await io.serve(
handler,
config.host,
config.port,
);
debugPrint('✅ [SseServer] Server started on ${config.getServerUrl()}');
// Start cleanup timer for dead connections
_startCleanupTimer();
// Register Bonjour/mDNS service
await _registerBonjourService(config);
} catch (e) {
debugPrint('❌ [SseServer] Failed to start server: $e');
_server = null;
rethrow;
}
}
/// Register Bonjour/mDNS service for network discovery
Future<void> _registerBonjourService(SseServerConfig config) async {
try {
debugPrint('📡 [SseServer] Registering Bonjour service ${NetworkScannerService.serviceType}...');
_bonjourRegistration = await register(
const Service(
name: 'MeshCore SSE Server',
type: NetworkScannerService.serviceType,
port: 0, // Will be set dynamically
),
);
// Update with actual port
if (_bonjourRegistration != null) {
// Unregister and re-register with correct port
await unregister(_bonjourRegistration!);
_bonjourRegistration = await register(
Service(
name: 'MeshCore SSE Server',
type: NetworkScannerService.serviceType,
port: config.port,
),
);
debugPrint('✅ [SseServer] Bonjour service registered on port ${config.port}');
}
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to register Bonjour service: $e');
// Don't throw - server can still work without Bonjour
}
}
/// Start cleanup timer to remove dead connections
void _startCleanupTimer() {
_cleanupTimer?.cancel();
_cleanupTimer = Timer.periodic(const Duration(seconds: 60), (timer) {
_cleanupDeadConnections();
});
debugPrint('🧹 [SseServer] Cleanup timer started (60s interval)');
}
/// Clean up dead/closed connections
void _cleanupDeadConnections() {
// Clean up message streams
final deadMessageStreams = _messageStreams.where((s) => s.isClosed).toList();
for (final stream in deadMessageStreams) {
_messageStreams.remove(stream);
}
// Clean up contact streams
final deadContactStreams = _contactStreams.where((s) => s.isClosed).toList();
for (final stream in deadContactStreams) {
_contactStreams.remove(stream);
}
if (deadMessageStreams.isNotEmpty || deadContactStreams.isNotEmpty) {
debugPrint('🧹 [SseServer] Cleaned up ${deadMessageStreams.length} dead message streams, ${deadContactStreams.length} dead contact streams');
debugPrint(' Active: ${_messageStreams.length} message clients, ${_contactStreams.length} contact clients');
}
}
/// Stop the SSE server
Future<void> stopServer() async {
if (_server == null) {
return;
}
debugPrint('🛑 [SseServer] Stopping server...');
// Stop cleanup timer
_cleanupTimer?.cancel();
_cleanupTimer = null;
// Close all SSE streams
for (final stream in _messageStreams) {
await stream.close();
}
_messageStreams.clear();
for (final stream in _contactStreams) {
await stream.close();
}
_contactStreams.clear();
// Unregister Bonjour service
if (_bonjourRegistration != null) {
try {
await unregister(_bonjourRegistration!);
debugPrint('✅ [SseServer] Bonjour service unregistered');
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to unregister Bonjour service: $e');
}
_bonjourRegistration = null;
}
// Close HTTP server
await _server!.close(force: true);
_server = null;
_config = null;
debugPrint('✅ [SseServer] Server stopped');
}
/// Main request handler
Future<shelf.Response> _handleRequest(shelf.Request request) async {
// Check authentication if token is configured
if (_config?.authToken != null) {
final authHeader = request.headers['authorization'];
if (authHeader != 'Bearer ${_config!.authToken}') {
return shelf.Response.forbidden('Invalid authentication token');
}
}
final path = request.url.path;
final method = request.method;
debugPrint('📨 [SseServer] $method /$path');
// Route requests
if (method == 'GET' && path == 'sse/messages') {
return _handleSseMessages(request);
} else if (method == 'GET' && path == 'sse/contacts') {
return _handleSseContacts(request);
} else if (method == 'POST' && path == 'api/messages') {
return _handlePostMessage(request);
} else if (method == 'POST' && path == 'api/messages/channel') {
return _handlePostChannelMessage(request);
} else if (method == 'POST' && path == 'api/contacts/sync') {
return _handlePostContactsSync(request);
} else if (method == 'GET' && path == 'api/messages/history') {
return _handleGetMessageHistory(request);
} else if (method == 'GET' && path == 'api/contacts') {
return _handleGetContacts(request);
} else if (method == 'GET' && path == 'api/status') {
return _handleGetStatus(request);
} else if (method == 'GET' && path == '') {
return _handleRoot(request);
}
return shelf.Response.notFound('Not found');
}
/// Handle SSE messages stream
shelf.Response _handleSseMessages(shelf.Request request) {
return request.hijack((channel) async {
debugPrint('📥 [SseServer] New SSE client connected (messages) via hijack');
// Set up the sink for sending data
final sink = utf8.encoder.startChunkedConversion(channel.sink);
// Send SSE headers
sink.add('HTTP/1.1 200 OK\r\n');
sink.add('Content-Type: text/event-stream\r\n');
sink.add('Cache-Control: no-cache\r\n');
sink.add('Connection: keep-alive\r\n');
sink.add('\r\n');
// Create controller for this connection
final controller = StreamController<String>();
_messageStreams.add(controller);
debugPrint(' Total clients: ${_messageStreams.length}');
// Send initial connection event
sink.add(': connected\n\n');
// Send initial message history
for (final message in _messageHistory) {
final event = _formatSseEvent('message', _messageToJson(message));
sink.add(event);
}
// Start keep-alive timer
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
try {
sink.add(': keepalive\n\n');
} catch (e) {
debugPrint('⚠️ [SseServer] Keep-alive failed: $e');
timer.cancel();
}
});
// Listen to controller for new messages to broadcast
final subscription = controller.stream.listen(
(data) {
try {
sink.add(data);
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to send data: $e');
}
},
onDone: () {
debugPrint('📤 [SseServer] Controller stream closed');
},
);
// Wait for channel to close
await channel.stream.drain();
// Cleanup
keepAliveTimer.cancel();
await subscription.cancel();
_messageStreams.remove(controller);
await controller.close();
debugPrint('📤 [SseServer] SSE client disconnected (messages)');
debugPrint(' Total clients: ${_messageStreams.length}');
});
}
/// Handle SSE contacts stream
shelf.Response _handleSseContacts(shelf.Request request) {
return request.hijack((channel) async {
debugPrint('📥 [SseServer] New SSE client connected (contacts) via hijack');
// Set up the sink for sending data
final sink = utf8.encoder.startChunkedConversion(channel.sink);
// Send SSE headers
sink.add('HTTP/1.1 200 OK\r\n');
sink.add('Content-Type: text/event-stream\r\n');
sink.add('Cache-Control: no-cache\r\n');
sink.add('Connection: keep-alive\r\n');
sink.add('\r\n');
// Create controller for this connection
final controller = StreamController<String>();
_contactStreams.add(controller);
debugPrint(' Total clients: ${_contactStreams.length}');
// Send initial connection event
sink.add(': connected\n\n');
// Send initial contact list
for (final contact in _contacts.values) {
final event = _formatSseEvent('contact', _contactToJson(contact));
sink.add(event);
}
// Start keep-alive timer
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
try {
sink.add(': keepalive\n\n');
} catch (e) {
debugPrint('⚠️ [SseServer] Keep-alive failed: $e');
timer.cancel();
}
});
// Listen to controller for new messages to broadcast
final subscription = controller.stream.listen(
(data) {
try {
sink.add(data);
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to send data: $e');
}
},
onDone: () {
debugPrint('📤 [SseServer] Controller stream closed');
},
);
// Wait for channel to close
await channel.stream.drain();
// Cleanup
keepAliveTimer.cancel();
await subscription.cancel();
_contactStreams.remove(controller);
await controller.close();
debugPrint('📤 [SseServer] SSE client disconnected (contacts)');
debugPrint(' Total clients: ${_contactStreams.length}');
});
}
/// Handle POST message request
Future<shelf.Response> _handlePostMessage(shelf.Request request) async {
try {
final body = await request.readAsString();
final json = jsonDecode(body) as Map<String, dynamic>;
final recipientPublicKey = json['recipientPublicKey'] as String;
final text = json['text'] as String;
if (onSendMessage == null) {
return shelf.Response.internalServerError(
body: jsonEncode({'error': 'Send message callback not configured'}),
);
}
final success = await onSendMessage!(recipientPublicKey, text);
return shelf.Response.ok(
jsonEncode({'success': success}),
headers: {'content-type': 'application/json'},
);
} catch (e) {
debugPrint('❌ [SseServer] Error handling POST message: $e');
return shelf.Response.internalServerError(
body: jsonEncode({'error': e.toString()}),
);
}
}
/// Handle POST channel message request
Future<shelf.Response> _handlePostChannelMessage(shelf.Request request) async {
try {
final body = await request.readAsString();
final json = jsonDecode(body) as Map<String, dynamic>;
final channelIdx = json['channelIdx'] as int;
final text = json['text'] as String;
if (onSendChannelMessage == null) {
return shelf.Response.internalServerError(
body: jsonEncode({'error': 'Send channel message callback not configured'}),
);
}
await onSendChannelMessage!(channelIdx, text);
return shelf.Response.ok(
jsonEncode({'success': true}),
headers: {'content-type': 'application/json'},
);
} catch (e) {
debugPrint('❌ [SseServer] Error handling POST channel message: $e');
return shelf.Response.internalServerError(
body: jsonEncode({'error': e.toString()}),
);
}
}
/// Handle POST contacts sync request
Future<shelf.Response> _handlePostContactsSync(shelf.Request request) async {
try {
if (onSyncContacts == null) {
return shelf.Response.internalServerError(
body: jsonEncode({'error': 'Sync contacts callback not configured'}),
);
}
await onSyncContacts!();
return shelf.Response.ok(
jsonEncode({'success': true}),
headers: {'content-type': 'application/json'},
);
} catch (e) {
debugPrint('❌ [SseServer] Error handling POST contacts sync: $e');
return shelf.Response.internalServerError(
body: jsonEncode({'error': e.toString()}),
);
}
}
/// Handle GET message history request
shelf.Response _handleGetMessageHistory(shelf.Request request) {
final messages = _messageHistory.map(_messageToJson).toList();
return shelf.Response.ok(
jsonEncode({'messages': messages}),
headers: {'content-type': 'application/json'},
);
}
/// Handle GET contacts request
shelf.Response _handleGetContacts(shelf.Request request) {
final contacts = _contacts.values.map(_contactToJson).toList();
return shelf.Response.ok(
jsonEncode({'contacts': contacts}),
headers: {'content-type': 'application/json'},
);
}
/// Handle GET status request
shelf.Response _handleGetStatus(shelf.Request request) {
return shelf.Response.ok(
jsonEncode({
'status': 'running',
'connectedClients': connectedClients,
'messageCount': _messageHistory.length,
'contactCount': _contacts.length,
'deviceName': _deviceName,
}),
headers: {'content-type': 'application/json'},
);
}
/// Handle root request (landing page)
shelf.Response _handleRoot(shelf.Request request) {
final html = '''
<!DOCTYPE html>
<html>
<head>
<title>MeshCore SAR - SSE Server</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: sans-serif; margin: 40px; background: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
h1 { color: #333; }
.status { background: #4CAF50; color: white; padding: 10px; border-radius: 4px; margin: 20px 0; }
.endpoint { background: #f9f9f9; padding: 10px; margin: 10px 0; border-left: 3px solid #2196F3; font-family: monospace; }
code { background: #eee; padding: 2px 6px; border-radius: 3px; }
</style>
</head>
<body>
<div class="container">
<h1>🚀 MeshCore SAR Server</h1>
<div class="status">✅ Server is running</div>
<p>This server enables multiple MeshCore SAR clients to share a single BLE device.</p>
<h2>📡 SSE Endpoints</h2>
<div class="endpoint">GET /sse/messages</div>
<div class="endpoint">GET /sse/contacts</div>
<h2>🔧 API Endpoints</h2>
<div class="endpoint">POST /api/messages</div>
<div class="endpoint">POST /api/messages/channel</div>
<div class="endpoint">POST /api/contacts/sync</div>
<div class="endpoint">GET /api/messages/history</div>
<div class="endpoint">GET /api/contacts</div>
<div class="endpoint">GET /api/status</div>
<h2>📊 Stats</h2>
<p>Connected clients: <strong id="clients">Loading...</strong></p>
<p>Messages: <strong id="messages">Loading...</strong></p>
<p>Contacts: <strong id="contacts">Loading...</strong></p>
</div>
<script>
async function updateStats() {
try {
const res = await fetch('/api/status');
const data = await res.json();
document.getElementById('clients').textContent = data.connectedClients;
document.getElementById('messages').textContent = data.messageCount;
document.getElementById('contacts').textContent = data.contactCount;
} catch (e) {
console.error('Failed to fetch stats:', e);
}
}
updateStats();
setInterval(updateStats, 5000);
</script>
</body>
</html>
''';
return shelf.Response.ok(
html,
headers: {'content-type': 'text/html'},
);
}
/// Broadcast a new message to all SSE clients
void broadcastMessage(Message message) {
// Add to history (limit to 1000 messages)
_messageHistory.add(message);
if (_messageHistory.length > 1000) {
_messageHistory.removeAt(0);
}
// Broadcast to all connected clients
final event = _formatSseEvent('message', _messageToJson(message));
final deadStreams = <StreamController<String>>[];
for (final stream in _messageStreams) {
if (stream.isClosed) {
deadStreams.add(stream);
} else {
try {
stream.add(event);
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to send to stream, marking as dead: $e');
deadStreams.add(stream);
}
}
}
// Remove dead streams
for (final stream in deadStreams) {
_messageStreams.remove(stream);
stream.close().catchError((e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'));
}
if (deadStreams.isNotEmpty) {
debugPrint('🧹 [SseServer] Removed ${deadStreams.length} dead message streams during broadcast');
}
debugPrint('📢 [SseServer] Broadcasted message to ${_messageStreams.length} clients');
}
/// Broadcast a new or updated contact to all SSE clients
void broadcastContact(Contact contact) {
// Update contact list
_contacts[contact.publicKeyHex] = contact;
// Broadcast to all connected clients
final event = _formatSseEvent('contact', _contactToJson(contact));
final deadStreams = <StreamController<String>>[];
for (final stream in _contactStreams) {
if (stream.isClosed) {
deadStreams.add(stream);
} else {
try {
stream.add(event);
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to send to stream, marking as dead: $e');
deadStreams.add(stream);
}
}
}
// Remove dead streams
for (final stream in deadStreams) {
_contactStreams.remove(stream);
stream.close().catchError((e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'));
}
if (deadStreams.isNotEmpty) {
debugPrint('🧹 [SseServer] Removed ${deadStreams.length} dead contact streams during broadcast');
}
debugPrint('📢 [SseServer] Broadcasted contact to ${_contactStreams.length} clients');
}
/// Format SSE event
String _formatSseEvent(String eventType, Map<String, dynamic> data) {
final jsonData = jsonEncode(data);
return 'event: $eventType\ndata: $jsonData\n\n';
}
/// Convert Message to JSON
Map<String, dynamic> _messageToJson(Message message) {
return {
'id': message.id,
'messageType': message.messageType.name,
'senderPublicKeyPrefix': message.senderPublicKeyPrefix?.toList(),
'channelIdx': message.channelIdx,
'pathLen': message.pathLen,
'textType': message.textType.value,
'senderTimestamp': message.senderTimestamp,
'text': message.text,
'isSarMarker': message.isSarMarker,
'sarGpsCoordinates': message.sarGpsCoordinates != null
? {
'latitude': message.sarGpsCoordinates!.latitude,
'longitude': message.sarGpsCoordinates!.longitude,
}
: null,
'sarNotes': message.sarNotes,
'sarCustomEmoji': message.sarCustomEmoji,
'sarColorIndex': message.sarColorIndex,
'receivedAt': message.receivedAt.toIso8601String(),
'senderName': message.senderName,
'deliveryStatus': message.deliveryStatus.name,
'expectedAckTag': message.expectedAckTag,
'suggestedTimeoutMs': message.suggestedTimeoutMs,
'roundTripTimeMs': message.roundTripTimeMs,
'deliveredAt': message.deliveredAt?.toIso8601String(),
'recipientPublicKey': message.recipientPublicKey?.toList(),
'retryAttempt': message.retryAttempt,
'lastRetryAt': message.lastRetryAt?.toIso8601String(),
'usedFloodFallback': message.usedFloodFallback,
'isRead': message.isRead,
'echoCount': message.echoCount,
'firstEchoAt': message.firstEchoAt?.toIso8601String(),
'isDrawing': message.isDrawing,
'drawingId': message.drawingId,
};
}
/// Convert Contact to JSON
Map<String, dynamic> _contactToJson(Contact contact) {
return {
'publicKey': contact.publicKey.toList(),
'publicKeyHex': contact.publicKeyHex,
'type': contact.type.value,
'flags': contact.flags,
'outPathLen': contact.outPathLen,
'outPath': contact.outPath.toList(),
'advName': contact.advName,
'lastAdvert': contact.lastAdvert,
'advLat': contact.advLat,
'advLon': contact.advLon,
'lastMod': contact.lastMod,
'telemetry': contact.telemetry != null
? {
'batteryPercentage': contact.telemetry!.batteryPercentage,
'batteryMilliVolts': contact.telemetry!.batteryMilliVolts,
'temperature': contact.telemetry!.temperature,
'humidity': contact.telemetry!.humidity,
'pressure': contact.telemetry!.pressure,
'gpsLocation': contact.telemetry!.gpsLocation != null
? {
'latitude': contact.telemetry!.gpsLocation!.latitude,
'longitude': contact.telemetry!.gpsLocation!.longitude,
}
: null,
'timestamp': contact.telemetry!.timestamp.toIso8601String(),
}
: null,
};
}
/// Clear message history
void clearMessageHistory() {
_messageHistory.clear();
}
/// Clear contact list
void clearContacts() {
_contacts.clear();
}
}

View File

@@ -0,0 +1,284 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart';
import 'package:vector_map_tiles_mbtiles/vector_map_tiles_mbtiles.dart';
import 'package:mbtiles/mbtiles.dart';
import '../models/map_layer.dart';
class TileCacheService {
static const String _storeName = 'meshcore_sar_tiles';
// Global flag to ensure ObjectBox is only initialized once
static bool _objectBoxInitialized = false;
static final _initLock = <String, Future<void>>{};
late final FMTCStore _store;
bool _isInitialized = false;
bool _isDownloading = false;
Future<void> initialize() async {
if (_isInitialized) return;
// Ensure we only initialize ObjectBox once globally
if (!_objectBoxInitialized) {
// Use a lock to prevent concurrent initialization attempts
final initFuture = _initLock.putIfAbsent('objectbox', () async {
try {
await FMTCObjectBoxBackend().initialise();
_objectBoxInitialized = true;
} catch (e) {
// Already initialized or error - that's okay
_objectBoxInitialized = true;
}
});
await initFuture;
}
try {
_store = FMTCStore(_storeName);
await _store.manage.create();
_isInitialized = true;
} catch (e) {
// Store might already exist
_store = FMTCStore(_storeName);
_isInitialized = true;
}
}
FMTCTileProvider getTileProvider(MapLayer layer) {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
return FMTCTileProvider(
stores: {_storeName: BrowseStoreStrategy.readUpdateCreate},
loadingStrategy: BrowseLoadingStrategy.cacheFirst,
cachedValidDuration: const Duration(days: 30),
);
}
/// Get tile provider for WMS layers with caching support
/// WMS layers require special handling because they use WMSTileLayerOptions
FMTCTileProvider getTileProviderForWms(MapLayer layer) {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
if (!layer.isWms) {
throw ArgumentError('Layer must be a WMS layer');
}
// Return the same cached tile provider
// The WMS URL construction is handled by flutter_map's WMSTileLayerOptions
return FMTCTileProvider(
stores: {_storeName: BrowseStoreStrategy.readUpdateCreate},
loadingStrategy: BrowseLoadingStrategy.cacheFirst,
cachedValidDuration: const Duration(days: 30),
);
}
Future<void> downloadRegion({
required MapLayer layer,
required LatLngBounds bounds,
required int minZoom,
required int maxZoom,
Function(double progress)? onProgress,
}) async {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
if (_isDownloading) {
throw StateError('A download is already in progress. Cancel it first.');
}
_isDownloading = true;
try {
final region = RectangleRegion(bounds);
final downloadable = region.toDownloadable(
minZoom: minZoom,
maxZoom: maxZoom,
options: TileLayer(urlTemplate: layer.urlTemplate),
);
final download = _store.download.startForeground(region: downloadable);
await for (final progress in download.downloadProgress) {
if (onProgress != null && progress.maxTilesCount > 0) {
// Use attemptedTilesCount instead of successfulTilesCount
// attemptedTilesCount includes successful + buffered + skipped tiles
final percentage = progress.percentageProgress;
debugPrint(
'Download progress: ${progress.attemptedTilesCount}/${progress.maxTilesCount} = ${percentage.toStringAsFixed(1)}% (successful: ${progress.successfulTilesCount}, buffered: ${progress.bufferedTilesCount}, skipped: ${progress.skippedTilesCount})',
);
onProgress(percentage);
}
}
} finally {
_isDownloading = false;
}
}
Future<void> cancelDownload() async {
if (!_isInitialized) return;
await _store.download.cancel();
}
Future<void> clearCache() async {
if (!_isInitialized) return;
await _store.manage.delete();
await _store.manage.create();
}
Future<int> getCachedTileCount() async {
if (!_isInitialized) return 0;
final stats = await _store.stats.length;
return stats;
}
Future<double> getCacheSizeMB() async {
if (!_isInitialized) return 0.0;
final stats = await _store.stats.size;
return stats / (1024 * 1024);
}
Future<List<String>> getAvailableStores() async {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
final stores = await FMTCRoot.stats.storesAvailable;
return stores.map((store) => store.storeName).toList();
}
Future<Map<String, dynamic>> getStoreStats() async {
if (!_isInitialized) return {};
final length = await _store.stats.length;
final size = await _store.stats.all.then((a) => a.size);
return {
'tileCount': length,
'sizeMB': size / 1024,
'storeName': _storeName,
};
}
/// Get vector tile provider for MBTiles layers
MbTilesVectorTileProvider? getVectorTileProvider(MapLayer layer) {
if (!layer.isVector || layer.mbtilesFile == null) {
return null;
}
try {
final mbtiles = MbTiles(
mbtilesPath: layer.mbtilesFile!.path,
gzip: layer.isGzipped ?? false,
);
return MbTilesVectorTileProvider(
mbtiles: mbtiles,
);
} catch (e) {
debugPrint('Error creating vector tile provider: $e');
return null;
}
}
/// Export the current tile cache store to an archive file
///
/// [outputPath] - Full path where the archive should be saved (e.g., '/path/to/export.fmtc')
///
/// Returns the number of tiles exported
Future<int> exportStore(String outputPath) async {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
try {
final external = FMTCRoot.external(pathToArchive: outputPath);
final result = await external.export(storeNames: [_storeName]);
debugPrint('Export completed: $result tiles exported to $outputPath');
return result;
} catch (e) {
debugPrint('Error exporting store: $e');
rethrow;
}
}
/// Import a tile cache store from an archive file
///
/// [filePath] - Path to the .fmtc archive file to import
/// [storeNames] - Optional list of store names to import (null = import all)
/// [strategy] - Conflict resolution strategy (default: merge)
///
/// Returns a map with import statistics (e.g., tile count, stores imported)
Future<Map<String, dynamic>> importStore(
String filePath, {
List<String>? storeNames,
ImportConflictStrategy strategy = ImportConflictStrategy.merge,
}) async {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
try {
final external = FMTCRoot.external(pathToArchive: filePath);
final result = external.import(storeNames: storeNames, strategy: strategy);
// Wait for the import to complete and get tile count
final tileCount = await result.complete;
// Wait for store states
final storesToStates = await result.storesToStates;
debugPrint('Import completed: $tileCount tiles imported, ${storesToStates.length} stores');
// Count successful stores (those that weren't skipped)
final successfulCount = storesToStates.values.where((state) => state.name != null).length;
return {
'successfulStores': successfulCount,
'tileCount': tileCount,
'storesToStates': storesToStates,
};
} catch (e) {
debugPrint('Error importing store: $e');
rethrow;
}
}
/// List all stores available in an archive file without importing
///
/// [filePath] - Path to the .fmtc archive file to inspect
///
/// Returns a list of store names contained in the archive
Future<List<String>> listArchiveStores(String filePath) async {
try {
final external = FMTCRoot.external(pathToArchive: filePath);
final stores = await external.listStores;
debugPrint('Archive contains ${stores.length} stores: $stores');
return stores;
} catch (e) {
debugPrint('Error listing archive stores: $e');
rethrow;
}
}
void dispose() {
_isInitialized = false;
}
}

View File

@@ -0,0 +1,226 @@
import 'package:flutter/material.dart';
import '../models/contact.dart';
/// Service for assigning consistent colors to contact trails
/// Uses emoji-based semantic mapping with deterministic hash fallback
class TrailColorService {
// 64-color pastel palette optimized for visibility on all map types
// Organized by hue families for better distribution
// Avoids red/orange/yellow spectrum to prevent confusion with fire markers
// Avoids pure blue (#2196F3) which is reserved for user trail
static final List<Color> _colorPalette = [
// Pinks & Light Corals (8)
const Color(0xFFFFB6C1), // Light Pink
const Color(0xFFFF7F7F), // Coral
const Color(0xFFFFC0CB), // Pink
const Color(0xFFFFB3BA), // Pastel Pink
const Color(0xFFFF9AA2), // Light Coral
const Color(0xFFFFDAE9), // Pale Pink
const Color(0xFFFAA0B8), // Pastel Rose
const Color(0xFFFF8FA3), // Salmon Pink
// Purples & Plums (8)
const Color(0xFFE6E6FA), // Lavender
const Color(0xFFDDA0DD), // Plum
const Color(0xFFD8BFD8), // Thistle
const Color(0xFFDDA5E9), // Pastel Purple
const Color(0xFFE0BBE4), // Mauve
const Color(0xFFC5A3E0), // Light Purple
const Color(0xFFB19CD9), // Medium Lavender
const Color(0xFFAF9FCD), // Wisteria
// Blues & Sky (12)
const Color(0xFF87CEEB), // Sky Blue
const Color(0xFFB0E0E6), // Powder Blue
const Color(0xFFADD8E6), // Light Blue
const Color(0xFF87CEFA), // Light Sky Blue
const Color(0xFFB0C4DE), // Light Steel Blue
const Color(0xFF9BB8D3), // Pastel Blue
const Color(0xFF89CFF0), // Baby Blue
const Color(0xFFA2C8EC), // Columbia Blue
const Color(0xFF7FB3D5), // Pale Blue
const Color(0xFF6A9FB5), // Air Force Blue
const Color(0xFF8DB4D2), // Soft Blue
const Color(0xFF7BA5C9), // Light Denim
// Cyans & Teals (8)
const Color(0xFF5F9EA0), // Cadet Blue
const Color(0xFF7FFFD4), // Aquamarine
const Color(0xFF98D8C8), // Mint
const Color(0xFF82E0D5), // Pale Cyan
const Color(0xFF8FD8D8), // Light Teal
const Color(0xFF81C0BB), // Cadet Teal
const Color(0xFF72B0A8), // Medium Teal
const Color(0xFF6FA09E), // Soft Teal
// Greens & Mints (8)
const Color(0xFF90EE90), // Light Green
const Color(0xFF98D8B4), // Celadon
const Color(0xFFA8E4A0), // Granny Smith
const Color(0xFFB2E8B2), // Tea Green
const Color(0xFF9FD8AF), // Eton Blue
const Color(0xFF8FC49F), // Pastel Green
const Color(0xFF7EB693), // Cambridge Blue
const Color(0xFF73A685), // Russian Green
// Beiges & Tans (12)
const Color(0xFFD2B48C), // Tan
const Color(0xFFDEB887), // Burlywood
const Color(0xFFE0D8B0), // Beige
const Color(0xFFFFDAB9), // Peach
const Color(0xFFFFE4B5), // Moccasin
const Color(0xFFFFF8DC), // Cornsilk
const Color(0xFFE8D5C4), // Champagne
const Color(0xFFD4C5B9), // Dust
const Color(0xFFC9B8A9), // Khaki
const Color(0xFFBCAA99), // Cashmere
const Color(0xFFB09B87), // Taupe
const Color(0xFFA58F7A), // Mocha
// Grays & Silvers (8)
const Color(0xFFD3D3D3), // Light Gray
const Color(0xFFC0C0C0), // Silver
const Color(0xFFBCBCBC), // Bright Gray
const Color(0xFFB2B2B2), // Medium Gray
const Color(0xFFA9A9A9), // Dark Gray
const Color(0xFF9E9E9E), // Gray
const Color(0xFF8E8E8E), // Taupe Gray
const Color(0xFF7E7E7E), // Granite
];
// Emoji to color mapping for SAR roles
// Uses pastel semantic colors for high visibility on maps
// Avoids red/orange/yellow to prevent confusion with fire markers
static final Map<String, Color> _emojiColorMap = {
// Emergency Services - Firefighters
'🚒': Color(0xFFFF7F7F), // Fire engine → Coral
'🧑‍🚒': Color(0xFFFF7F7F), // Firefighter → Coral
'👨‍🚒': Color(0xFFFF7F7F), // Firefighter → Coral
'👩‍🚒': Color(0xFFFF7F7F), // Firefighter → Coral
'🔥': Color(0xFFFFB6C1), // Fire → Light Pink
// Emergency Services - Medical
'🚑': Color(0xFF7FFFD4), // Ambulance → Mint (medical cross)
'👨‍⚕️': Color(0xFF7FFFD4), // Health worker → Mint
'👩‍⚕️': Color(0xFF7FFFD4), // Health worker → Mint
'🧑‍⚕️': Color(0xFF7FFFD4), // Health worker → Mint
'⚕️': Color(0xFF7FFFD4), // Medical symbol → Mint
// Emergency Services - Police
'👮': Color(0xFF87CEEB), // Police → Light Blue
'👮‍♂️': Color(0xFF87CEEB), // Police → Light Blue
'👮‍♀️': Color(0xFF87CEEB), // Police → Light Blue
'🚔': Color(0xFF87CEEB), // Police car → Light Blue
// Emergency Services - Aviation
'🧑‍✈️': Color(0xFFB0E0E6), // Pilot → Sky Blue
'👨‍✈️': Color(0xFFB0E0E6), // Pilot → Sky Blue
'👩‍✈️': Color(0xFFB0E0E6), // Pilot → Sky Blue
'🚁': Color(0xFFE6E6FA), // Helicopter → Lavender
// SAR Roles - Mountain/Alpine
'🏔️': Color(0xFFD2B48C), // Mountain → Tan
'⛰️': Color(0xFFD2B48C), // Mountain → Tan
'🧗': Color(0xFFD2B48C), // Climber → Tan
'🧗‍♂️': Color(0xFFD2B48C), // Climber → Tan
'🧗‍♀️': Color(0xFFD2B48C), // Climber → Tan
'🥾': Color(0xFFDEB887), // Hiking boot → Burlywood
// SAR Roles - K9 Unit
'🐕': Color(0xFFFFDAB9), // Dog → Peach
'🐶': Color(0xFFFFDAB9), // Dog → Peach
'🦮': Color(0xFFFFDAB9), // Service dog → Peach
// SAR Roles - Water Rescue
'🚤': Color(0xFF87CEEB), // Speedboat → Sky Blue
'': Color(0xFF87CEEB), // Sailboat → Sky Blue
'🏊': Color(0xFF5F9EA0), // Swimmer → Cadet Blue
'🏊‍♂️': Color(0xFF5F9EA0), // Swimmer → Cadet Blue
'🏊‍♀️': Color(0xFF5F9EA0), // Swimmer → Cadet Blue
// Team Roles - Leadership
'🎯': Color(0xFFFFB6C1), // Target → Light Pink (team leader)
'': Color(0xFFFFE4B5), // Star → Moccasin (coordinator)
'👑': Color(0xFFFFE4B5), // Crown → Moccasin (leader)
// Team Roles - Communication
'📡': Color(0xFF5F9EA0), // Satellite → Cadet Blue (radio/comms)
'📻': Color(0xFF5F9EA0), // Radio → Cadet Blue
'📞': Color(0xFF5F9EA0), // Phone → Cadet Blue
// Team Roles - Navigation
'🗺️': Color(0xFF87CEEB), // Map → Sky Blue (navigator)
'🧭': Color(0xFF87CEEB), // Compass → Sky Blue
'📍': Color(0xFFFF7F7F), // Pin → Coral (location marker)
// Team Roles - Documentation
'📷': Color(0xFFDDA0DD), // Camera → Plum
'📹': Color(0xFFDDA0DD), // Video camera → Plum
'📝': Color(0xFFE0E0A0), // Note → Khaki (scribe)
// Equipment
'🔦': Color(0xFFFFE4B5), // Flashlight → Moccasin
'': Color(0xFFFFE4B5), // Lightning → Moccasin (power/energy)
'🔋': Color(0xFF7FFFD4), // Battery → Mint
'🎒': Color(0xFFDEB887), // Backpack → Burlywood
// Generic Person Icons
'👤': Color(0xFFD3D3D3), // Silhouette → Light Gray
'🧑': Color(0xFFD3D3D3), // Person → Light Gray
'👨': Color(0xFFD3D3D3), // Man → Light Gray
'👩': Color(0xFFD3D3D3), // Woman → Light Gray
'👥': Color(0xFFC0C0C0), // People → Silver
};
/// Get trail color for a contact
/// Priority: Emoji mapping > Name hash > Default
/// Returns fully opaque color - alpha transparency applied by caller
static Color getTrailColor(Contact contact) {
// 1. Try emoji-based color mapping
if (contact.roleEmoji != null) {
final emojiColor = _emojiColorMap[contact.roleEmoji];
if (emojiColor != null) {
return emojiColor;
}
}
// 2. Deterministic color based on display name
// Use display name (without emoji) for consistent hashing
final name = contact.displayName.isNotEmpty
? contact.displayName
: contact.publicKeyHex;
final hash = _hashString(name);
final colorIndex = hash % _colorPalette.length; // 0-63
return _colorPalette[colorIndex];
}
/// Simple string hash function (DJB2 algorithm)
/// Same algorithm used for echo detection in the app
static int _hashString(String str) {
int hash = 5381;
for (int i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash) + str.codeUnitAt(i);
hash = hash & 0xFFFFFFFF; // Keep 32-bit
}
return hash.abs();
}
/// Get all unique colors currently in use by contacts with trails
static List<Color> getActiveColors(List<Contact> contacts) {
final colors = <Color>{};
for (final contact in contacts) {
if (contact.advertHistory.length >= 2) {
colors.add(getTrailColor(contact));
}
}
return colors.toList();
}
/// Check if a color is from emoji mapping (semantic) vs hash-based
static bool isSemanticColor(Contact contact) {
if (contact.roleEmoji == null) return false;
return _emojiColorMap.containsKey(contact.roleEmoji);
}
}

View File

@@ -0,0 +1,135 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../models/update_info.dart';
import 'build_info_service.dart';
/// Service for checking if a new app version is available
/// Compares current build's commit hash with latest manifest from server
class UpdateCheckerService {
static final UpdateCheckerService _instance = UpdateCheckerService._internal();
factory UpdateCheckerService() => _instance;
UpdateCheckerService._internal();
final BuildInfoService _buildInfoService = BuildInfoService();
// Manifest URL for the latest unstable build
static const String _manifestUrl = 'https://meshcore-sar.dz0ny.dev/unstable/latest/manifest.json';
/// Check if an update is available
/// Returns UpdateInfo with availability status and download URL if available
Future<UpdateInfo> checkForUpdate() async {
try {
// Get current build's commit hash
final currentCommitHash = await _buildInfoService.getCommitHash();
// Skip check for dev builds (local development)
if (currentCommitHash == 'dev' || currentCommitHash == 'unknown') {
debugPrint('[UpdateChecker] Skipping update check for dev/unknown build');
return UpdateInfo.noUpdate(currentCommitHash);
}
debugPrint('[UpdateChecker] Current commit hash: $currentCommitHash');
debugPrint('[UpdateChecker] Fetching latest manifest from: $_manifestUrl');
// Fetch manifest from server
final response = await http.get(
Uri.parse(_manifestUrl),
headers: {'Accept': 'application/json'},
).timeout(
const Duration(seconds: 10),
onTimeout: () {
debugPrint('[UpdateChecker] Manifest fetch timed out');
throw Exception('Manifest fetch timed out');
},
);
if (response.statusCode != 200) {
debugPrint('[UpdateChecker] Failed to fetch manifest: ${response.statusCode}');
return UpdateInfo.noUpdate(currentCommitHash);
}
// Parse manifest JSON
final Map<String, dynamic> manifest = json.decode(response.body);
final latestCommitHash = manifest['commit'] as String?;
final commitShort = manifest['commit_short'] as String?;
final buildId = manifest['build_id'] as String?;
final timestamp = manifest['timestamp'] as String?;
final artifacts = manifest['artifacts'] as List<dynamic>?;
if (latestCommitHash == null || commitShort == null) {
debugPrint('[UpdateChecker] Invalid manifest: missing commit information');
return UpdateInfo.noUpdate(currentCommitHash);
}
debugPrint('[UpdateChecker] Latest commit hash: $latestCommitHash');
debugPrint('[UpdateChecker] Latest commit short: $commitShort');
// Compare commit hashes
// Current hash might be full SHA or short (7 chars)
// Latest from manifest is full SHA
final isUpdateAvailable = !_compareCommitHashes(currentCommitHash, latestCommitHash);
if (!isUpdateAvailable) {
debugPrint('[UpdateChecker] No update available (same commit)');
return UpdateInfo.noUpdate(currentCommitHash);
}
// Find Android APK in artifacts
final String? apkUrl = _findAndroidApkUrl(artifacts);
if (apkUrl == null) {
debugPrint('[UpdateChecker] Update available but no APK found in artifacts');
return UpdateInfo.noUpdate(currentCommitHash);
}
debugPrint('[UpdateChecker] Update available! APK URL: $apkUrl');
return UpdateInfo.available(
currentCommitHash: currentCommitHash,
latestCommitHash: commitShort,
downloadUrl: apkUrl,
buildId: buildId,
timestamp: timestamp,
);
} catch (e) {
debugPrint('[UpdateChecker] Error checking for update: $e');
// Return no update on error to avoid disrupting app startup
final currentCommitHash = await _buildInfoService.getCommitHash();
return UpdateInfo.noUpdate(currentCommitHash);
}
}
/// Compare two commit hashes (handles both full SHA and short format)
bool _compareCommitHashes(String current, String latest) {
// Normalize to lowercase for comparison
final currentLower = current.toLowerCase();
final latestLower = latest.toLowerCase();
// Direct match
if (currentLower == latestLower) return true;
// Check if current is short form of latest
if (latestLower.startsWith(currentLower)) return true;
// Check if latest is short form of current
if (currentLower.startsWith(latestLower)) return true;
return false;
}
/// Find Android APK URL in artifacts list
String? _findAndroidApkUrl(List<dynamic>? artifacts) {
if (artifacts == null || artifacts.isEmpty) return null;
// Look for .apk file in artifacts
for (final artifact in artifacts) {
if (artifact is String && artifact.toLowerCase().endsWith('.apk')) {
// Construct full URL
return 'https://meshcore-sar.dz0ny.dev/unstable/latest/$artifact';
}
}
return null;
}
}

View File

@@ -0,0 +1,511 @@
/// Centralized validation service for form validation, coordinate validation,
/// input sanitization, and common validation patterns used across the app.
///
/// This service provides structured validation results with helpful error messages
/// and includes parse + validate methods for complex inputs.
class ValidationService {
// Singleton pattern
static final ValidationService _instance = ValidationService._internal();
factory ValidationService() => _instance;
ValidationService._internal();
// ============================================================================
// COORDINATE VALIDATION
// ============================================================================
/// Validates latitude value (-90.0 to +90.0)
ValidationResult validateLatitude(double? lat) {
if (lat == null) {
return const ValidationResult.invalid('Latitude is required');
}
if (lat < -90.0 || lat > 90.0) {
return const ValidationResult.invalid(
'Latitude must be between -90.0 and +90.0',
);
}
return const ValidationResult.valid();
}
/// Validates longitude value (-180.0 to +180.0)
ValidationResult validateLongitude(double? lon) {
if (lon == null) {
return const ValidationResult.invalid('Longitude is required');
}
if (lon < -180.0 || lon > 180.0) {
return const ValidationResult.invalid(
'Longitude must be between -180.0 and +180.0',
);
}
return const ValidationResult.valid();
}
/// Validates both latitude and longitude coordinates
ValidationResult validateCoordinates(double? lat, double? lon) {
final latResult = validateLatitude(lat);
if (!latResult.isValid) return latResult;
final lonResult = validateLongitude(lon);
if (!lonResult.isValid) return lonResult;
return const ValidationResult.valid();
}
// ============================================================================
// COORDINATE BOUNDS VALIDATION (for region downloads)
// ============================================================================
/// Validates coordinate bounds for map region downloads
///
/// Checks:
/// - All coordinates are valid numbers
/// - North > South
/// - East > West
/// - Coordinates are within valid ranges
ValidationResult validateBounds({
required double? north,
required double? south,
required double? east,
required double? west,
}) {
// Validate all coordinates exist
if (north == null || south == null || east == null || west == null) {
return const ValidationResult.invalid(
'All coordinates are required (North, South, East, West)',
);
}
// Validate individual coordinate ranges
final northResult = validateLatitude(north);
if (!northResult.isValid) {
return ValidationResult.invalid('North: ${northResult.errorMessage}');
}
final southResult = validateLatitude(south);
if (!southResult.isValid) {
return ValidationResult.invalid('South: ${southResult.errorMessage}');
}
final eastResult = validateLongitude(east);
if (!eastResult.isValid) {
return ValidationResult.invalid('East: ${eastResult.errorMessage}');
}
final westResult = validateLongitude(west);
if (!westResult.isValid) {
return ValidationResult.invalid('West: ${westResult.errorMessage}');
}
// Validate bounds relationships
if (north <= south) {
return const ValidationResult.invalid(
'North must be greater than South',
);
}
if (east <= west) {
return const ValidationResult.invalid(
'East must be greater than West',
);
}
return const ValidationResult.valid();
}
// ============================================================================
// RADIO PARAMETER VALIDATION
// ============================================================================
/// Validates LoRa radio frequency in MHz (137.0 to 1020.0 MHz)
ValidationResult validateFrequency(double? freqMhz) {
if (freqMhz == null) {
return const ValidationResult.invalid('Frequency is required');
}
if (freqMhz < 137.0 || freqMhz > 1020.0) {
return const ValidationResult.invalid(
'Frequency must be between 137.0 and 1020.0 MHz',
);
}
return const ValidationResult.valid();
}
/// Validates TX power in dBm (-9 to +22 dBm typical, or up to maxPower)
///
/// If maxPower is provided, uses that as upper limit.
/// Otherwise defaults to +22 dBm.
ValidationResult validateTxPower(int? powerDbm, int? maxPower) {
if (powerDbm == null) {
return const ValidationResult.invalid('TX power is required');
}
final max = maxPower ?? 22;
if (powerDbm < -9) {
return const ValidationResult.invalid(
'TX power must be at least -9 dBm',
);
}
if (powerDbm > max) {
return ValidationResult.invalid(
'TX power must not exceed $max dBm',
);
}
return const ValidationResult.valid();
}
/// Validates LoRa bandwidth index (0-9)
///
/// Valid bandwidth indices:
/// 0=7.8kHz, 1=10.4kHz, 2=15.6kHz, 3=20.8kHz, 4=31.25kHz,
/// 5=41.7kHz, 6=62.5kHz, 7=125kHz, 8=250kHz, 9=500kHz
ValidationResult validateBandwidth(int? bwIndex) {
if (bwIndex == null) {
return const ValidationResult.invalid('Bandwidth is required');
}
if (bwIndex < 0 || bwIndex > 9) {
return const ValidationResult.invalid(
'Bandwidth index must be between 0 and 9',
);
}
return const ValidationResult.valid();
}
/// Validates LoRa spreading factor (7-12)
ValidationResult validateSpreadingFactor(int? sf) {
if (sf == null) {
return const ValidationResult.invalid('Spreading factor is required');
}
if (sf < 7 || sf > 12) {
return const ValidationResult.invalid(
'Spreading factor must be between 7 and 12',
);
}
return const ValidationResult.valid();
}
/// Validates LoRa coding rate (5-8)
ValidationResult validateCodingRate(int? cr) {
if (cr == null) {
return const ValidationResult.invalid('Coding rate is required');
}
if (cr < 5 || cr > 8) {
return const ValidationResult.invalid(
'Coding rate must be between 5 and 8',
);
}
return const ValidationResult.valid();
}
// ============================================================================
// DISTANCE AND TIME VALIDATION
// ============================================================================
/// Validates distance in meters
///
/// Optional min and max bounds can be provided.
/// Defaults to 1m minimum if not specified.
ValidationResult validateDistance(
double? meters, {
double? min,
double? max,
}) {
if (meters == null) {
return const ValidationResult.invalid('Distance is required');
}
final minValue = min ?? 1.0;
if (meters < minValue) {
return ValidationResult.invalid(
'Distance must be at least ${minValue.toStringAsFixed(0)}m',
);
}
if (max != null && meters > max) {
return ValidationResult.invalid(
'Distance must not exceed ${max.toStringAsFixed(0)}m',
);
}
return const ValidationResult.valid();
}
/// Validates time interval in seconds
///
/// Optional min and max bounds can be provided.
/// Defaults to 10 seconds minimum if not specified.
ValidationResult validateTimeInterval(
int? seconds, {
int? min,
int? max,
}) {
if (seconds == null) {
return const ValidationResult.invalid('Time interval is required');
}
final minValue = min ?? 10;
if (seconds < minValue) {
return ValidationResult.invalid(
'Time interval must be at least ${minValue}s',
);
}
if (max != null && seconds > max) {
return ValidationResult.invalid(
'Time interval must not exceed ${max}s',
);
}
return const ValidationResult.valid();
}
// ============================================================================
// ZOOM LEVEL VALIDATION
// ============================================================================
/// Validates map zoom level (1-19 for most tile sources)
ValidationResult validateZoomLevel(int? zoom) {
if (zoom == null) {
return const ValidationResult.invalid('Zoom level is required');
}
if (zoom < 1 || zoom > 19) {
return const ValidationResult.invalid(
'Zoom level must be between 1 and 19',
);
}
return const ValidationResult.valid();
}
// ============================================================================
// NAME AND TEXT VALIDATION
// ============================================================================
/// Validates name/text field
///
/// Checks for:
/// - Non-empty after trimming
/// - Maximum length (defaults to 32 characters)
ValidationResult validateName(String? name, {int? maxLength}) {
if (name == null || name.trim().isEmpty) {
return const ValidationResult.invalid('Name cannot be empty');
}
final max = maxLength ?? 32;
if (name.length > max) {
return ValidationResult.invalid(
'Name must not exceed $max characters',
);
}
return const ValidationResult.valid();
}
/// Validates password field
///
/// Checks for:
/// - Non-empty
/// - Maximum length of 15 characters (MeshCore protocol limit)
ValidationResult validatePassword(String? password) {
if (password == null || password.isEmpty) {
return const ValidationResult.invalid('Password cannot be empty');
}
if (password.length > 15) {
return const ValidationResult.invalid(
'Password must not exceed 15 characters',
);
}
return const ValidationResult.valid();
}
// ============================================================================
// PARSE AND VALIDATE METHODS
// ============================================================================
/// Parses and validates latitude string
///
/// Returns ParseResult with parsed value or error message.
ParseResult<double> parseLatitude(String text) {
if (text.trim().isEmpty) {
return const ParseResult.error('Latitude is required');
}
final value = double.tryParse(text.trim());
if (value == null) {
return const ParseResult.error('Invalid number format');
}
final validation = validateLatitude(value);
if (!validation.isValid) {
return ParseResult.error(validation.errorMessage!);
}
return ParseResult.success(value);
}
/// Parses and validates longitude string
///
/// Returns ParseResult with parsed value or error message.
ParseResult<double> parseLongitude(String text) {
if (text.trim().isEmpty) {
return const ParseResult.error('Longitude is required');
}
final value = double.tryParse(text.trim());
if (value == null) {
return const ParseResult.error('Invalid number format');
}
final validation = validateLongitude(value);
if (!validation.isValid) {
return ParseResult.error(validation.errorMessage!);
}
return ParseResult.success(value);
}
/// Parses and validates frequency string (in MHz)
///
/// Returns ParseResult with parsed value or error message.
ParseResult<double> parseFrequency(String text) {
if (text.trim().isEmpty) {
return const ParseResult.error('Frequency is required');
}
final value = double.tryParse(text.trim());
if (value == null) {
return const ParseResult.error('Invalid number format');
}
final validation = validateFrequency(value);
if (!validation.isValid) {
return ParseResult.error(validation.errorMessage!);
}
return ParseResult.success(value);
}
/// Parses and validates TX power string (in dBm)
///
/// Returns ParseResult with parsed value or error message.
ParseResult<int> parseTxPower(String text, {int? maxPower}) {
if (text.trim().isEmpty) {
return const ParseResult.error('TX power is required');
}
final value = int.tryParse(text.trim());
if (value == null) {
return const ParseResult.error('Invalid number format');
}
final validation = validateTxPower(value, maxPower);
if (!validation.isValid) {
return ParseResult.error(validation.errorMessage!);
}
return ParseResult.success(value);
}
// ============================================================================
// SANITIZATION METHODS
// ============================================================================
/// Sanitizes name string
///
/// - Trims whitespace
/// - Removes control characters
/// - Truncates to maxLength if specified (defaults to 32)
String sanitizeName(String name, {int? maxLength}) {
final max = maxLength ?? 32;
// Trim whitespace
String sanitized = name.trim();
// Remove control characters (0x00-0x1F, 0x7F)
sanitized = sanitized.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '');
// Truncate if too long
if (sanitized.length > max) {
sanitized = sanitized.substring(0, max);
}
return sanitized;
}
/// Sanitizes password string
///
/// - Removes whitespace
/// - Removes control characters
/// - Truncates to 15 characters (MeshCore protocol limit)
String sanitizePassword(String password) {
// Remove all whitespace
String sanitized = password.replaceAll(RegExp(r'\s'), '');
// Remove control characters
sanitized = sanitized.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '');
// Truncate to protocol limit
if (sanitized.length > 15) {
sanitized = sanitized.substring(0, 15);
}
return sanitized;
}
}
// ==============================================================================
// RESULT CLASSES
// ==============================================================================
/// Result of a validation operation
///
/// Contains either success (isValid=true) or failure with error message.
class ValidationResult {
/// Whether the validation passed
final bool isValid;
/// Error message if validation failed (null if valid)
final String? errorMessage;
/// Creates a valid result
const ValidationResult.valid()
: isValid = true,
errorMessage = null;
/// Creates an invalid result with error message
const ValidationResult.invalid(this.errorMessage) : isValid = false;
@override
String toString() {
return isValid ? 'Valid' : 'Invalid: $errorMessage';
}
}
/// Result of a parse operation
///
/// Contains either parsed value (success) or error message (failure).
class ParseResult<T> {
/// Parsed value if successful (null if error)
final T? value;
/// Error message if parsing failed (null if successful)
final String? errorMessage;
/// Creates a successful parse result
const ParseResult.success(this.value) : errorMessage = null;
/// Creates a failed parse result with error message
const ParseResult.error(this.errorMessage) : value = null;
/// Whether the parse operation succeeded
bool get isSuccess => value != null;
@override
String toString() {
return isSuccess ? 'Success: $value' : 'Error: $errorMessage';
}
}

View File

@@ -0,0 +1,42 @@
import 'package:shared_preferences/shared_preferences.dart';
/// Service to manage welcome wizard preferences and state
class WizardPreferences {
static const String _wizardCompletedKey = 'wizard_completed';
static const String _wizardVersionKey = 'wizard_version';
static const int _currentWizardVersion = 1;
/// Check if the welcome wizard has been completed
static Future<bool> isWizardCompleted() async {
final prefs = await SharedPreferences.getInstance();
final completed = prefs.getBool(_wizardCompletedKey) ?? false;
final version = prefs.getInt(_wizardVersionKey) ?? 0;
// Re-show wizard if version has changed (for major updates)
return completed && version >= _currentWizardVersion;
}
/// Mark the welcome wizard as completed
static Future<void> setWizardCompleted(bool completed) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_wizardCompletedKey, completed);
if (completed) {
// Store current version when wizard is completed
await prefs.setInt(_wizardVersionKey, _currentWizardVersion);
}
}
/// Get the wizard version last shown to the user
static Future<int> getWizardVersion() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getInt(_wizardVersionKey) ?? 0;
}
/// Reset wizard state (useful for testing or re-showing tutorial)
static Future<void> resetWizard() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_wizardCompletedKey, false);
await prefs.setInt(_wizardVersionKey, 0);
}
}

View File

@@ -0,0 +1,73 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:http/http.dart' as http;
/// Custom tile provider that logs WMS URLs for debugging
class DebugWmsTileProvider extends TileProvider {
final http.Client httpClient;
DebugWmsTileProvider() : httpClient = http.Client();
@override
ImageProvider getImage(TileCoordinates coordinates, TileLayer options) {
return DebugNetworkTileProvider(
coordinates: coordinates,
options: options,
httpClient: httpClient,
);
}
@override
void dispose() {
httpClient.close();
super.dispose();
}
}
class DebugNetworkTileProvider extends ImageProvider<DebugNetworkTileProvider> {
final TileCoordinates coordinates;
final TileLayer options;
final http.Client httpClient;
const DebugNetworkTileProvider({
required this.coordinates,
required this.options,
required this.httpClient,
});
@override
ImageStreamCompleter loadImage(DebugNetworkTileProvider key, ImageDecoderCallback decode) {
// Get the WMS URL from the tile layer options
final wmsOptions = options.wmsOptions;
if (wmsOptions == null) {
throw Exception('WMSTileLayerOptions is required for DebugWmsTileProvider');
}
// Build the WMS URL
final url = wmsOptions.getUrl(coordinates, 256, false);
// Log the URL for debugging
debugPrint('🌐 WMS Request URL: $url');
// Use NetworkImage to load the tile
return NetworkImage(url, headers: {'User-Agent': 'MeshCore SAR'})
.loadImage(NetworkImage(url), decode);
}
@override
Future<DebugNetworkTileProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture<DebugNetworkTileProvider>(this);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is DebugNetworkTileProvider &&
other.coordinates == coordinates &&
other.options == options;
}
@override
int get hashCode => Object.hash(coordinates, options);
}

287
lib/theme/app_theme.dart Normal file
View File

@@ -0,0 +1,287 @@
import 'package:flutter/material.dart';
enum AppThemeMode {
light,
dark,
sarRed,
sarGreen,
sarNavyBlue,
system,
}
class AppTheme {
// Light theme (Blue)
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.light,
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
),
);
}
// Dark theme (Blue)
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
),
);
}
// SAR Red theme (Emergency/Alert tones)
static ThemeData get sarRedTheme {
return ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.dark(
brightness: Brightness.dark,
primary: Color(0xFFFF5252), // Bright red
onPrimary: Color(0xFF000000),
primaryContainer: Color(0xFF8B0000), // Dark red
onPrimaryContainer: Color(0xFFFFCDD2),
secondary: Color(0xFFFF8A80),
onSecondary: Color(0xFF000000),
secondaryContainer: Color(0xFFB71C1C),
onSecondaryContainer: Color(0xFFFFCDD2),
tertiary: Color(0xFFFF6E40),
onTertiary: Color(0xFF000000),
error: Color(0xFFCF6679),
onError: Color(0xFF000000),
surface: Color(0xFF1A0000), // Very dark red-tinted
onSurface: Color(0xFFFFEBEE),
surfaceContainerHighest: Color(0xFF2D0000),
onSurfaceVariant: Color(0xFFFFCDD2),
outline: Color(0xFFFF5252),
),
scaffoldBackgroundColor: const Color(0xFF1A0000),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: Color(0xFF2D0000),
foregroundColor: Color(0xFFFFEBEE),
),
cardTheme: CardThemeData(
elevation: 2,
color: const Color(0xFF2D0000),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(
color: Color(0xFFFF5252),
width: 1,
),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFFF5252)),
),
filled: true,
fillColor: const Color(0xFF2D0000),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFF5252),
foregroundColor: const Color(0xFF000000),
),
),
);
}
// SAR Green theme (All Clear/Safe tones)
static ThemeData get sarGreenTheme {
return ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.dark(
brightness: Brightness.dark,
primary: Color(0xFF69F0AE), // Bright green
onPrimary: Color(0xFF000000),
primaryContainer: Color(0xFF00695C), // Dark teal-green
onPrimaryContainer: Color(0xFFB9F6CA),
secondary: Color(0xFF64FFDA),
onSecondary: Color(0xFF000000),
secondaryContainer: Color(0xFF004D40),
onSecondaryContainer: Color(0xFFB9F6CA),
tertiary: Color(0xFF1DE9B6),
onTertiary: Color(0xFF000000),
error: Color(0xFFCF6679),
onError: Color(0xFF000000),
surface: Color(0xFF001A12), // Very dark green-tinted
onSurface: Color(0xFFE8F5E9),
surfaceContainerHighest: Color(0xFF002D1F),
onSurfaceVariant: Color(0xFFB9F6CA),
outline: Color(0xFF69F0AE),
),
scaffoldBackgroundColor: const Color(0xFF001A12),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: Color(0xFF002D1F),
foregroundColor: Color(0xFFE8F5E9),
),
cardTheme: CardThemeData(
elevation: 2,
color: const Color(0xFF002D1F),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(
color: Color(0xFF69F0AE),
width: 1,
),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF69F0AE)),
),
filled: true,
fillColor: const Color(0xFF002D1F),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF69F0AE),
foregroundColor: const Color(0xFF000000),
),
),
);
}
// SAR Navy Blue theme (Professional/Operations tones)
static ThemeData get sarNavyBlueTheme {
return ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.dark(
brightness: Brightness.dark,
primary: Color(0xFF5C9FFF), // Bright navy blue
onPrimary: Color(0xFF000000),
primaryContainer: Color(0xFF003366), // Dark navy
onPrimaryContainer: Color(0xFFBBDEFF),
secondary: Color(0xFF80B3FF),
onSecondary: Color(0xFF000000),
secondaryContainer: Color(0xFF002244),
onSecondaryContainer: Color(0xFFBBDEFF),
tertiary: Color(0xFF4DB8FF),
onTertiary: Color(0xFF000000),
error: Color(0xFFCF6679),
onError: Color(0xFF000000),
surface: Color(0xFF00111C), // Very dark blue-tinted
onSurface: Color(0xFFE3F2FD),
surfaceContainerHighest: Color(0xFF001A2D),
onSurfaceVariant: Color(0xFFBBDEFF),
outline: Color(0xFF5C9FFF),
),
scaffoldBackgroundColor: const Color(0xFF00111C),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: Color(0xFF001A2D),
foregroundColor: Color(0xFFE3F2FD),
),
cardTheme: CardThemeData(
elevation: 2,
color: const Color(0xFF001A2D),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(
color: Color(0xFF5C9FFF),
width: 1,
),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF5C9FFF)),
),
filled: true,
fillColor: const Color(0xFF001A2D),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF5C9FFF),
foregroundColor: const Color(0xFF000000),
),
),
);
}
// Get theme by mode
static ThemeData getTheme(AppThemeMode mode, Brightness systemBrightness) {
switch (mode) {
case AppThemeMode.light:
return lightTheme;
case AppThemeMode.dark:
return darkTheme;
case AppThemeMode.sarRed:
return sarRedTheme;
case AppThemeMode.sarGreen:
return sarGreenTheme;
case AppThemeMode.sarNavyBlue:
return sarNavyBlueTheme;
case AppThemeMode.system:
return systemBrightness == Brightness.dark ? darkTheme : lightTheme;
}
}
// Get display name for theme mode
static String getThemeDisplayName(AppThemeMode mode) {
switch (mode) {
case AppThemeMode.light:
return 'Light';
case AppThemeMode.dark:
return 'Dark';
case AppThemeMode.sarRed:
return 'SAR Red (Alert)';
case AppThemeMode.sarGreen:
return 'SAR Green (Safe)';
case AppThemeMode.sarNavyBlue:
return 'SAR Navy Blue (Ops)';
case AppThemeMode.system:
return 'Auto (System)';
}
}
// Get theme mode from string
static AppThemeMode themeFromString(String themeName) {
return AppThemeMode.values.firstWhere(
(mode) => mode.name == themeName,
orElse: () => AppThemeMode.system,
);
}
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
/// Utility class for battery and signal display helpers
/// Used across home_screen.dart and contact_tile.dart
class BatteryDisplayHelper {
/// Get battery icon based on percentage level
static IconData getBatteryIcon(double percentage) {
if (percentage > 80) return Icons.battery_full;
if (percentage > 50) return Icons.battery_5_bar;
if (percentage > 20) return Icons.battery_3_bar;
return Icons.battery_1_bar;
}
/// Get battery color based on percentage level
static Color getBatteryColor(double percentage) {
if (percentage > 50) return Colors.green;
if (percentage > 20) return Colors.orange;
return Colors.red;
}
/// Get signal strength color based on RSSI value
static Color getSignalColor(int rssi) {
if (rssi > -60) return Colors.green;
if (rssi > -70) return Colors.orange;
return Colors.red;
}
}

View File

@@ -0,0 +1,8 @@
import 'package:flutter/foundation.dart';
/// Debug print that only outputs in debug builds
void debugPrint(Object? message) {
if (kDebugMode) {
debugPrint(message);
}
}

View File

@@ -0,0 +1,161 @@
import 'dart:convert';
import '../models/map_drawing.dart';
/// Parser for drawing messages transmitted over mesh network
class DrawingMessageParser {
/// Drawing message prefix
static const String prefix = 'D:';
/// Check if message is a drawing message
static bool isDrawingMessage(String text) {
return text.startsWith(prefix);
}
/// Parse drawing message text into MapDrawing object
/// senderName and messageId should be extracted from packet metadata
/// Returns null if parsing fails
static MapDrawing? parseDrawingMessage(
String text, {
String? senderName,
String? messageId,
}) {
if (!isDrawingMessage(text)) {
return null;
}
try {
// Remove prefix
final jsonStr = text.substring(prefix.length);
// Parse JSON
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
// Use ultra-compact network format parser
// Sender name and message ID come from packet metadata, not JSON
return MapDrawing.fromNetworkJson(
json,
senderName: senderName,
messageId: messageId,
);
} catch (e) {
return null;
}
}
/// Create drawing message text from MapDrawing object
/// Sender will be determined from packet metadata on receiving end
static String createDrawingMessage(MapDrawing drawing) {
final json = drawing.toNetworkJson();
final jsonStr = jsonEncode(json).toString();
return '$prefix$jsonStr';
}
/// Get drawing type display name from drawing message text
/// Returns "Line" or "Rectangle", or null if parsing fails
static String? getDrawingTypeDisplay(String text) {
if (!isDrawingMessage(text)) return null;
try {
final jsonStr = text.substring(prefix.length);
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
final typeNum = json['t'] as int?;
if (typeNum == null) return null;
switch (typeNum) {
case 0:
return 'Line';
case 1:
return 'Rectangle';
default:
return null;
}
} catch (e) {
return null;
}
}
/// Get color name from drawing message text
/// Returns color name like "Red", "Blue", etc., or null if parsing fails
static String? getColorName(String text) {
if (!isDrawingMessage(text)) return null;
try {
final jsonStr = text.substring(prefix.length);
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
final colorIndex = json['c'] as int?;
if (colorIndex == null) return null;
// Color mapping from DrawingColor enum
const colorNames = [
'Red', // 0
'Blue', // 1
'Green', // 2
'Yellow', // 3
'Orange', // 4
'Purple', // 5
'Pink', // 6
'Cyan', // 7
];
if (colorIndex >= 0 && colorIndex < colorNames.length) {
return colorNames[colorIndex];
}
return null;
} catch (e) {
return null;
}
}
/// Get drawing metadata for display in message bubbles
/// Returns map with type, color, and pointCount, or null if parsing fails
static Map<String, dynamic>? getDrawingMetadata(String text) {
if (!isDrawingMessage(text)) return null;
try {
final jsonStr = text.substring(prefix.length);
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
final typeNum = json['t'] as int?;
final colorIndex = json['c'] as int?;
if (typeNum == null || colorIndex == null) return null;
// Get type display name
String type;
int? pointCount;
switch (typeNum) {
case 0: // Line
type = 'Line';
final points = json['p'] as List?;
pointCount = points != null ? points.length ~/ 2 : null;
break;
case 1: // Rectangle
type = 'Rectangle';
pointCount = 4; // Rectangles always have 4 corners
break;
default:
return null;
}
// Get color name
const colorNames = [
'Red', 'Blue', 'Green', 'Yellow', 'Orange', 'Purple', 'Pink', 'Cyan',
];
final color = colorIndex >= 0 && colorIndex < colorNames.length
? colorNames[colorIndex]
: 'Unknown';
return {
'type': type,
'color': color,
'pointCount': pointCount,
};
} catch (e) {
return null;
}
}
}

View File

@@ -0,0 +1,18 @@
import 'dart:typed_data';
/// Extension on Uint8List to provide comparison functionality for public keys.
///
/// This extension is used to compare MeshCore public keys (32 bytes) or their
/// prefixes (6 bytes) across the application.
extension Uint8ListComparison on Uint8List {
/// Compares this Uint8List with another for exact equality.
///
/// Returns true if both lists have the same length and identical bytes.
bool matches(Uint8List other) {
if (length != other.length) return false;
for (int i = 0; i < length; i++) {
if (this[i] != other[i]) return false;
}
return true;
}
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/widgets.dart';
import '../models/message.dart';
import '../l10n/app_localizations.dart';
/// Extension for Message to provide localized delivery status
extension MessageLocalization on Message {
/// Get localized delivery status text
String getLocalizedDeliveryStatus(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
// For channel messages, show echo count instead of delivery status
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
if (echoCount == 0) {
return l10n.broadcast; // "Broadcast (no echoes yet)"
} else if (echoCount == 1) {
return 'Rebroadcast by 1 node';
} else {
return 'Rebroadcast by $echoCount nodes';
}
}
switch (deliveryStatus) {
case MessageDeliveryStatus.sending:
return l10n.sending;
case MessageDeliveryStatus.sent:
return l10n.sent;
case MessageDeliveryStatus.delivered:
if (roundTripTimeMs != null) {
return l10n.deliveredWithTime(roundTripTimeMs!);
}
return l10n.delivered;
case MessageDeliveryStatus.failed:
return l10n.failed;
case MessageDeliveryStatus.received:
return '';
}
}
/// Get localized time ago string
String getLocalizedTimeAgo(BuildContext context) {
final diff = DateTime.now().difference(sentAt);
final l10n = AppLocalizations.of(context)!;
if (diff.inMinutes < 1) return l10n.justNow;
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
return l10n.daysAgo(diff.inDays);
}
}

View File

@@ -0,0 +1,407 @@
import 'dart:typed_data';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import '../models/contact.dart';
import '../models/contact_telemetry.dart';
import '../models/message.dart';
import '../l10n/app_localizations.dart';
/// Generates sample data for testing/demo purposes
class SampleDataGenerator {
static final Random _random = Random();
/// Generate sample contacts around a center location
static List<Contact> generateContacts({
required LatLng centerLocation,
required AppLocalizations l10n,
int teamMemberCount = 5,
int channelCount = 2,
}) {
final contacts = <Contact>[];
final now = DateTime.now();
final teamNames = [
'👮${l10n.samplePoliceLead}',
'🚁${l10n.sampleDroneOperator}',
'🧑🏻‍🚒${l10n.sampleFirefighterAlpha}',
'🧑‍⚕️${l10n.sampleMedicCharlie}',
'📡${l10n.sampleCommandDelta}',
'🚒${l10n.sampleFireEngine}',
'👨‍✈️${l10n.sampleAirSupport}',
'🧑‍💼${l10n.sampleBaseCoordinator}',
];
final channelNames = [
l10n.general,
l10n.channelEmergency,
l10n.channelCoordination,
l10n.channelUpdates,
];
// Generate team members (chat contacts)
for (int i = 0; i < teamMemberCount && i < teamNames.length; i++) {
// Generate location within ~1km radius
final latOffset = (_random.nextDouble() - 0.5) * 0.02; // ~1km
final lonOffset = (_random.nextDouble() - 0.5) * 0.02;
final lat = centerLocation.latitude + latOffset;
final lon = centerLocation.longitude + lonOffset;
// Generate random public key
final publicKey = Uint8List.fromList(
List.generate(32, (_) => _random.nextInt(256)),
);
// Random battery 20-100%
final battery = 20 + _random.nextInt(81);
// Random temperature 15-35°C
final temp = 15.0 + _random.nextDouble() * 20.0;
final telemetry = ContactTelemetry(
gpsLocation: LatLng(lat, lon),
batteryPercentage: battery.toDouble(),
batteryMilliVolts: 3000.0 + (battery / 100.0) * 1200.0,
temperature: temp,
timestamp: now.subtract(Duration(minutes: _random.nextInt(10))),
);
final contact = Contact(
publicKey: publicKey,
type: ContactType.chat,
flags: 0,
outPathLen: 1,
outPath: Uint8List(32),
advName: teamNames[i],
lastAdvert: now.millisecondsSinceEpoch ~/ 1000,
advLat: (lat * 1e6).toInt(),
advLon: (lon * 1e6).toInt(),
lastMod: now.millisecondsSinceEpoch ~/ 1000,
telemetry: telemetry,
);
contacts.add(contact);
}
// Generate channels/rooms
for (int i = 0; i < channelCount && i < channelNames.length; i++) {
// Generate random public key
final publicKey = Uint8List.fromList(
List.generate(32, (_) => _random.nextInt(256)),
);
// Channel index stored in outPath[0]
final outPath = Uint8List(32);
outPath[0] = i; // Channel index
final channel = Contact(
publicKey: publicKey,
type: ContactType.room,
flags: 0,
outPathLen: 1,
outPath: outPath,
advName: channelNames[i],
lastAdvert: now.millisecondsSinceEpoch ~/ 1000,
advLat: 0, // Channels don't have location
advLon: 0,
lastMod: now.millisecondsSinceEpoch ~/ 1000,
);
contacts.add(channel);
}
return contacts;
}
/// Generate sample SAR markers around a center location
static List<Message> generateSarMarkerMessages({
required LatLng centerLocation,
required AppLocalizations l10n,
int foundPersonCount = 2,
int fireCount = 1,
int stagingCount = 1,
int objectCount = 1,
}) {
final messages = <Message>[];
final now = DateTime.now();
int messageId = 1;
// Generate found person markers
for (int i = 0; i < foundPersonCount; i++) {
final latOffset = (_random.nextDouble() - 0.5) * 0.015;
final lonOffset = (_random.nextDouble() - 0.5) * 0.015;
final lat = centerLocation.latitude + latOffset;
final lon = centerLocation.longitude + lonOffset;
final senderKey = Uint8List.fromList(
List.generate(32, (_) => _random.nextInt(256)),
);
final timestamp = now.subtract(Duration(minutes: 10 + i * 5));
messages.add(Message(
id: 'sample_fp_$messageId',
messageType: MessageType.contact,
senderPublicKeyPrefix: senderKey.sublist(0, 6),
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
text: 'S:🧑:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}',
receivedAt: timestamp,
isSarMarker: true,
sarGpsCoordinates: LatLng(lat, lon),
sarCustomEmoji: '🧑',
senderName: l10n.sampleTeamMember,
));
messageId++;
}
// Generate fire markers
for (int i = 0; i < fireCount; i++) {
final latOffset = (_random.nextDouble() - 0.5) * 0.015;
final lonOffset = (_random.nextDouble() - 0.5) * 0.015;
final lat = centerLocation.latitude + latOffset;
final lon = centerLocation.longitude + lonOffset;
final senderKey = Uint8List.fromList(
List.generate(32, (_) => _random.nextInt(256)),
);
final timestamp = now.subtract(Duration(minutes: 20 + i * 5));
messages.add(Message(
id: 'sample_fire_$messageId',
messageType: MessageType.contact,
senderPublicKeyPrefix: senderKey.sublist(0, 6),
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
text: 'S:🔥:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}',
receivedAt: timestamp,
isSarMarker: true,
sarGpsCoordinates: LatLng(lat, lon),
sarCustomEmoji: '🔥',
senderName: l10n.sampleScout,
));
messageId++;
}
// Generate staging area markers
for (int i = 0; i < stagingCount; i++) {
final latOffset = (_random.nextDouble() - 0.5) * 0.015;
final lonOffset = (_random.nextDouble() - 0.5) * 0.015;
final lat = centerLocation.latitude + latOffset;
final lon = centerLocation.longitude + lonOffset;
final senderKey = Uint8List.fromList(
List.generate(32, (_) => _random.nextInt(256)),
);
final timestamp = now.subtract(Duration(minutes: 30 + i * 5));
messages.add(Message(
id: 'sample_staging_$messageId',
messageType: MessageType.contact,
senderPublicKeyPrefix: senderKey.sublist(0, 6),
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
text: 'S:🏕️:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}',
receivedAt: timestamp,
isSarMarker: true,
sarGpsCoordinates: LatLng(lat, lon),
sarCustomEmoji: '🏕️',
senderName: l10n.sampleBase,
));
messageId++;
}
// Generate object markers
for (int i = 0; i < objectCount; i++) {
final latOffset = (_random.nextDouble() - 0.5) * 0.015;
final lonOffset = (_random.nextDouble() - 0.5) * 0.015;
final lat = centerLocation.latitude + latOffset;
final lon = centerLocation.longitude + lonOffset;
final senderKey = Uint8List.fromList(
List.generate(32, (_) => _random.nextInt(256)),
);
final timestamp = now.subtract(Duration(minutes: 40 + i * 5));
final notes = [
l10n.sampleObjectBackpack,
l10n.sampleObjectVehicle,
l10n.sampleObjectCamping,
l10n.sampleObjectTrailMarker,
];
messages.add(Message(
id: 'sample_object_$messageId',
messageType: MessageType.contact,
senderPublicKeyPrefix: senderKey.sublist(0, 6),
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
text: 'S:📦:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}${notes[i % notes.length]}',
receivedAt: timestamp,
isSarMarker: true,
sarGpsCoordinates: LatLng(lat, lon),
sarCustomEmoji: '📦',
senderName: l10n.sampleSearcher,
));
messageId++;
}
return messages;
}
/// Generate sample map drawings
static List<dynamic> generateDrawings({
required LatLng centerLocation,
required AppLocalizations l10n,
}) {
final drawings = <dynamic>[];
final now = DateTime.now();
// Generate a line drawing (e.g., search path)
final linePoints = <LatLng>[
LatLng(centerLocation.latitude + 0.002, centerLocation.longitude - 0.003),
LatLng(centerLocation.latitude + 0.004, centerLocation.longitude - 0.002),
LatLng(centerLocation.latitude + 0.005, centerLocation.longitude + 0.001),
LatLng(centerLocation.latitude + 0.003, centerLocation.longitude + 0.003),
];
drawings.add({
'type': 'line',
'id': 'sample_line_${now.millisecondsSinceEpoch}',
'color': Colors.blue.toARGB32(),
'createdAt': now.subtract(const Duration(minutes: 15)).toIso8601String(),
'points': linePoints.map((p) => {'lat': p.latitude, 'lon': p.longitude}).toList(),
'sender': l10n.sampleTeamMember,
});
// Generate a rectangle drawing (e.g., search area)
drawings.add({
'type': 'rectangle',
'id': 'sample_rect_${now.millisecondsSinceEpoch + 1}',
'color': Colors.red.toARGB32(),
'createdAt': now.subtract(const Duration(minutes: 10)).toIso8601String(),
'topLeft': {
'lat': centerLocation.latitude - 0.003,
'lon': centerLocation.longitude - 0.004,
},
'bottomRight': {
'lat': centerLocation.latitude - 0.001,
'lon': centerLocation.longitude - 0.001,
},
'sender': l10n.sampleScout,
});
return drawings;
}
/// Generate sample channel messages for public channels
static List<Message> generateChannelMessages({
LatLng? centerLocation,
required AppLocalizations l10n,
int generalChannelMessages = 8,
int emergencyChannelMessages = 5,
}) {
// Use provided location or default to Ljubljana, Slovenia
final center = centerLocation ?? const LatLng(46.0569, 14.5058);
final messages = <Message>[];
final now = DateTime.now();
int messageId = 1000; // Start with high ID to avoid conflicts
// Sample messages for General channel (index 0)
final generalMessages = [
l10n.sampleMsgAllTeamsCheckIn,
l10n.sampleMsgWeatherUpdate,
l10n.sampleMsgBaseCamp,
l10n.sampleMsgTeamAlpha,
l10n.sampleMsgRadioCheck,
l10n.sampleMsgWaterSupply,
l10n.sampleMsgTeamBravo,
l10n.sampleMsgEtaRallyPoint,
l10n.sampleMsgSupplyDrop,
l10n.sampleMsgDroneSurvey,
l10n.sampleMsgTeamCharlie,
l10n.sampleMsgRadioDiscipline,
];
// Sample messages for Emergency channel (index 1)
// Mix regular messages and SAR markers
final emergencyMessages = [
l10n.sampleMsgUrgentMedical,
'S:🧑:${center.latitude.toStringAsFixed(5)},${(center.longitude + 0.005).toStringAsFixed(5)}${l10n.sampleMsgAdultMale}',
l10n.sampleMsgFireSpotted,
'S:🔥:${(center.latitude + 0.008).toStringAsFixed(5)},${(center.longitude + 0.003).toStringAsFixed(5)}${l10n.sampleMsgSpreadingRapidly}',
l10n.sampleMsgPriorityHelicopter,
l10n.sampleMsgMedicalTeamEnRoute,
l10n.sampleMsgEvacHelicopter,
l10n.sampleMsgEmergencyResolved,
'S:🏕️:${(center.latitude - 0.002).toStringAsFixed(5)},${(center.longitude - 0.004).toStringAsFixed(5)}${l10n.sampleMsgEmergencyStagingArea}',
l10n.sampleMsgEmergencyServices,
];
final teamNames = [
l10n.sampleAlphaTeamLead,
l10n.sampleBravoScout,
l10n.sampleCharlieMedic,
l10n.sampleDeltaNavigator,
l10n.sampleEchoSupport,
l10n.sampleBaseCommand,
l10n.sampleFieldCoordinator,
l10n.sampleMedicalTeam,
];
// Generate General channel messages
for (int i = 0; i < generalChannelMessages && i < generalMessages.length; i++) {
final senderKey = Uint8List.fromList(
List.generate(32, (_) => _random.nextInt(256)),
);
// Messages spread over the last 2 hours
final minutesAgo = 120 - (i * 15) - _random.nextInt(10);
final timestamp = now.subtract(Duration(minutes: minutesAgo));
messages.add(Message(
id: 'sample_general_$messageId',
messageType: MessageType.channel,
channelIdx: 0, // General channel
senderPublicKeyPrefix: senderKey.sublist(0, 6),
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
text: generalMessages[i],
receivedAt: timestamp,
senderName: teamNames[_random.nextInt(teamNames.length)],
));
messageId++;
}
// Generate Emergency channel messages
for (int i = 0; i < emergencyChannelMessages && i < emergencyMessages.length; i++) {
final senderKey = Uint8List.fromList(
List.generate(32, (_) => _random.nextInt(256)),
);
// Emergency messages more recent (last hour)
final minutesAgo = 60 - (i * 10) - _random.nextInt(5);
final timestamp = now.subtract(Duration(minutes: minutesAgo));
messages.add(Message(
id: 'sample_emergency_$messageId',
messageType: MessageType.channel,
channelIdx: 1, // Emergency channel
senderPublicKeyPrefix: senderKey.sublist(0, 6),
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
text: emergencyMessages[i],
receivedAt: timestamp,
senderName: teamNames[_random.nextInt(teamNames.length)],
));
messageId++;
}
return messages;
}
}

View File

@@ -0,0 +1,24 @@
import 'package:flutter/widgets.dart';
import '../models/sar_marker.dart';
import '../l10n/app_localizations.dart';
/// Extension for SarMarkerType to provide localized display names
extension SarMarkerTypeLocalization on SarMarkerType {
/// Get localized display name for this SAR marker type
String getLocalizedName(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
switch (this) {
case SarMarkerType.foundPerson:
return l10n.foundPerson;
case SarMarkerType.fire:
return l10n.fire;
case SarMarkerType.stagingArea:
return l10n.stagingArea;
case SarMarkerType.object:
return 'Object'; // Not commonly used, keeping English for now
case SarMarkerType.unknown:
return 'Unknown'; // Not commonly used, keeping English for now
}
}
}

View File

@@ -0,0 +1,227 @@
import 'package:latlong2/latlong.dart';
import '../models/sar_marker.dart';
import '../models/message.dart';
/// Parser for SAR (Search & Rescue) special messages
/// Old format: `S:<emoji>:<latitude>,<longitude>:<optional_message>`
/// New format: `S:<emoji>:<colorIndex>:<latitude>,<longitude>:<optional_message>`
/// Examples:
/// S:🧑:37.7749,-122.4194 (old format)
/// S:🧑:2:37.7749,-122.4194 (new format with green color)
/// S:🔥:0:40.7128,-74.0060:Large wildfire spreading (new format with red color)
class SarMessageParser {
// Regex for new format with color index: S:emoji:colorIndex:lat,lon:notes
// Captures: emoji, colorIndex (single digit), latitude, longitude, optional message
static final RegExp _sarPatternNew = RegExp(
r'^S:([^:]+):(\d):(-?\d+\.?\d*),(-?\d+\.?\d*):?(.*)',
multiLine: false,
);
// Regex for old format (backward compatibility): S:emoji:lat,lon:notes
// Captures: emoji, latitude, longitude, optional message
static final RegExp _sarPatternOld = RegExp(
r'^S:([^:]+):(-?\d+\.?\d*),(-?\d+\.?\d*):?(.*)',
multiLine: false,
);
/// Check if a message is a SAR marker message
static bool isSarMessage(String text) {
// Extract just the first line for matching
final firstLine = text.trim().split('\n').first;
return firstLine.startsWith('S:') &&
(_sarPatternNew.hasMatch(firstLine) ||
_sarPatternOld.hasMatch(firstLine));
}
/// Parse a SAR message and extract marker information
/// Returns null if the message is not a valid SAR message
/// Supports both old format (S:emoji:lat,lon:notes) and new format (S:emoji:colorIndex:lat,lon:notes)
static SarMarkerInfo? parse(String text) {
final trimmed = text.trim();
if (!trimmed.startsWith('S:')) return null;
// Extract first line (actual SAR marker)
final firstLine = trimmed.split('\n').first;
// Try new format first (with color index)
var match = _sarPatternNew.firstMatch(firstLine);
bool isNewFormat = match != null;
// If new format didn't match, try old format
if (match == null) {
match = _sarPatternOld.firstMatch(firstLine);
if (match == null) return null;
}
try {
String emoji;
double latitude;
double longitude;
String? inlineMessage;
int? colorIndex;
if (isNewFormat) {
// New format: S:emoji:colorIndex:lat,lon:notes
emoji = match.group(1)!;
colorIndex = int.parse(match.group(2)!);
latitude = double.parse(match.group(3)!);
longitude = double.parse(match.group(4)!);
inlineMessage = match.group(5)?.trim();
} else {
// Old format: S:emoji:lat,lon:notes
emoji = match.group(1)!;
colorIndex = null; // No color index in old format
latitude = double.parse(match.group(2)!);
longitude = double.parse(match.group(3)!);
inlineMessage = match.group(4)?.trim();
}
// Validate coordinates
if (latitude < -90 || latitude > 90) return null;
if (longitude < -180 || longitude > 180) return null;
// Validate color index if present
if (colorIndex != null && (colorIndex < 0 || colorIndex > 7)) {
colorIndex = null; // Invalid index, ignore it
}
final markerType = SarMarkerType.fromEmoji(emoji);
final location = LatLng(latitude, longitude);
// Combine inline message with multi-line notes
String? notes;
if (inlineMessage != null && inlineMessage.isNotEmpty) {
notes = inlineMessage;
}
// Check for multi-line notes (lines after the first line)
final additionalNotes = extractNotes(text);
if (additionalNotes != null) {
notes = notes != null ? '$notes\n$additionalNotes' : additionalNotes;
}
return SarMarkerInfo(
type: markerType,
location: location,
emoji: emoji,
notes: notes,
colorIndex: colorIndex,
);
} catch (e) {
return null;
}
}
/// Enhance a Message with SAR marker information
static Message enhanceMessage(Message message) {
final sarInfo = parse(message.text);
if (sarInfo == null) return message;
return message.copyWith(
isSarMarker: true,
sarGpsCoordinates: sarInfo.location,
sarNotes: sarInfo.notes, // Extract and store notes
sarCustomEmoji: sarInfo.emoji, // Always store emoji for type inference
sarColorIndex: sarInfo.colorIndex, // Store color index
);
}
/// Create a SAR marker message text (new format with color index)
static String createSarMessage({
required SarMarkerType type,
required LatLng location,
String? notes,
int? colorIndex,
}) {
// New format: S:emoji:colorIndex:lat,lon:notes
final colorIdx = colorIndex ?? 0; // Default to red if not specified
final text =
'S:${type.emoji}:$colorIdx:${location.latitude.toString()},${location.longitude.toString()}';
if (notes != null && notes.isNotEmpty) {
// Use colon-separated format for inline message
return '$text:$notes';
}
return text;
}
/// Extract additional notes from SAR message (text after the marker)
static String? extractNotes(String text) {
final trimmed = text.trim();
final lines = trimmed.split('\n');
if (lines.length <= 1) return null;
// Everything after the first line is considered notes
return lines.sublist(1).join('\n').trim();
}
/// Validate SAR message format
static bool isValidFormat(String text) {
return isSarMessage(text) && parse(text) != null;
}
/// Get a user-friendly error message for invalid SAR format
static String? getFormatError(String text) {
if (!text.trim().startsWith('S:')) {
return 'SAR message must start with "S:"';
}
final parts = text.trim().split(':');
if (parts.length < 3) {
return 'Invalid format. Use: S:<emoji>:<latitude>,<longitude>';
}
final emoji = parts[1];
if (emoji.isEmpty) {
return 'Missing emoji marker (🧑, 🔥, or 🏕️)';
}
final coords = parts[2];
if (!coords.contains(',')) {
return 'Coordinates must be separated by comma';
}
final coordParts = coords.split(',');
if (coordParts.length != 2) {
return 'Invalid coordinates format';
}
try {
final lat = double.parse(coordParts[0]);
final lon = double.parse(coordParts[1]);
if (lat < -90 || lat > 90) {
return 'Latitude must be between -90 and 90';
}
if (lon < -180 || lon > 180) {
return 'Longitude must be between -180 and 180';
}
} catch (e) {
return 'Invalid coordinate values';
}
return null;
}
}
/// Parsed SAR marker information
class SarMarkerInfo {
final SarMarkerType type;
final LatLng location;
final String emoji;
final String? notes;
final int?
colorIndex; // Color index from standard palette (0-7), null for backward compatibility
SarMarkerInfo({
required this.type,
required this.location,
required this.emoji,
this.notes,
this.colorIndex,
});
@override
String toString() {
return 'SarMarkerInfo(type: ${type.displayName}, location: $location, colorIndex: $colorIndex, notes: $notes)';
}
}

View File

@@ -0,0 +1,87 @@
import 'dart:math' show Point;
import 'dart:ui' show Rect;
import 'package:flutter_map/flutter_map.dart';
import 'package:proj4dart/proj4dart.dart' as proj4;
/// EPSG:3794 - Slovenia 1996 / Slovene National Grid
/// Transverse Mercator projection for Slovenia
///
/// Official definition from https://epsg.io/3794:
/// +proj=tmerc +lat_0=0 +lon_0=15 +k=0.9999 +x_0=500000 +y_0=-5000000
/// +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs
///
/// This CRS is used by Slovenian government WMS services (prostor.zgs.gov.si)
/// Register and get EPSG:3794 projection
proj4.Projection getEpsg3794Projection() {
const epsg3794Def =
'+proj=tmerc +lat_0=0 +lon_0=15 +k=0.9999 '
'+x_0=500000 +y_0=-5000000 +ellps=GRS80 '
'+towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs';
// Register projection if not already registered
try {
return proj4.Projection.get('EPSG:3794') ??
proj4.Projection.add('EPSG:3794', epsg3794Def);
} catch (e) {
// If already registered, get it
return proj4.Projection.get('EPSG:3794')!;
}
}
/// Create Proj4Crs for EPSG:3794
///
/// Configuration matches the GeoWebCache tile grid used by prostor.zgs.gov.si
///
/// Official tile grid from WMTS GetCapabilities:
/// - TopLeftCorner: 373217.6542445397, 246158.298050262
/// - Bounds: X: 373217.65 to 695777.65, Y: 31118.30 to 246158.30
/// - Tile size: 256x256 pixels
/// - Scale denominators converted to resolutions using: resolution = scaleDenom * 0.00028
Crs getSlovenianCrs() {
final projection = getEpsg3794Projection();
// Resolutions calculated from GeoWebCache scale denominators
// Formula: resolution (m/px) = scaleDenominator * 0.00028 (OGC standard)
final resolutions = <double>[
420.0, // Zoom 0 - ScaleDenom: 1500000
280.0, // Zoom 1 - ScaleDenom: 1000000
210.0, // Zoom 2 - ScaleDenom: 750000
140.0, // Zoom 3 - ScaleDenom: 500000
70.0, // Zoom 4 - ScaleDenom: 250000
28.0, // Zoom 5 - ScaleDenom: 100000
14.0, // Zoom 6 - ScaleDenom: 50000
7.0, // Zoom 7 - ScaleDenom: 25000
4.2, // Zoom 8 - ScaleDenom: 15000
2.8, // Zoom 9 - ScaleDenom: 10000
1.4, // Zoom 10 - ScaleDenom: 5000
0.56, // Zoom 11 - ScaleDenom: 2000
0.28, // Zoom 12 - ScaleDenom: 1000
0.14, // Zoom 13 - ScaleDenom: 500
0.07, // Zoom 14 - ScaleDenom: 250
0.028, // Zoom 15 - ScaleDenom: 100
];
// Bounds from WMS capabilities (actual data extent in Slovenia)
final bounds = Rect.fromLTRB(
373217.65, // min X (west)
31118.30, // min Y (south) - top in Rect coordinates
695777.65, // max X (east)
246158.30, // max Y (north) - bottom in Rect coordinates
);
// Origin from WMTS TileMatrixSet TopLeftCorner
// This is the top-left corner of the tile pyramid (min X, max Y)
final origin = Point<double>(373217.6542445397, 246158.298050262);
return Proj4Crs.fromFactory(
code: 'EPSG:3794',
proj4Projection: projection,
resolutions: resolutions,
bounds: bounds,
origins: [origin],
);
}
/// Singleton instance of Slovenian CRS for reuse
final Crs slovenianCrs = getSlovenianCrs();

View File

@@ -0,0 +1,25 @@
import 'package:flutter/widgets.dart';
import '../l10n/app_localizations.dart';
/// Extension to provide localized "time ago" formatting
extension TimeAgoExtension on Duration {
/// Get localized time ago string
String toLocalizedTimeAgo(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (inMinutes < 1) return l10n.justNow;
if (inMinutes < 60) return l10n.minutesAgo(inMinutes);
if (inHours < 24) return l10n.hoursAgo(inHours);
return l10n.daysAgo(inDays);
}
/// Get localized time ago string with seconds support
String toLocalizedTimeAgoWithSeconds(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (inSeconds < 60) return l10n.secondsAgo(inSeconds);
if (inMinutes < 60) return l10n.minutesAgo(inMinutes);
if (inHours < 24) return l10n.hoursAgo(inHours);
return l10n.daysAgo(inDays);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/messages_provider.dart';
/// Toast logger utility - replaces SnackBar with system messages in the channels tab
class ToastLogger {
/// Log an info message
static void info(BuildContext context, String message) {
_log(context, message, 'info');
}
/// Log a success message
static void success(BuildContext context, String message) {
_log(context, message, 'success');
}
/// Log a warning message
static void warning(BuildContext context, String message) {
_log(context, message, 'warning');
}
/// Log an error message
static void error(BuildContext context, String message) {
_log(context, message, 'error');
}
/// Internal method to log a system message
static void _log(BuildContext context, String message, String level) {
try {
final messagesProvider = context.read<MessagesProvider>();
messagesProvider.logSystemMessage(text: message, level: level);
} catch (e) {
// Fallback to print if provider is not available
debugPrint('[$level] $message');
}
}
}

Some files were not shown because too many files have changed in this diff Show More