mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: MeshCore SAR - Flutter BLE mesh radio companion app
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2835
lib/l10n/app_de.arb
Normal file
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
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
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
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
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
2838
lib/l10n/app_it.arb
Normal file
File diff suppressed because it is too large
Load Diff
4106
lib/l10n/app_localizations.dart
Normal file
4106
lib/l10n/app_localizations.dart
Normal file
File diff suppressed because it is too large
Load Diff
2278
lib/l10n/app_localizations_de.dart
Normal file
2278
lib/l10n/app_localizations_de.dart
Normal file
File diff suppressed because it is too large
Load Diff
2251
lib/l10n/app_localizations_en.dart
Normal file
2251
lib/l10n/app_localizations_en.dart
Normal file
File diff suppressed because it is too large
Load Diff
2282
lib/l10n/app_localizations_es.dart
Normal file
2282
lib/l10n/app_localizations_es.dart
Normal file
File diff suppressed because it is too large
Load Diff
2288
lib/l10n/app_localizations_fr.dart
Normal file
2288
lib/l10n/app_localizations_fr.dart
Normal file
File diff suppressed because it is too large
Load Diff
2263
lib/l10n/app_localizations_hr.dart
Normal file
2263
lib/l10n/app_localizations_hr.dart
Normal file
File diff suppressed because it is too large
Load Diff
2277
lib/l10n/app_localizations_it.dart
Normal file
2277
lib/l10n/app_localizations_it.dart
Normal file
File diff suppressed because it is too large
Load Diff
2265
lib/l10n/app_localizations_sl.dart
Normal file
2265
lib/l10n/app_localizations_sl.dart
Normal file
File diff suppressed because it is too large
Load Diff
1110
lib/l10n/app_sl.arb
Normal file
1110
lib/l10n/app_sl.arb
Normal file
File diff suppressed because it is too large
Load Diff
1
lib/l10n/untranslated.json
Normal file
1
lib/l10n/untranslated.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
326
lib/main.dart
Normal file
326
lib/main.dart
Normal 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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
40
lib/models/advert_location.dart
Normal file
40
lib/models/advert_location.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// Single advertisement location point in a contact's movement history
|
||||
class AdvertLocation {
|
||||
final LatLng location;
|
||||
final DateTime timestamp;
|
||||
|
||||
AdvertLocation({
|
||||
required this.location,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
/// Get friendly time ago display
|
||||
String get timeAgo {
|
||||
final diff = DateTime.now().difference(timestamp);
|
||||
if (diff.inMinutes < 1) return 'Just now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
return '${diff.inDays}d ago';
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AdvertLocation(lat: ${location.latitude.toStringAsFixed(6)}, '
|
||||
'lon: ${location.longitude.toStringAsFixed(6)}, '
|
||||
'time: ${timestamp.toIso8601String()})';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is AdvertLocation &&
|
||||
other.location.latitude == location.latitude &&
|
||||
other.location.longitude == location.longitude &&
|
||||
other.timestamp == timestamp;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(location.latitude, location.longitude, timestamp);
|
||||
}
|
||||
121
lib/models/ble_packet_log.dart
Normal file
121
lib/models/ble_packet_log.dart
Normal file
@@ -0,0 +1,121 @@
|
||||
import 'dart:typed_data';
|
||||
import '../services/meshcore_opcode_names.dart';
|
||||
|
||||
/// Decoded LOG_RX_DATA packet structure
|
||||
class LogRxDataInfo {
|
||||
final int? airtimeMs;
|
||||
final Uint8List? senderPublicKey;
|
||||
final int? ackCode;
|
||||
final List<String> embeddedStrings;
|
||||
final double entropy;
|
||||
final bool isLikelyEncrypted;
|
||||
final double? snrDb; // Signal-to-Noise Ratio in dB
|
||||
final int? rssiDbm; // Received Signal Strength Indicator in dBm
|
||||
|
||||
LogRxDataInfo({
|
||||
this.airtimeMs,
|
||||
this.senderPublicKey,
|
||||
this.ackCode,
|
||||
this.embeddedStrings = const [],
|
||||
required this.entropy,
|
||||
required this.isLikelyEncrypted,
|
||||
this.snrDb,
|
||||
this.rssiDbm,
|
||||
});
|
||||
|
||||
/// Get sender public key as hex string (short)
|
||||
String? get senderKeyShort {
|
||||
if (senderPublicKey == null || senderPublicKey!.length < 6) return null;
|
||||
return senderPublicKey!
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join(':');
|
||||
}
|
||||
|
||||
String get summary {
|
||||
final parts = <String>[];
|
||||
if (rssiDbm != null) parts.add('RSSI:${rssiDbm}dBm');
|
||||
final snr = snrDb;
|
||||
if (snr != null) parts.add('SNR:${snr.toStringAsFixed(1)}dB');
|
||||
if (airtimeMs != null) parts.add('airtime:${airtimeMs}ms');
|
||||
if (ackCode != null) parts.add('ACK:$ackCode');
|
||||
if (senderKeyShort != null) parts.add('from:$senderKeyShort');
|
||||
if (embeddedStrings.isNotEmpty) parts.add('strings:${embeddedStrings.length}');
|
||||
if (isLikelyEncrypted) parts.add('encrypted');
|
||||
return parts.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a logged BLE packet with timestamp and metadata
|
||||
class BlePacketLog {
|
||||
final DateTime timestamp;
|
||||
final Uint8List rawData;
|
||||
final PacketDirection direction;
|
||||
final int? responseCode;
|
||||
final String? description;
|
||||
final LogRxDataInfo? logRxDataInfo; // Decoded LOG_RX_DATA information
|
||||
|
||||
BlePacketLog({
|
||||
required this.timestamp,
|
||||
required this.rawData,
|
||||
required this.direction,
|
||||
this.responseCode,
|
||||
this.description,
|
||||
this.logRxDataInfo,
|
||||
});
|
||||
|
||||
/// Convert raw data to hex string for display
|
||||
String get hexData {
|
||||
return rawData.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
|
||||
}
|
||||
|
||||
/// Get opcode name for this packet
|
||||
String get opcodeName {
|
||||
if (responseCode == null) return 'N/A';
|
||||
return MeshCoreOpcodeNames.getOpcodeName(
|
||||
responseCode!,
|
||||
isTx: direction == PacketDirection.tx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get full opcode description (name + hex code)
|
||||
String get opcodeDescription {
|
||||
if (responseCode == null) return 'N/A';
|
||||
return MeshCoreOpcodeNames.getOpcodeDescription(
|
||||
responseCode!,
|
||||
isTx: direction == PacketDirection.tx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get short summary of the packet
|
||||
String get summary {
|
||||
final dir = direction == PacketDirection.rx ? 'RX' : 'TX';
|
||||
final code = responseCode != null ? '0x${responseCode!.toRadixString(16).padLeft(2, '0')}' : 'N/A';
|
||||
final name = responseCode != null ? opcodeName : '';
|
||||
return '[$dir] $name Code: $code, Size: ${rawData.length} bytes';
|
||||
}
|
||||
|
||||
/// Convert to CSV format for export
|
||||
String toCsvRow() {
|
||||
final dir = direction == PacketDirection.rx ? 'RX' : 'TX';
|
||||
final code = responseCode?.toString() ?? '';
|
||||
final name = responseCode != null ? opcodeName : '';
|
||||
final hex = hexData;
|
||||
final desc = description ?? '';
|
||||
return '${timestamp.toIso8601String()},$dir,${rawData.length},$name,$code,"$hex","$desc"';
|
||||
}
|
||||
|
||||
/// Convert to human-readable log format
|
||||
String toLogString() {
|
||||
final dir = direction == PacketDirection.rx ? 'RX' : 'TX';
|
||||
final code = responseCode != null ? ' [$opcodeDescription]' : '';
|
||||
final desc = description != null ? ' - $description' : '';
|
||||
final logRxInfo = logRxDataInfo != null ? ' [${logRxDataInfo!.summary}]' : '';
|
||||
return '${timestamp.toIso8601String()} [$dir]$code ${rawData.length} bytes: $hexData$desc$logRxInfo';
|
||||
}
|
||||
}
|
||||
|
||||
enum PacketDirection {
|
||||
rx, // Received from device
|
||||
tx, // Sent to device
|
||||
}
|
||||
173
lib/models/channel.dart
Normal file
173
lib/models/channel.dart
Normal file
@@ -0,0 +1,173 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// Channel model - represents a communication channel
|
||||
///
|
||||
/// Supports two types of channels:
|
||||
/// 1. Hash-based channels: Names starting with '#' (e.g., '#team', '#sar-ops')
|
||||
/// - Secrets are auto-generated using SHA256(name)
|
||||
/// - Same name produces same secret on all devices
|
||||
/// 2. Normal channels: Any name with explicit secret
|
||||
/// - User provides explicit 16-byte secret
|
||||
/// - Only known to those who share the secret
|
||||
class Channel {
|
||||
final int index; // 0-255
|
||||
final String name;
|
||||
final Uint8List secret; // 16 bytes
|
||||
final int? flags;
|
||||
|
||||
Channel({
|
||||
required this.index,
|
||||
required this.name,
|
||||
required this.secret,
|
||||
this.flags,
|
||||
}) {
|
||||
if (secret.length != 16) {
|
||||
throw ArgumentError('Channel secret must be exactly 16 bytes');
|
||||
}
|
||||
if (index < 0 || index > 255) {
|
||||
throw ArgumentError('Channel index must be 0-255');
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a channel with auto-generated secret for #channels
|
||||
///
|
||||
/// For #channels (name starting with '#'):
|
||||
/// - Secret is auto-generated using SHA256(name)[0:16]
|
||||
/// - Deterministic: same name = same secret across all devices
|
||||
///
|
||||
/// For normal channels:
|
||||
/// - Must provide explicit 16-byte secret
|
||||
factory Channel.create({
|
||||
required int index,
|
||||
required String name,
|
||||
Uint8List? explicitSecret,
|
||||
int? flags,
|
||||
}) {
|
||||
if (name.startsWith('#')) {
|
||||
// Hash-based channel: auto-generate secret from name
|
||||
if (explicitSecret != null) {
|
||||
throw ArgumentError(
|
||||
'Cannot provide explicit secret for #channel. Secret is auto-generated.',
|
||||
);
|
||||
}
|
||||
final secret = _generateHashChannelSecret(name);
|
||||
return Channel(index: index, name: name, secret: secret, flags: flags);
|
||||
} else {
|
||||
// Normal channel: require explicit secret
|
||||
if (explicitSecret == null || explicitSecret.length != 16) {
|
||||
throw ArgumentError(
|
||||
'Normal channels require a 16-byte secret',
|
||||
);
|
||||
}
|
||||
return Channel(
|
||||
index: index,
|
||||
name: name,
|
||||
secret: explicitSecret,
|
||||
flags: flags,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate secret for #channel using SHA256
|
||||
/// Python equivalent: hashlib.sha256(channel_name.encode()).digest()[0:16]
|
||||
static Uint8List _generateHashChannelSecret(String channelName) {
|
||||
final bytes = utf8.encode(channelName);
|
||||
final digest = sha256.convert(bytes);
|
||||
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
||||
}
|
||||
|
||||
/// Create the default public channel (channel 0)
|
||||
/// Uses the well-known pre-shared key from MeshCore
|
||||
factory Channel.publicChannel() {
|
||||
return Channel(
|
||||
index: 0,
|
||||
name: 'Public Channel',
|
||||
secret: Uint8List.fromList([
|
||||
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
|
||||
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72,
|
||||
]),
|
||||
flags: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if this is a hash-based channel (name starts with '#')
|
||||
bool get isHashChannel => name.startsWith('#');
|
||||
|
||||
/// Display name for the channel
|
||||
/// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N"
|
||||
String get displayName {
|
||||
if (index == 0) {
|
||||
return name.isEmpty ? 'Public' : name;
|
||||
}
|
||||
return name.isEmpty ? 'Channel $index' : name;
|
||||
}
|
||||
|
||||
/// Check if channel is the public channel (index 0)
|
||||
bool get isPublicChannel => index == 0;
|
||||
|
||||
/// Check if channel has a custom name
|
||||
bool get hasCustomName => name.isNotEmpty;
|
||||
|
||||
/// Create from JSON
|
||||
factory Channel.fromJson(Map<String, dynamic> json) {
|
||||
return Channel(
|
||||
index: json['index'] as int,
|
||||
name: json['name'] as String? ?? '',
|
||||
secret: base64.decode(json['secret'] as String),
|
||||
flags: json['flags'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'index': index,
|
||||
'name': name,
|
||||
'secret': base64.encode(secret),
|
||||
'flags': flags,
|
||||
};
|
||||
}
|
||||
|
||||
/// Create a copy with modified fields
|
||||
Channel copyWith({
|
||||
int? index,
|
||||
String? name,
|
||||
Uint8List? secret,
|
||||
int? flags,
|
||||
}) {
|
||||
return Channel(
|
||||
index: index ?? this.index,
|
||||
name: name ?? this.name,
|
||||
secret: secret ?? this.secret,
|
||||
flags: flags ?? this.flags,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Channel(index: $index, name: $name, isHashChannel: $isHashChannel, flags: $flags)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is Channel &&
|
||||
other.index == index &&
|
||||
other.name == name &&
|
||||
_secretsEqual(other.secret, secret) &&
|
||||
other.flags == flags;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(index, name, secret, flags);
|
||||
|
||||
bool _secretsEqual(Uint8List a, Uint8List b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
364
lib/models/contact.dart
Normal file
364
lib/models/contact.dart
Normal file
@@ -0,0 +1,364 @@
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'contact_telemetry.dart';
|
||||
import 'advert_location.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// MeshCore contact types
|
||||
enum ContactType {
|
||||
none(0),
|
||||
chat(1),
|
||||
repeater(2),
|
||||
room(3),
|
||||
channel(99); // Virtual type for public channel (not from protocol)
|
||||
|
||||
const ContactType(this.value);
|
||||
final int value;
|
||||
|
||||
static ContactType fromValue(int value) {
|
||||
return ContactType.values.firstWhere(
|
||||
(e) => e.value == value,
|
||||
orElse: () => ContactType.none,
|
||||
);
|
||||
}
|
||||
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case ContactType.chat:
|
||||
return 'Chat';
|
||||
case ContactType.repeater:
|
||||
return 'Repeater';
|
||||
case ContactType.room:
|
||||
return 'Room';
|
||||
case ContactType.channel:
|
||||
return 'Channel';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MeshCore contact model
|
||||
class Contact {
|
||||
final Uint8List publicKey;
|
||||
final ContactType type;
|
||||
final int flags;
|
||||
final int outPathLen;
|
||||
final Uint8List outPath;
|
||||
final String advName;
|
||||
final int lastAdvert; // Unix timestamp
|
||||
final int advLat; // Latitude as int32
|
||||
final int advLon; // Longitude as int32
|
||||
final int lastMod; // Unix timestamp
|
||||
|
||||
// Telemetry data (updated separately)
|
||||
ContactTelemetry? telemetry;
|
||||
|
||||
// Advertisement location history (most recent first)
|
||||
final List<AdvertLocation> advertHistory;
|
||||
|
||||
// UI state tracking
|
||||
final bool isNew; // Whether contact is newly added and not yet viewed
|
||||
|
||||
Contact({
|
||||
required this.publicKey,
|
||||
required this.type,
|
||||
required this.flags,
|
||||
required this.outPathLen,
|
||||
required this.outPath,
|
||||
required this.advName,
|
||||
required this.lastAdvert,
|
||||
required this.advLat,
|
||||
required this.advLon,
|
||||
required this.lastMod,
|
||||
this.telemetry,
|
||||
List<AdvertLocation>? advertHistory,
|
||||
this.isNew = false,
|
||||
}) : advertHistory = advertHistory ?? [];
|
||||
|
||||
/// Get public key as hex string (first 8 bytes)
|
||||
String get publicKeyShort {
|
||||
if (publicKey.length < 8) return '';
|
||||
return publicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||
}
|
||||
|
||||
/// Get full public key as hex string
|
||||
String get publicKeyHex {
|
||||
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||
}
|
||||
|
||||
/// Get public key prefix (first 6 bytes) for room login matching
|
||||
Uint8List get publicKeyPrefix {
|
||||
if (publicKey.length < 6) return publicKey;
|
||||
return publicKey.sublist(0, 6);
|
||||
}
|
||||
|
||||
/// Convert advLat/advLon to LatLng
|
||||
LatLng? get advertLocation {
|
||||
if (advLat == 0 && advLon == 0) return null;
|
||||
// Convert from int32 to double (degrees)
|
||||
final lat = advLat / 1e6;
|
||||
final lon = advLon / 1e6;
|
||||
return LatLng(lat, lon);
|
||||
}
|
||||
|
||||
/// Get display location (prefer telemetry over advert)
|
||||
LatLng? get displayLocation {
|
||||
if (telemetry?.gpsLocation != null && telemetry!.isRecent) {
|
||||
return telemetry!.gpsLocation;
|
||||
}
|
||||
return advertLocation;
|
||||
}
|
||||
|
||||
/// Get display battery (from telemetry or null)
|
||||
double? get displayBattery {
|
||||
return telemetry?.batteryPercentage;
|
||||
}
|
||||
|
||||
/// Check if contact is a chat type (team member)
|
||||
bool get isChat => type == ContactType.chat;
|
||||
|
||||
/// Check if contact is a repeater
|
||||
bool get isRepeater => type == ContactType.repeater;
|
||||
|
||||
/// Check if contact is a room (persistent storage)
|
||||
bool get isRoom => type == ContactType.room;
|
||||
|
||||
/// Check if contact is a channel (ephemeral broadcast)
|
||||
bool get isChannel => type == ContactType.channel;
|
||||
|
||||
/// Get last seen time
|
||||
DateTime get lastSeenTime {
|
||||
return DateTime.fromMillisecondsSinceEpoch(lastAdvert * 1000);
|
||||
}
|
||||
|
||||
/// Get last modified time
|
||||
DateTime get lastModifiedTime {
|
||||
return DateTime.fromMillisecondsSinceEpoch(lastMod * 1000);
|
||||
}
|
||||
|
||||
/// Check if contact was seen recently (within last 10 minutes)
|
||||
bool get isRecentlySeen {
|
||||
return DateTime.now().difference(lastSeenTime).inMinutes < 10;
|
||||
}
|
||||
|
||||
/// Get friendly time since last seen
|
||||
String get timeSinceLastSeen {
|
||||
final diff = DateTime.now().difference(lastSeenTime);
|
||||
if (diff.inMinutes < 1) return 'Just now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
return '${diff.inDays}d ago';
|
||||
}
|
||||
|
||||
/// Get time when location was last updated
|
||||
DateTime? get locationUpdateTime {
|
||||
// Prefer telemetry timestamp if available
|
||||
if (telemetry?.gpsLocation != null) {
|
||||
return telemetry!.timestamp;
|
||||
}
|
||||
// Fall back to lastAdvert time if using advertised location
|
||||
if (advertLocation != null) {
|
||||
return lastSeenTime;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get friendly time since location was last updated
|
||||
String get timeSinceLocationUpdate {
|
||||
final updateTime = locationUpdateTime;
|
||||
if (updateTime == null) return 'Unknown';
|
||||
|
||||
final diff = DateTime.now().difference(updateTime);
|
||||
if (diff.inMinutes < 1) return 'Now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h';
|
||||
return '${diff.inDays}d';
|
||||
}
|
||||
|
||||
/// Extract role emoji from name (e.g., "🧑🏻🚒Janez" → "🧑🏻🚒")
|
||||
/// Returns null if no emoji at start of name
|
||||
String? get roleEmoji {
|
||||
if (advName.isEmpty) return null;
|
||||
|
||||
// Get the first character/grapheme cluster (which could be a complex emoji)
|
||||
final firstChar = advName.characters.first;
|
||||
|
||||
// Check if it's an emoji (basic check - emojis are typically in certain Unicode ranges)
|
||||
final firstCodeUnit = firstChar.runes.first;
|
||||
|
||||
// Emoji ranges (simplified check):
|
||||
// 0x1F300-0x1F9FF: Misc Symbols and Pictographs, Emoticons, Transport, etc.
|
||||
// 0x2600-0x26FF: Misc symbols
|
||||
// 0x2700-0x27BF: Dingbats
|
||||
// 0xFE00-0xFE0F: Variation Selectors
|
||||
// 0x1F900-0x1F9FF: Supplemental Symbols and Pictographs
|
||||
if ((firstCodeUnit >= 0x1F300 && firstCodeUnit <= 0x1F9FF) ||
|
||||
(firstCodeUnit >= 0x2600 && firstCodeUnit <= 0x27BF) ||
|
||||
(firstCodeUnit >= 0x1F600 && firstCodeUnit <= 0x1F64F)) {
|
||||
return firstChar;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get display name without role emoji (e.g., "🧑🏻🚒Janez" → "Janez")
|
||||
/// If no emoji, returns full advName
|
||||
String get displayName {
|
||||
final emoji = roleEmoji;
|
||||
if (emoji == null) return advName;
|
||||
|
||||
// Remove the emoji from the beginning
|
||||
return advName.substring(emoji.length).trim();
|
||||
}
|
||||
|
||||
/// Check if this contact is the Public Channel (all-zeros public key)
|
||||
bool get isPublicChannel =>
|
||||
publicKeyHex == '0000000000000000000000000000000000000000000000000000000000000000';
|
||||
|
||||
/// Get localized display name (for Public Channel and other special contacts)
|
||||
String getLocalizedDisplayName(BuildContext context) {
|
||||
// Check if this is the Public Channel (all-zeros public key)
|
||||
if (isPublicChannel) {
|
||||
return AppLocalizations.of(context)!.publicChannel;
|
||||
}
|
||||
// For all other contacts, use the regular display name
|
||||
return displayName;
|
||||
}
|
||||
|
||||
/// Check if contact has a learned routing path
|
||||
/// When true, messages will use direct routing. When false, messages will use flood mode.
|
||||
/// outPathLen: -1 = unknown/not learned, 0 = direct (zero hops), 1+ = multi-hop path
|
||||
bool get hasPath => outPathLen >= 0 && outPathLen <= 64;
|
||||
|
||||
/// Get path description for UI display
|
||||
String get pathDescription {
|
||||
if (!hasPath) {
|
||||
// -1 (0xFF) indicates path not learned yet
|
||||
return 'No path (flood mode)';
|
||||
}
|
||||
|
||||
// outPathLen = 0 means direct connection with zero hops
|
||||
// outPathLen >= 1 means path with N hops
|
||||
if (outPathLen == 0) {
|
||||
return 'Direct (0 hops)';
|
||||
} else if (outPathLen == 1) {
|
||||
return 'Direct (1 hop)';
|
||||
} else if (outPathLen <= 3) {
|
||||
return 'Good path ($outPathLen hops)';
|
||||
} else if (outPathLen <= 5) {
|
||||
return 'Medium path ($outPathLen hops)';
|
||||
} else {
|
||||
return 'Long path ($outPathLen hops)';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get path quality indicator (0-5 scale, higher is better)
|
||||
/// -1 means no path (will use flood mode)
|
||||
int get pathQuality {
|
||||
if (!hasPath) return -1;
|
||||
if (outPathLen == 0) return 5; // Direct connection (0 hops)
|
||||
if (outPathLen == 1) return 4; // 1 hop
|
||||
if (outPathLen <= 2) return 3; // 2 hops
|
||||
if (outPathLen <= 3) return 2; // 3 hops
|
||||
if (outPathLen <= 4) return 1; // 4 hops
|
||||
return 0; // 5+ hops
|
||||
}
|
||||
|
||||
/// Add a new advertisement location to history (maintains max 1000 points)
|
||||
///
|
||||
/// Implements location dithering to avoid storing redundant points:
|
||||
/// - Only stores points that are ≥1 meter apart (max meter accuracy)
|
||||
/// - Prevents trail clutter when contact is stationary or moving slowly
|
||||
/// - Maintains chronological order (most recent first)
|
||||
Contact addAdvertLocation(LatLng location, DateTime timestamp) {
|
||||
final newPoint = AdvertLocation(location: location, timestamp: timestamp);
|
||||
|
||||
// Dithering: Skip points within 1 meter of the last recorded position
|
||||
// This provides max meter accuracy while avoiding redundant data
|
||||
if (advertHistory.isNotEmpty) {
|
||||
final lastPoint = advertHistory.first;
|
||||
final distance = _calculateDistance(lastPoint.location, location);
|
||||
|
||||
// If less than 1 meter apart, skip this point (location dithering)
|
||||
if (distance < 1.0) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
// Add new point at the beginning (most recent first)
|
||||
final updatedHistory = [newPoint, ...advertHistory];
|
||||
|
||||
// Keep only the most recent 1000 points to limit memory usage
|
||||
final trimmedHistory = updatedHistory.length > 1000
|
||||
? updatedHistory.sublist(0, 1000)
|
||||
: updatedHistory;
|
||||
|
||||
return copyWith(advertHistory: trimmedHistory);
|
||||
}
|
||||
|
||||
/// Calculate distance between two points in meters (Haversine formula)
|
||||
double _calculateDistance(LatLng point1, LatLng point2) {
|
||||
const double earthRadius = 6371000; // meters
|
||||
final lat1 = point1.latitude * (pi / 180);
|
||||
final lat2 = point2.latitude * (pi / 180);
|
||||
final dLat = (point2.latitude - point1.latitude) * (pi / 180);
|
||||
final dLon = (point2.longitude - point1.longitude) * (pi / 180);
|
||||
|
||||
final a = sin(dLat / 2) * sin(dLat / 2) +
|
||||
cos(lat1) * cos(lat2) *
|
||||
sin(dLon / 2) * sin(dLon / 2);
|
||||
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
|
||||
|
||||
return earthRadius * c;
|
||||
}
|
||||
|
||||
Contact copyWith({
|
||||
Uint8List? publicKey,
|
||||
ContactType? type,
|
||||
int? flags,
|
||||
int? outPathLen,
|
||||
Uint8List? outPath,
|
||||
String? advName,
|
||||
int? lastAdvert,
|
||||
int? advLat,
|
||||
int? advLon,
|
||||
int? lastMod,
|
||||
ContactTelemetry? telemetry,
|
||||
List<AdvertLocation>? advertHistory,
|
||||
bool? isNew,
|
||||
}) {
|
||||
return Contact(
|
||||
publicKey: publicKey ?? this.publicKey,
|
||||
type: type ?? this.type,
|
||||
flags: flags ?? this.flags,
|
||||
outPathLen: outPathLen ?? this.outPathLen,
|
||||
outPath: outPath ?? this.outPath,
|
||||
advName: advName ?? this.advName,
|
||||
lastAdvert: lastAdvert ?? this.lastAdvert,
|
||||
advLat: advLat ?? this.advLat,
|
||||
advLon: advLon ?? this.advLon,
|
||||
lastMod: lastMod ?? this.lastMod,
|
||||
telemetry: telemetry ?? this.telemetry,
|
||||
advertHistory: advertHistory ?? this.advertHistory,
|
||||
isNew: isNew ?? this.isNew,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Contact(name: $advName, type: ${type.displayName}, key: $publicKeyShort)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is Contact &&
|
||||
publicKeyHex == other.publicKeyHex;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => publicKeyHex.hashCode;
|
||||
}
|
||||
76
lib/models/contact_telemetry.dart
Normal file
76
lib/models/contact_telemetry.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// Contact telemetry data from MeshCore device
|
||||
class ContactTelemetry {
|
||||
final LatLng? gpsLocation;
|
||||
final double? batteryPercentage;
|
||||
final double? batteryMilliVolts;
|
||||
final double? temperature;
|
||||
final DateTime timestamp;
|
||||
|
||||
// Additional sensor data
|
||||
final double? humidity;
|
||||
final double? pressure;
|
||||
final Map<String, dynamic>? extraSensorData;
|
||||
|
||||
ContactTelemetry({
|
||||
this.gpsLocation,
|
||||
this.batteryPercentage,
|
||||
this.batteryMilliVolts,
|
||||
this.temperature,
|
||||
required this.timestamp,
|
||||
this.humidity,
|
||||
this.pressure,
|
||||
this.extraSensorData,
|
||||
});
|
||||
|
||||
/// Check if telemetry data is recent (within last 5 minutes)
|
||||
bool get isRecent {
|
||||
return DateTime.now().difference(timestamp).inMinutes < 5;
|
||||
}
|
||||
|
||||
/// Check if battery level is low (< 20%)
|
||||
bool get isLowBattery {
|
||||
return batteryPercentage != null && batteryPercentage! < 20.0;
|
||||
}
|
||||
|
||||
/// Check if battery level is critical (< 10%)
|
||||
bool get isCriticalBattery {
|
||||
return batteryPercentage != null && batteryPercentage! < 10.0;
|
||||
}
|
||||
|
||||
/// Get battery status color indicator
|
||||
String get batteryStatus {
|
||||
if (batteryPercentage == null) return 'unknown';
|
||||
if (batteryPercentage! > 50) return 'good';
|
||||
if (batteryPercentage! > 20) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
ContactTelemetry copyWith({
|
||||
LatLng? gpsLocation,
|
||||
double? batteryPercentage,
|
||||
double? batteryMilliVolts,
|
||||
double? temperature,
|
||||
DateTime? timestamp,
|
||||
double? humidity,
|
||||
double? pressure,
|
||||
Map<String, dynamic>? extraSensorData,
|
||||
}) {
|
||||
return ContactTelemetry(
|
||||
gpsLocation: gpsLocation ?? this.gpsLocation,
|
||||
batteryPercentage: batteryPercentage ?? this.batteryPercentage,
|
||||
batteryMilliVolts: batteryMilliVolts ?? this.batteryMilliVolts,
|
||||
temperature: temperature ?? this.temperature,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
humidity: humidity ?? this.humidity,
|
||||
pressure: pressure ?? this.pressure,
|
||||
extraSensorData: extraSensorData ?? this.extraSensorData,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ContactTelemetry(gps: $gpsLocation, battery: $batteryPercentage%, temp: $temperature°C, time: $timestamp)';
|
||||
}
|
||||
}
|
||||
282
lib/models/device_info.dart
Normal file
282
lib/models/device_info.dart
Normal file
@@ -0,0 +1,282 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// BLE connection state
|
||||
enum ConnectionState {
|
||||
disconnected,
|
||||
connecting,
|
||||
connected,
|
||||
disconnecting,
|
||||
error,
|
||||
}
|
||||
|
||||
/// Connection mode for app operation
|
||||
enum ConnectionMode {
|
||||
/// Direct BLE connection to MeshCore device (default)
|
||||
ble,
|
||||
|
||||
/// Act as SSE server - share BLE device with multiple clients
|
||||
sseServer,
|
||||
|
||||
/// Connect to remote SSE server - no direct BLE connection
|
||||
sseClient,
|
||||
}
|
||||
|
||||
extension ConnectionModeExtension on ConnectionMode {
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case ConnectionMode.ble:
|
||||
return 'Direct (BLE)';
|
||||
case ConnectionMode.sseServer:
|
||||
return 'Share Device (Server)';
|
||||
case ConnectionMode.sseClient:
|
||||
return 'Connect to Server';
|
||||
}
|
||||
}
|
||||
|
||||
String get description {
|
||||
switch (this) {
|
||||
case ConnectionMode.ble:
|
||||
return 'Direct BLE connection to MeshCore device';
|
||||
case ConnectionMode.sseServer:
|
||||
return 'Share BLE device with multiple clients over network';
|
||||
case ConnectionMode.sseClient:
|
||||
return 'Connect to remote server without BLE';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MeshCore device information
|
||||
class DeviceInfo {
|
||||
final String? deviceId;
|
||||
final String? deviceName;
|
||||
final ConnectionState connectionState;
|
||||
final int? batteryMilliVolts;
|
||||
final double? batteryPercentage;
|
||||
final int? storageUsedKb;
|
||||
final int? storageTotalKb;
|
||||
final int? signalRssi;
|
||||
final double? signalSnr;
|
||||
final DateTime? lastUpdate;
|
||||
|
||||
// Self info from MeshCore device
|
||||
final int? deviceType;
|
||||
final int? txPower;
|
||||
final int? maxTxPower;
|
||||
final Uint8List? publicKey;
|
||||
final int? advLat;
|
||||
final int? advLon;
|
||||
final bool? manualAddContacts;
|
||||
final int? radioFreq;
|
||||
final int? radioBw;
|
||||
final int? radioSf;
|
||||
final int? radioCr;
|
||||
final String? selfName;
|
||||
|
||||
// Additional device capabilities (from RESP_CODE_DEVICE_INFO)
|
||||
final int? maxContacts; // Max contacts device supports
|
||||
final int? maxChannels; // Max channels device supports
|
||||
final int? telemetryModes; // Telemetry permission modes (bits 0-1: Base, bits 2-3: Location)
|
||||
final int? blePin; // BLE PIN code
|
||||
final int? multiAcks; // Extra ACK mode (0=no, 1=yes)
|
||||
final int? advertLocPolicy; // Location sharing policy (0=don't share, 1=share)
|
||||
|
||||
// Firmware info
|
||||
final int? firmwareVersion;
|
||||
final String? firmwareBuildDate;
|
||||
final String? manufacturerModel;
|
||||
final String? semanticVersion;
|
||||
|
||||
DeviceInfo({
|
||||
this.deviceId,
|
||||
this.deviceName,
|
||||
this.connectionState = ConnectionState.disconnected,
|
||||
this.batteryMilliVolts,
|
||||
this.batteryPercentage,
|
||||
this.storageUsedKb,
|
||||
this.storageTotalKb,
|
||||
this.signalRssi,
|
||||
this.signalSnr,
|
||||
this.lastUpdate,
|
||||
this.deviceType,
|
||||
this.txPower,
|
||||
this.maxTxPower,
|
||||
this.publicKey,
|
||||
this.advLat,
|
||||
this.advLon,
|
||||
this.manualAddContacts,
|
||||
this.radioFreq,
|
||||
this.radioBw,
|
||||
this.radioSf,
|
||||
this.radioCr,
|
||||
this.selfName,
|
||||
this.maxContacts,
|
||||
this.maxChannels,
|
||||
this.telemetryModes,
|
||||
this.blePin,
|
||||
this.multiAcks,
|
||||
this.advertLocPolicy,
|
||||
this.firmwareVersion,
|
||||
this.firmwareBuildDate,
|
||||
this.manufacturerModel,
|
||||
this.semanticVersion,
|
||||
});
|
||||
|
||||
/// Check if device is connected
|
||||
bool get isConnected => connectionState == ConnectionState.connected;
|
||||
|
||||
/// Check if device is connecting
|
||||
bool get isConnecting => connectionState == ConnectionState.connecting;
|
||||
|
||||
/// Check if device has error
|
||||
bool get hasError => connectionState == ConnectionState.error;
|
||||
|
||||
/// Get battery percentage (calculated or provided)
|
||||
double? get batteryPercent {
|
||||
if (batteryPercentage != null) return batteryPercentage!;
|
||||
if (batteryMilliVolts == null) return null;
|
||||
|
||||
// Rough conversion from mV to percentage (3.0V = 0%, 4.2V = 100%)
|
||||
final voltage = batteryMilliVolts! / 1000.0;
|
||||
if (voltage <= 3.0) return 0.0;
|
||||
if (voltage >= 4.2) return 100.0;
|
||||
return ((voltage - 3.0) / 1.2) * 100.0;
|
||||
}
|
||||
|
||||
/// Get battery status
|
||||
String get batteryStatus {
|
||||
final percent = batteryPercent;
|
||||
if (percent == null) return 'Unknown';
|
||||
if (percent > 80) return 'Excellent';
|
||||
if (percent > 50) return 'Good';
|
||||
if (percent > 20) return 'Low';
|
||||
return 'Critical';
|
||||
}
|
||||
|
||||
/// Get storage usage percentage (0-100)
|
||||
double? get storageUsedPercent {
|
||||
if (storageUsedKb == null || storageTotalKb == null || storageTotalKb == 0) {
|
||||
return null;
|
||||
}
|
||||
return (storageUsedKb! / storageTotalKb!) * 100.0;
|
||||
}
|
||||
|
||||
/// Get storage available in KB
|
||||
int? get storageAvailableKb {
|
||||
if (storageUsedKb == null || storageTotalKb == null) {
|
||||
return null;
|
||||
}
|
||||
return storageTotalKb! - storageUsedKb!;
|
||||
}
|
||||
|
||||
/// Get human-readable storage status
|
||||
String get storageStatus {
|
||||
final percent = storageUsedPercent;
|
||||
if (percent == null) return 'Unknown';
|
||||
if (percent < 50) return 'Plenty Available';
|
||||
if (percent < 80) return 'Moderate Usage';
|
||||
if (percent < 95) return 'Low Space';
|
||||
return 'Critical - Nearly Full';
|
||||
}
|
||||
|
||||
/// Get signal strength category
|
||||
String get signalStrength {
|
||||
if (signalRssi == null) return 'Unknown';
|
||||
if (signalRssi! > -60) return 'Excellent';
|
||||
if (signalRssi! > -70) return 'Good';
|
||||
if (signalRssi! > -80) return 'Fair';
|
||||
return 'Poor';
|
||||
}
|
||||
|
||||
/// Get public key as hex string (short)
|
||||
String? get publicKeyShort {
|
||||
if (publicKey == null || publicKey!.length < 8) return null;
|
||||
return publicKey!
|
||||
.sublist(0, 8)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/// Get display name with "MeshCore-" prefix removed
|
||||
String? get displayName {
|
||||
if (deviceName == null) return null;
|
||||
if (deviceName!.startsWith('MeshCore-')) {
|
||||
return deviceName!.substring(9); // Remove "MeshCore-" (9 characters)
|
||||
}
|
||||
return deviceName;
|
||||
}
|
||||
|
||||
DeviceInfo copyWith({
|
||||
String? deviceId,
|
||||
String? deviceName,
|
||||
ConnectionState? connectionState,
|
||||
int? batteryMilliVolts,
|
||||
double? batteryPercentage,
|
||||
int? storageUsedKb,
|
||||
int? storageTotalKb,
|
||||
int? signalRssi,
|
||||
double? signalSnr,
|
||||
DateTime? lastUpdate,
|
||||
int? deviceType,
|
||||
int? txPower,
|
||||
int? maxTxPower,
|
||||
Uint8List? publicKey,
|
||||
int? advLat,
|
||||
int? advLon,
|
||||
bool? manualAddContacts,
|
||||
int? radioFreq,
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
String? selfName,
|
||||
int? maxContacts,
|
||||
int? maxChannels,
|
||||
int? telemetryModes,
|
||||
int? blePin,
|
||||
int? multiAcks,
|
||||
int? advertLocPolicy,
|
||||
int? firmwareVersion,
|
||||
String? firmwareBuildDate,
|
||||
String? manufacturerModel,
|
||||
String? semanticVersion,
|
||||
}) {
|
||||
return DeviceInfo(
|
||||
deviceId: deviceId ?? this.deviceId,
|
||||
deviceName: deviceName ?? this.deviceName,
|
||||
connectionState: connectionState ?? this.connectionState,
|
||||
batteryMilliVolts: batteryMilliVolts ?? this.batteryMilliVolts,
|
||||
batteryPercentage: batteryPercentage ?? this.batteryPercentage,
|
||||
storageUsedKb: storageUsedKb ?? this.storageUsedKb,
|
||||
storageTotalKb: storageTotalKb ?? this.storageTotalKb,
|
||||
signalRssi: signalRssi ?? this.signalRssi,
|
||||
signalSnr: signalSnr ?? this.signalSnr,
|
||||
lastUpdate: lastUpdate ?? this.lastUpdate,
|
||||
deviceType: deviceType ?? this.deviceType,
|
||||
txPower: txPower ?? this.txPower,
|
||||
maxTxPower: maxTxPower ?? this.maxTxPower,
|
||||
publicKey: publicKey ?? this.publicKey,
|
||||
advLat: advLat ?? this.advLat,
|
||||
advLon: advLon ?? this.advLon,
|
||||
manualAddContacts: manualAddContacts ?? this.manualAddContacts,
|
||||
radioFreq: radioFreq ?? this.radioFreq,
|
||||
radioBw: radioBw ?? this.radioBw,
|
||||
radioSf: radioSf ?? this.radioSf,
|
||||
radioCr: radioCr ?? this.radioCr,
|
||||
selfName: selfName ?? this.selfName,
|
||||
maxContacts: maxContacts ?? this.maxContacts,
|
||||
maxChannels: maxChannels ?? this.maxChannels,
|
||||
telemetryModes: telemetryModes ?? this.telemetryModes,
|
||||
blePin: blePin ?? this.blePin,
|
||||
multiAcks: multiAcks ?? this.multiAcks,
|
||||
advertLocPolicy: advertLocPolicy ?? this.advertLocPolicy,
|
||||
firmwareVersion: firmwareVersion ?? this.firmwareVersion,
|
||||
firmwareBuildDate: firmwareBuildDate ?? this.firmwareBuildDate,
|
||||
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
|
||||
semanticVersion: semanticVersion ?? this.semanticVersion,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DeviceInfo(name: $deviceName, state: $connectionState, battery: ${batteryPercent?.toStringAsFixed(0)}%, signal: $signalRssi dBm)';
|
||||
}
|
||||
}
|
||||
106
lib/models/location_trail.dart
Normal file
106
lib/models/location_trail.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// Represents a single point in a location trail
|
||||
class TrailPoint {
|
||||
final LatLng position;
|
||||
final DateTime timestamp;
|
||||
final double? accuracy;
|
||||
final double? speed;
|
||||
|
||||
TrailPoint({
|
||||
required this.position,
|
||||
required this.timestamp,
|
||||
this.accuracy,
|
||||
this.speed,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'lat': position.latitude,
|
||||
'lon': position.longitude,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'accuracy': accuracy,
|
||||
'speed': speed,
|
||||
};
|
||||
|
||||
factory TrailPoint.fromJson(Map<String, dynamic> json) {
|
||||
return TrailPoint(
|
||||
position: LatLng(json['lat'] as double, json['lon'] as double),
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
accuracy: json['accuracy'] as double?,
|
||||
speed: json['speed'] as double?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a location trail (breadcrumb trail) on the map
|
||||
class LocationTrail {
|
||||
final String id;
|
||||
final List<TrailPoint> points;
|
||||
final DateTime startTime;
|
||||
DateTime? endTime;
|
||||
bool isActive;
|
||||
|
||||
LocationTrail({
|
||||
required this.id,
|
||||
List<TrailPoint>? points,
|
||||
DateTime? startTime,
|
||||
this.endTime,
|
||||
this.isActive = true,
|
||||
}) : points = points ?? [],
|
||||
startTime = startTime ?? DateTime.now();
|
||||
|
||||
/// Add a new point to the trail
|
||||
void addPoint(TrailPoint point) {
|
||||
points.add(point);
|
||||
}
|
||||
|
||||
/// Get total distance traveled in meters
|
||||
double get totalDistance {
|
||||
if (points.length < 2) return 0;
|
||||
|
||||
final distance = Distance();
|
||||
double total = 0;
|
||||
|
||||
for (int i = 0; i < points.length - 1; i++) {
|
||||
total += distance.as(
|
||||
LengthUnit.Meter,
|
||||
points[i].position,
|
||||
points[i + 1].position,
|
||||
);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Get duration of the trail
|
||||
Duration get duration {
|
||||
if (points.isEmpty) return Duration.zero;
|
||||
final end = endTime ?? DateTime.now();
|
||||
return end.difference(startTime);
|
||||
}
|
||||
|
||||
/// Get list of LatLng points for rendering
|
||||
List<LatLng> get latLngPoints => points.map((p) => p.position).toList();
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'points': points.map((p) => p.toJson()).toList(),
|
||||
'startTime': startTime.toIso8601String(),
|
||||
'endTime': endTime?.toIso8601String(),
|
||||
'isActive': isActive,
|
||||
};
|
||||
|
||||
factory LocationTrail.fromJson(Map<String, dynamic> json) {
|
||||
return LocationTrail(
|
||||
id: json['id'] as String,
|
||||
points: (json['points'] as List)
|
||||
.map((p) => TrailPoint.fromJson(p as Map<String, dynamic>))
|
||||
.toList(),
|
||||
startTime: DateTime.parse(json['startTime'] as String),
|
||||
endTime: json['endTime'] != null
|
||||
? DateTime.parse(json['endTime'] as String)
|
||||
: null,
|
||||
isActive: json['isActive'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
}
|
||||
420
lib/models/map_drawing.dart
Normal file
420
lib/models/map_drawing.dart
Normal file
@@ -0,0 +1,420 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// Drawing shape type
|
||||
enum DrawingShapeType {
|
||||
line,
|
||||
rectangle,
|
||||
}
|
||||
|
||||
/// Drawing color enum for compact network transmission
|
||||
enum DrawingColor {
|
||||
red, // 0
|
||||
blue, // 1
|
||||
green, // 2
|
||||
yellow, // 3
|
||||
orange, // 4
|
||||
purple, // 5
|
||||
pink, // 6
|
||||
cyan, // 7
|
||||
}
|
||||
|
||||
/// Drawing colors available for user selection
|
||||
class DrawingColors {
|
||||
static const List<Color> palette = [
|
||||
Colors.red, // index 0
|
||||
Colors.blue, // index 1
|
||||
Colors.green, // index 2
|
||||
Colors.yellow, // index 3
|
||||
Colors.orange, // index 4
|
||||
Colors.purple, // index 5
|
||||
Colors.pink, // index 6
|
||||
Colors.cyan, // index 7
|
||||
];
|
||||
|
||||
static String colorToName(Color color) {
|
||||
if (color == Colors.red) return 'Red';
|
||||
if (color == Colors.blue) return 'Blue';
|
||||
if (color == Colors.green) return 'Green';
|
||||
if (color == Colors.yellow) return 'Yellow';
|
||||
if (color == Colors.orange) return 'Orange';
|
||||
if (color == Colors.purple) return 'Purple';
|
||||
if (color == Colors.pink) return 'Pink';
|
||||
if (color == Colors.cyan) return 'Cyan';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/// Convert Color to enum index for network transmission
|
||||
static int colorToIndex(Color color) {
|
||||
for (int i = 0; i < palette.length; i++) {
|
||||
if (palette[i].toARGB32() == color.toARGB32()) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0; // Default to red if not found
|
||||
}
|
||||
|
||||
/// Convert enum index to Color for network reception
|
||||
static Color indexToColor(int index) {
|
||||
if (index >= 0 && index < palette.length) {
|
||||
return palette[index];
|
||||
}
|
||||
return palette[0]; // Default to red if invalid index
|
||||
}
|
||||
}
|
||||
|
||||
/// Base class for map drawings
|
||||
abstract class MapDrawing {
|
||||
final String id;
|
||||
final DrawingShapeType type;
|
||||
final Color color;
|
||||
final DateTime createdAt;
|
||||
final String? senderName; // Name of sender (null if local drawing)
|
||||
final bool isReceived; // True if drawing was received from another node
|
||||
final String? messageId; // ID of the source message (for navigation)
|
||||
final bool isShared; // Whether drawing has been broadcast over mesh
|
||||
final bool isSent; // Whether this is a sent drawing (vs received)
|
||||
final bool isHidden; // Temporary visibility toggle (session only, not persisted)
|
||||
|
||||
MapDrawing({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.color,
|
||||
required this.createdAt,
|
||||
this.senderName,
|
||||
this.isReceived = false,
|
||||
this.messageId,
|
||||
this.isShared = false,
|
||||
this.isSent = false,
|
||||
this.isHidden = false,
|
||||
});
|
||||
|
||||
/// Convert to JSON for persistence
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
/// Convert to JSON for network transmission (compact format)
|
||||
/// Uses short field names and excludes createdAt to minimize message size
|
||||
/// Sender will be fetched from packet metadata
|
||||
Map<String, dynamic> toNetworkJson();
|
||||
|
||||
/// Parse network JSON (compact format)
|
||||
/// senderName and messageId will be populated from packet metadata
|
||||
static MapDrawing? fromNetworkJson(
|
||||
Map<String, dynamic> json, {
|
||||
String? senderName,
|
||||
String? messageId,
|
||||
}) {
|
||||
final typeNum = json['t'] as int?;
|
||||
if (typeNum == null || typeNum < 0 || typeNum >= DrawingShapeType.values.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final type = DrawingShapeType.values[typeNum];
|
||||
|
||||
switch (type) {
|
||||
case DrawingShapeType.line:
|
||||
return LineDrawing.fromNetworkJson(
|
||||
json,
|
||||
senderName: senderName,
|
||||
messageId: messageId,
|
||||
);
|
||||
case DrawingShapeType.rectangle:
|
||||
return RectangleDrawing.fromNetworkJson(
|
||||
json,
|
||||
senderName: senderName,
|
||||
messageId: messageId,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create from JSON
|
||||
static MapDrawing? fromJson(Map<String, dynamic> json) {
|
||||
final typeStr = json['type'] as String?;
|
||||
if (typeStr == null) return null;
|
||||
|
||||
try {
|
||||
final type = DrawingShapeType.values.firstWhere(
|
||||
(e) => e.toString() == 'DrawingShapeType.$typeStr',
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case DrawingShapeType.line:
|
||||
return LineDrawing.fromJson(json);
|
||||
case DrawingShapeType.rectangle:
|
||||
return RectangleDrawing.fromJson(json);
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the center point of the drawing
|
||||
LatLng getCenter();
|
||||
|
||||
/// Get the bounds of the drawing
|
||||
LatLngBounds getBounds();
|
||||
}
|
||||
|
||||
/// Line drawing on map
|
||||
class LineDrawing extends MapDrawing {
|
||||
final List<LatLng> points;
|
||||
|
||||
LineDrawing({
|
||||
required super.id,
|
||||
required super.color,
|
||||
required super.createdAt,
|
||||
required this.points,
|
||||
super.senderName,
|
||||
super.isReceived,
|
||||
super.messageId,
|
||||
super.isShared,
|
||||
super.isSent,
|
||||
super.isHidden,
|
||||
}) : super(type: DrawingShapeType.line);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'type': type.name,
|
||||
'color': color.toARGB32(),
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'points': points.map((p) => {'lat': p.latitude, 'lon': p.longitude}).toList(),
|
||||
'isShared': isShared,
|
||||
// Note: isHidden is not persisted - it's session-only
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toNetworkJson() {
|
||||
// Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), p=points
|
||||
// Points are encoded as flat array [lat1,lon1,lat2,lon2,...]
|
||||
// Coordinates rounded to 5 decimal places (~1m precision, like SAR markers)
|
||||
// Sender is fetched from packet metadata, not included in JSON
|
||||
return {
|
||||
't': type.index,
|
||||
'c': DrawingColors.colorToIndex(color),
|
||||
'p': points.expand((p) => [
|
||||
double.parse(p.latitude.toStringAsFixed(5)),
|
||||
double.parse(p.longitude.toStringAsFixed(5)),
|
||||
]).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
static LineDrawing fromJson(Map<String, dynamic> json) {
|
||||
final pointsJson = json['points'] as List<dynamic>;
|
||||
final points = pointsJson.map((p) => LatLng(p['lat'] as double, p['lon'] as double)).toList();
|
||||
final senderName = json['sender'] as String?;
|
||||
|
||||
return LineDrawing(
|
||||
id: json['id'] as String,
|
||||
color: Color(json['color'] as int),
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
points: points,
|
||||
senderName: senderName,
|
||||
isReceived: senderName != null, // Mark as received if sender is present
|
||||
isShared: json['isShared'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
static LineDrawing fromNetworkJson(
|
||||
Map<String, dynamic> json, {
|
||||
String? senderName,
|
||||
String? messageId,
|
||||
}) {
|
||||
// Parse ultra-compact format
|
||||
final pointsFlat = (json['p'] as List<dynamic>).cast<double>();
|
||||
final points = <LatLng>[];
|
||||
for (int i = 0; i < pointsFlat.length; i += 2) {
|
||||
points.add(LatLng(pointsFlat[i], pointsFlat[i + 1]));
|
||||
}
|
||||
|
||||
return LineDrawing(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID
|
||||
color: DrawingColors.indexToColor(json['c'] as int),
|
||||
createdAt: DateTime.now(),
|
||||
points: points,
|
||||
senderName: senderName,
|
||||
isReceived: true,
|
||||
messageId: messageId, // Link to source message
|
||||
isShared: false, // Received drawings are not marked as shared
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a copy with updated points
|
||||
LineDrawing copyWith({List<LatLng>? points}) {
|
||||
return LineDrawing(
|
||||
id: id,
|
||||
color: color,
|
||||
createdAt: createdAt,
|
||||
points: points ?? this.points,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
LatLng getCenter() {
|
||||
if (points.isEmpty) return LatLng(0, 0);
|
||||
if (points.length == 1) return points[0];
|
||||
|
||||
// Calculate center as average of all points
|
||||
double sumLat = 0;
|
||||
double sumLon = 0;
|
||||
for (final point in points) {
|
||||
sumLat += point.latitude;
|
||||
sumLon += point.longitude;
|
||||
}
|
||||
return LatLng(sumLat / points.length, sumLon / points.length);
|
||||
}
|
||||
|
||||
@override
|
||||
LatLngBounds getBounds() {
|
||||
if (points.isEmpty) return LatLngBounds(LatLng(0, 0), LatLng(0, 0));
|
||||
if (points.length == 1) return LatLngBounds(points[0], points[0]);
|
||||
|
||||
double minLat = points[0].latitude;
|
||||
double maxLat = points[0].latitude;
|
||||
double minLon = points[0].longitude;
|
||||
double maxLon = points[0].longitude;
|
||||
|
||||
for (final point in points) {
|
||||
if (point.latitude < minLat) minLat = point.latitude;
|
||||
if (point.latitude > maxLat) maxLat = point.latitude;
|
||||
if (point.longitude < minLon) minLon = point.longitude;
|
||||
if (point.longitude > maxLon) maxLon = point.longitude;
|
||||
}
|
||||
|
||||
return LatLngBounds(LatLng(minLat, minLon), LatLng(maxLat, maxLon));
|
||||
}
|
||||
}
|
||||
|
||||
/// Rectangle drawing on map
|
||||
class RectangleDrawing extends MapDrawing {
|
||||
final LatLng topLeft;
|
||||
final LatLng bottomRight;
|
||||
|
||||
RectangleDrawing({
|
||||
required super.id,
|
||||
required super.color,
|
||||
required super.createdAt,
|
||||
required this.topLeft,
|
||||
required this.bottomRight,
|
||||
super.senderName,
|
||||
super.isReceived,
|
||||
super.messageId,
|
||||
super.isShared,
|
||||
super.isSent,
|
||||
super.isHidden,
|
||||
}) : super(type: DrawingShapeType.rectangle);
|
||||
|
||||
/// Get all corner points for rendering
|
||||
List<LatLng> get corners => [
|
||||
topLeft,
|
||||
LatLng(topLeft.latitude, bottomRight.longitude), // top right
|
||||
bottomRight,
|
||||
LatLng(bottomRight.latitude, topLeft.longitude), // bottom left
|
||||
topLeft, // close the rectangle
|
||||
];
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'type': type.name,
|
||||
'color': color.toARGB32(),
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'topLeft': {'lat': topLeft.latitude, 'lon': topLeft.longitude},
|
||||
'bottomRight': {'lat': bottomRight.latitude, 'lon': bottomRight.longitude},
|
||||
'isShared': isShared,
|
||||
// Note: isHidden is not persisted - it's session-only
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toNetworkJson() {
|
||||
// Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), b=bounds [lat1,lon1,lat2,lon2]
|
||||
// Coordinates rounded to 5 decimal places (~1m precision, like SAR markers)
|
||||
// Sender is fetched from packet metadata, not included in JSON
|
||||
return {
|
||||
't': type.index,
|
||||
'c': DrawingColors.colorToIndex(color),
|
||||
'b': [
|
||||
double.parse(topLeft.latitude.toStringAsFixed(5)),
|
||||
double.parse(topLeft.longitude.toStringAsFixed(5)),
|
||||
double.parse(bottomRight.latitude.toStringAsFixed(5)),
|
||||
double.parse(bottomRight.longitude.toStringAsFixed(5)),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
static RectangleDrawing fromJson(Map<String, dynamic> json) {
|
||||
final topLeftJson = json['topLeft'] as Map<String, dynamic>;
|
||||
final bottomRightJson = json['bottomRight'] as Map<String, dynamic>;
|
||||
final senderName = json['sender'] as String?;
|
||||
|
||||
return RectangleDrawing(
|
||||
id: json['id'] as String,
|
||||
color: Color(json['color'] as int),
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
topLeft: LatLng(topLeftJson['lat'] as double, topLeftJson['lon'] as double),
|
||||
bottomRight: LatLng(bottomRightJson['lat'] as double, bottomRightJson['lon'] as double),
|
||||
senderName: senderName,
|
||||
isReceived: senderName != null, // Mark as received if sender is present
|
||||
isShared: json['isShared'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
static RectangleDrawing fromNetworkJson(
|
||||
Map<String, dynamic> json, {
|
||||
String? senderName,
|
||||
String? messageId,
|
||||
}) {
|
||||
// Parse ultra-compact format
|
||||
final bounds = (json['b'] as List<dynamic>).cast<double>();
|
||||
|
||||
return RectangleDrawing(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID
|
||||
color: DrawingColors.indexToColor(json['c'] as int),
|
||||
createdAt: DateTime.now(),
|
||||
topLeft: LatLng(bounds[0], bounds[1]),
|
||||
bottomRight: LatLng(bounds[2], bounds[3]),
|
||||
senderName: senderName,
|
||||
isReceived: true,
|
||||
messageId: messageId, // Link to source message
|
||||
isShared: false, // Received drawings are not marked as shared
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a copy with updated corners
|
||||
RectangleDrawing copyWith({
|
||||
LatLng? topLeft,
|
||||
LatLng? bottomRight,
|
||||
}) {
|
||||
return RectangleDrawing(
|
||||
id: id,
|
||||
color: color,
|
||||
createdAt: createdAt,
|
||||
topLeft: topLeft ?? this.topLeft,
|
||||
bottomRight: bottomRight ?? this.bottomRight,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
LatLng getCenter() {
|
||||
// Center is the midpoint between top-left and bottom-right
|
||||
return LatLng(
|
||||
(topLeft.latitude + bottomRight.latitude) / 2,
|
||||
(topLeft.longitude + bottomRight.longitude) / 2,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
LatLngBounds getBounds() {
|
||||
// Bounds are simply the two corners
|
||||
return LatLngBounds(topLeft, bottomRight);
|
||||
}
|
||||
}
|
||||
209
lib/models/map_layer.dart
Normal file
209
lib/models/map_layer.dart
Normal file
@@ -0,0 +1,209 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
enum MapLayerType {
|
||||
openStreetMap,
|
||||
openTopoMap,
|
||||
esriWorldImagery,
|
||||
googleHybrid,
|
||||
googleRoadmap,
|
||||
googleTerrain,
|
||||
vectorMbtiles,
|
||||
wmsBase,
|
||||
}
|
||||
|
||||
class MapLayer {
|
||||
final MapLayerType type;
|
||||
final String name;
|
||||
final String urlTemplate;
|
||||
final String attribution;
|
||||
final double maxZoom;
|
||||
|
||||
// Vector tile specific properties
|
||||
final bool isVector;
|
||||
final File? mbtilesFile;
|
||||
final String? styleUrl;
|
||||
final String? sourceName;
|
||||
final bool? isGzipped;
|
||||
|
||||
// WMS specific properties
|
||||
final bool isWms;
|
||||
final String? wmsBaseUrl;
|
||||
final List<String>? wmsLayers;
|
||||
final String? wmsFormat;
|
||||
final bool? wmsTransparent;
|
||||
final List<String>? wmsStyles;
|
||||
final Crs? crs;
|
||||
|
||||
const MapLayer({
|
||||
required this.type,
|
||||
required this.name,
|
||||
required this.urlTemplate,
|
||||
required this.attribution,
|
||||
required this.maxZoom,
|
||||
this.isVector = false,
|
||||
this.mbtilesFile,
|
||||
this.styleUrl,
|
||||
this.sourceName,
|
||||
this.isGzipped,
|
||||
this.isWms = false,
|
||||
this.wmsBaseUrl,
|
||||
this.wmsLayers,
|
||||
this.wmsFormat,
|
||||
this.wmsTransparent,
|
||||
this.wmsStyles,
|
||||
this.crs,
|
||||
});
|
||||
|
||||
/// Get localized name for the layer
|
||||
String getLocalizedName(BuildContext context) {
|
||||
final localizations = AppLocalizations.of(context)!;
|
||||
switch (type) {
|
||||
case MapLayerType.openStreetMap:
|
||||
return localizations.openStreetMap;
|
||||
case MapLayerType.openTopoMap:
|
||||
return localizations.openTopoMap;
|
||||
case MapLayerType.esriWorldImagery:
|
||||
return localizations.esriSatellite;
|
||||
case MapLayerType.googleHybrid:
|
||||
return localizations.googleHybrid;
|
||||
case MapLayerType.googleRoadmap:
|
||||
return localizations.googleRoadmap;
|
||||
case MapLayerType.googleTerrain:
|
||||
return localizations.googleTerrain;
|
||||
case MapLayerType.vectorMbtiles:
|
||||
// For vector tiles, use the name from metadata
|
||||
return name;
|
||||
case MapLayerType.wmsBase:
|
||||
// For WMS layers, use the name (will be localized separately)
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
static const openStreetMap = MapLayer(
|
||||
type: MapLayerType.openStreetMap,
|
||||
name: 'OpenStreetMap',
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19, // OSM standard maximum
|
||||
);
|
||||
|
||||
static const openTopoMap = MapLayer(
|
||||
type: MapLayerType.openTopoMap,
|
||||
name: 'OpenTopoMap',
|
||||
urlTemplate: 'https://a.tile.opentopomap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© OpenTopoMap (CC-BY-SA)',
|
||||
maxZoom: 17.49, // OpenTopoMap maximum (just below level 18)
|
||||
);
|
||||
|
||||
static const esriWorldImagery = MapLayer(
|
||||
type: MapLayerType.esriWorldImagery,
|
||||
name: 'ESRI Satellite',
|
||||
urlTemplate:
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
attribution: '© Esri',
|
||||
maxZoom: 19, // ESRI World Imagery maximum
|
||||
);
|
||||
|
||||
static const googleHybrid = MapLayer(
|
||||
type: MapLayerType.googleHybrid,
|
||||
name: 'Google Hybrid',
|
||||
urlTemplate: 'http://mt0.google.com/vt/lyrs=y&hl=en&x={x}&y={y}&z={z}',
|
||||
attribution: '© Google',
|
||||
maxZoom: 20, // Google Maps maximum
|
||||
);
|
||||
|
||||
static const googleRoadmap = MapLayer(
|
||||
type: MapLayerType.googleRoadmap,
|
||||
name: 'Google Roadmap',
|
||||
urlTemplate: 'http://mt0.google.com/vt/lyrs=m&hl=en&x={x}&y={y}&z={z}',
|
||||
attribution: '© Google',
|
||||
maxZoom: 20, // Google Maps maximum
|
||||
);
|
||||
|
||||
static const googleTerrain = MapLayer(
|
||||
type: MapLayerType.googleTerrain,
|
||||
name: 'Google Terrain',
|
||||
urlTemplate: 'http://mt0.google.com/vt/lyrs=p&hl=en&x={x}&y={y}&z={z}',
|
||||
attribution: '© Google',
|
||||
maxZoom: 20, // Google Maps maximum
|
||||
);
|
||||
|
||||
/// Slovenian Aerial Imagery 2024 (Ortofoto) - WMS Base Layer
|
||||
/// Uses EPSG:3794 coordinate system (GeoWebCache tile matrix zoom 0-15)
|
||||
/// Note: CRS is initialized at runtime in getSlovenianAerial2024()
|
||||
static MapLayer getSlovenianAerial2024(Crs slovenianCrs) {
|
||||
return MapLayer(
|
||||
type: MapLayerType.wmsBase,
|
||||
name: 'Ortofoto 2024 (Slovenija)',
|
||||
urlTemplate: '', // Not used for WMS
|
||||
attribution: '© GURS (Geodetska uprava Republike Slovenije)',
|
||||
maxZoom: 15, // GeoWebCache tile matrix maximum
|
||||
isWms: true,
|
||||
wmsBaseUrl: 'https://prostor.zgs.gov.si/geowebcache/service/wms?',
|
||||
wmsLayers: const ['pregledovalnik:DOF_2024'],
|
||||
wmsFormat: 'image/jpeg',
|
||||
wmsTransparent: false,
|
||||
crs: slovenianCrs,
|
||||
);
|
||||
}
|
||||
|
||||
/// Slovenian Topographic Map 1:25000 (DTK25) - WMS Base Layer
|
||||
/// Uses EPSG:3794 coordinate system (GeoWebCache tile matrix zoom 0-15)
|
||||
/// Note: CRS is initialized at runtime in getDTK25()
|
||||
static MapLayer getDTK25(Crs slovenianCrs) {
|
||||
return MapLayer(
|
||||
type: MapLayerType.wmsBase,
|
||||
name: 'DTK25 (Slovenija)',
|
||||
urlTemplate: '', // Not used for WMS
|
||||
attribution: '© GURS (Geodetska uprava Republike Slovenije)',
|
||||
maxZoom: 15, // GeoWebCache tile matrix maximum
|
||||
isWms: true,
|
||||
wmsBaseUrl: 'https://prostor.zgs.gov.si/geowebcache/service/wms?',
|
||||
wmsLayers: const ['pregledovalnik:DTK25'],
|
||||
wmsFormat: 'image/jpeg',
|
||||
wmsTransparent: false,
|
||||
crs: slovenianCrs,
|
||||
);
|
||||
}
|
||||
|
||||
static const List<MapLayer> allLayers = [
|
||||
openStreetMap,
|
||||
openTopoMap,
|
||||
esriWorldImagery,
|
||||
googleHybrid,
|
||||
googleRoadmap,
|
||||
googleTerrain,
|
||||
// Note: Slovenian aerial layer is added dynamically via getSlovenianAerial2024()
|
||||
];
|
||||
|
||||
static MapLayer fromType(MapLayerType type) {
|
||||
return allLayers.firstWhere((layer) => layer.type == type);
|
||||
}
|
||||
|
||||
/// Create a MapLayer from an MBTiles file
|
||||
static MapLayer fromMbtilesFile({
|
||||
required String name,
|
||||
required File mbtilesFile,
|
||||
required String styleUrl,
|
||||
required String sourceName,
|
||||
required double maxZoom,
|
||||
required bool isGzipped,
|
||||
String? attribution,
|
||||
}) {
|
||||
return MapLayer(
|
||||
type: MapLayerType.vectorMbtiles,
|
||||
name: name,
|
||||
urlTemplate: '', // Not used for vector tiles
|
||||
attribution: attribution ?? 'MBTiles',
|
||||
maxZoom: maxZoom,
|
||||
isVector: true,
|
||||
mbtilesFile: mbtilesFile,
|
||||
styleUrl: styleUrl,
|
||||
sourceName: sourceName,
|
||||
isGzipped: isGzipped,
|
||||
);
|
||||
}
|
||||
}
|
||||
491
lib/models/message.dart
Normal file
491
lib/models/message.dart
Normal file
@@ -0,0 +1,491 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'sar_marker.dart';
|
||||
|
||||
/// Message recipient tracking for grouped messages
|
||||
class MessageRecipient {
|
||||
final Uint8List publicKey; // Full public key
|
||||
final String displayName; // Contact display name
|
||||
final MessageDeliveryStatus deliveryStatus;
|
||||
final int? expectedAckTag;
|
||||
final int? roundTripTimeMs;
|
||||
final DateTime? deliveredAt;
|
||||
final DateTime sentAt;
|
||||
|
||||
const MessageRecipient({
|
||||
required this.publicKey,
|
||||
required this.displayName,
|
||||
required this.deliveryStatus,
|
||||
this.expectedAckTag,
|
||||
this.roundTripTimeMs,
|
||||
this.deliveredAt,
|
||||
required this.sentAt,
|
||||
});
|
||||
|
||||
MessageRecipient copyWith({
|
||||
Uint8List? publicKey,
|
||||
String? displayName,
|
||||
MessageDeliveryStatus? deliveryStatus,
|
||||
int? expectedAckTag,
|
||||
int? roundTripTimeMs,
|
||||
DateTime? deliveredAt,
|
||||
DateTime? sentAt,
|
||||
}) {
|
||||
return MessageRecipient(
|
||||
publicKey: publicKey ?? this.publicKey,
|
||||
displayName: displayName ?? this.displayName,
|
||||
deliveryStatus: deliveryStatus ?? this.deliveryStatus,
|
||||
expectedAckTag: expectedAckTag ?? this.expectedAckTag,
|
||||
roundTripTimeMs: roundTripTimeMs ?? this.roundTripTimeMs,
|
||||
deliveredAt: deliveredAt ?? this.deliveredAt,
|
||||
sentAt: sentAt ?? this.sentAt,
|
||||
);
|
||||
}
|
||||
|
||||
String get publicKeyShort {
|
||||
return publicKey
|
||||
.sublist(0, publicKey.length < 6 ? publicKey.length : 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
|
||||
/// Message text types from MeshCore protocol
|
||||
enum MessageTextType {
|
||||
plain(0),
|
||||
cliData(1),
|
||||
signedPlain(2);
|
||||
|
||||
const MessageTextType(this.value);
|
||||
final int value;
|
||||
|
||||
static MessageTextType fromValue(int value) {
|
||||
return MessageTextType.values.firstWhere(
|
||||
(e) => e.value == value,
|
||||
orElse: () => MessageTextType.plain,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Message type (contact, channel, or system)
|
||||
enum MessageType {
|
||||
contact,
|
||||
channel,
|
||||
system, // System messages (log entries, status updates)
|
||||
}
|
||||
|
||||
/// Message delivery status
|
||||
enum MessageDeliveryStatus {
|
||||
sending, // Message is being sent
|
||||
sent, // Message queued with expected ACK
|
||||
delivered, // Delivery confirmed (ACK received)
|
||||
failed, // Delivery failed
|
||||
received, // Message received from another contact
|
||||
}
|
||||
|
||||
/// MeshCore message model
|
||||
class Message {
|
||||
final String id;
|
||||
final MessageType messageType;
|
||||
final Uint8List? senderPublicKeyPrefix; // 6 bytes for contact messages
|
||||
final int? channelIdx; // For channel messages
|
||||
final int pathLen;
|
||||
final MessageTextType textType;
|
||||
final int senderTimestamp; // Unix timestamp
|
||||
final String text;
|
||||
|
||||
// SAR marker data (if this is a SAR message)
|
||||
final bool isSarMarker;
|
||||
final LatLng? sarGpsCoordinates;
|
||||
final String? sarNotes; // Optional message/notes for SAR marker
|
||||
final String? sarCustomEmoji; // Custom emoji for unknown SAR marker types
|
||||
final int? sarColorIndex; // Color index (0-7) from standard palette
|
||||
|
||||
// Display metadata
|
||||
final DateTime receivedAt;
|
||||
final String? senderName;
|
||||
|
||||
// Delivery tracking (for sent messages)
|
||||
final MessageDeliveryStatus deliveryStatus;
|
||||
final int? expectedAckTag; // Expected ACK/TAG from SENT response
|
||||
final int? suggestedTimeoutMs; // Suggested timeout from SENT response
|
||||
final int? roundTripTimeMs; // RTT from SEND_CONFIRMED
|
||||
final DateTime? deliveredAt; // When delivery was confirmed
|
||||
final Uint8List?
|
||||
recipientPublicKey; // Full 32-byte public key of recipient (for retry)
|
||||
|
||||
// Retry tracking (for automatic retry with progressive timeouts)
|
||||
final int retryAttempt; // Current retry attempt (0-3), 0 = first send
|
||||
final DateTime? lastRetryAt; // When last retry was sent
|
||||
final bool
|
||||
usedFloodFallback; // Whether message fell back to flood mode after retries
|
||||
|
||||
// Read status tracking
|
||||
final bool isRead; // Whether message has been read by user
|
||||
|
||||
// Echo detection for public channel messages
|
||||
final int echoCount; // Number of times message was detected being rebroadcast
|
||||
final DateTime? firstEchoAt; // When first echo was detected
|
||||
|
||||
// Drawing message tracking
|
||||
final bool isDrawing; // Whether this message contains a map drawing
|
||||
final String? drawingId; // ID of the associated drawing (for navigation)
|
||||
|
||||
// Message grouping for bulk sends (same message to multiple recipients)
|
||||
final String? groupId; // Shared ID for messages in the same bulk send
|
||||
final List<MessageRecipient>?
|
||||
recipients; // List of recipients (for group leader message)
|
||||
|
||||
Message({
|
||||
required this.id,
|
||||
required this.messageType,
|
||||
this.senderPublicKeyPrefix,
|
||||
this.channelIdx,
|
||||
required this.pathLen,
|
||||
required this.textType,
|
||||
required this.senderTimestamp,
|
||||
required this.text,
|
||||
this.isSarMarker = false,
|
||||
this.sarGpsCoordinates,
|
||||
this.sarNotes,
|
||||
this.sarCustomEmoji,
|
||||
this.sarColorIndex,
|
||||
required this.receivedAt,
|
||||
this.senderName,
|
||||
this.deliveryStatus = MessageDeliveryStatus.received,
|
||||
this.expectedAckTag,
|
||||
this.suggestedTimeoutMs,
|
||||
this.roundTripTimeMs,
|
||||
this.deliveredAt,
|
||||
this.recipientPublicKey,
|
||||
this.retryAttempt = 0,
|
||||
this.lastRetryAt,
|
||||
this.usedFloodFallback = false,
|
||||
this.isRead = false,
|
||||
this.echoCount = 0,
|
||||
this.firstEchoAt,
|
||||
this.isDrawing = false,
|
||||
this.drawingId,
|
||||
this.groupId,
|
||||
this.recipients,
|
||||
});
|
||||
|
||||
/// Get SAR marker type by inferring from message content
|
||||
/// Returns the type inferred from sarCustomEmoji or by parsing the message text
|
||||
SarMarkerType? get sarMarkerType {
|
||||
if (!isSarMarker) return null;
|
||||
|
||||
// If we have a custom emoji stored, infer type from it
|
||||
if (sarCustomEmoji != null && sarCustomEmoji!.isNotEmpty) {
|
||||
return SarMarkerType.fromEmoji(sarCustomEmoji!);
|
||||
}
|
||||
|
||||
// Otherwise, parse the message text to extract the emoji
|
||||
final trimmed = text.trim();
|
||||
if (!trimmed.startsWith('S:')) return null;
|
||||
|
||||
// Extract emoji from format: S:<emoji>:... or S:<emoji>:<colorIndex>:...
|
||||
final parts = trimmed.split(':');
|
||||
if (parts.length < 3) return null;
|
||||
|
||||
final emoji = parts[1];
|
||||
return SarMarkerType.fromEmoji(emoji);
|
||||
}
|
||||
|
||||
/// Get sender public key as hex string
|
||||
String? get senderKeyShort {
|
||||
if (senderPublicKeyPrefix == null) return null;
|
||||
return senderPublicKeyPrefix!
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/// Get sender timestamp as DateTime
|
||||
DateTime get sentAt {
|
||||
return DateTime.fromMillisecondsSinceEpoch(senderTimestamp * 1000);
|
||||
}
|
||||
|
||||
/// Check if message is from a channel
|
||||
bool get isChannelMessage => messageType == MessageType.channel;
|
||||
|
||||
/// Check if message is from a contact
|
||||
bool get isContactMessage => messageType == MessageType.contact;
|
||||
|
||||
/// Check if message is a system message
|
||||
bool get isSystemMessage => messageType == MessageType.system;
|
||||
|
||||
/// Get friendly time since message was sent
|
||||
String get timeAgo {
|
||||
final diff = DateTime.now().difference(sentAt);
|
||||
if (diff.inMinutes < 1) return 'Just now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
return '${diff.inDays}d ago';
|
||||
}
|
||||
|
||||
/// Get display name for sender (basic fallback without contact info)
|
||||
String get displaySender {
|
||||
if (senderName != null && senderName!.isNotEmpty) {
|
||||
return senderName!;
|
||||
}
|
||||
if (senderKeyShort != null) {
|
||||
return senderKeyShort!.substring(0, 8);
|
||||
}
|
||||
if (isChannelMessage && channelIdx != null) {
|
||||
return 'Channel $channelIdx';
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/// Get rich display name for sender using contact information
|
||||
/// Returns emoji + display name if available, otherwise falls back to displaySender
|
||||
String getRichDisplayName(dynamic contact) {
|
||||
if (contact == null) return displaySender;
|
||||
|
||||
// If contact has roleEmoji, use it with displayName
|
||||
final roleEmoji = contact.roleEmoji;
|
||||
if (roleEmoji != null && roleEmoji.isNotEmpty) {
|
||||
return '$roleEmoji ${contact.displayName}';
|
||||
}
|
||||
|
||||
// Otherwise just use advName or displayName
|
||||
return contact.displayName ?? contact.advName ?? displaySender;
|
||||
}
|
||||
|
||||
/// Convert to SAR marker if applicable
|
||||
SarMarker? toSarMarker() {
|
||||
if (!isSarMarker || sarMarkerType == null || sarGpsCoordinates == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Debug: Check what's in sarNotes
|
||||
debugPrint('📍 [Message.toSarMarker] Converting to marker:');
|
||||
debugPrint(' message.text: "$text"');
|
||||
debugPrint(' message.sarNotes: "$sarNotes"');
|
||||
debugPrint(' message.sarMarkerType: $sarMarkerType');
|
||||
debugPrint(' message.sarCustomEmoji: "$sarCustomEmoji"');
|
||||
|
||||
return SarMarker(
|
||||
id: id,
|
||||
type: sarMarkerType!,
|
||||
location: sarGpsCoordinates!,
|
||||
timestamp: sentAt,
|
||||
senderPublicKey: senderPublicKeyPrefix,
|
||||
senderName: senderName,
|
||||
notes: sarNotes, // Use dedicated notes field instead of full text
|
||||
customEmoji: sarCustomEmoji, // Preserve custom emoji for unknown types
|
||||
colorIndex: sarColorIndex, // Pass through color index
|
||||
);
|
||||
}
|
||||
|
||||
/// Get echo status text for channel messages
|
||||
String get echoStatusText {
|
||||
if (!isChannelMessage) return '';
|
||||
|
||||
if (echoCount == 0) {
|
||||
return 'Broadcast (no echoes)';
|
||||
} else if (echoCount == 1) {
|
||||
return 'Rebroadcast by 1 node';
|
||||
} else {
|
||||
return 'Rebroadcast by $echoCount nodes';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get friendly delivery status description
|
||||
String get deliveryStatusText {
|
||||
// For channel messages, show echo status instead
|
||||
if (isChannelMessage && isSentMessage) {
|
||||
return echoStatusText;
|
||||
}
|
||||
|
||||
switch (deliveryStatus) {
|
||||
case MessageDeliveryStatus.sending:
|
||||
if (retryAttempt > 0) {
|
||||
return 'Retrying ($retryAttempt/3)...';
|
||||
}
|
||||
return 'Sending...';
|
||||
|
||||
case MessageDeliveryStatus.sent:
|
||||
if (retryAttempt > 0) {
|
||||
return 'Sent (retry $retryAttempt)';
|
||||
}
|
||||
return 'Sent';
|
||||
|
||||
case MessageDeliveryStatus.delivered:
|
||||
final rttText = roundTripTimeMs != null ? '${roundTripTimeMs}ms' : '';
|
||||
if (retryAttempt > 0 && rttText.isNotEmpty) {
|
||||
return 'Delivered ($rttText) [retry $retryAttempt]';
|
||||
} else if (retryAttempt > 0) {
|
||||
return 'Delivered [retry $retryAttempt]';
|
||||
} else if (rttText.isNotEmpty) {
|
||||
return 'Delivered ($rttText)';
|
||||
}
|
||||
return 'Delivered';
|
||||
|
||||
case MessageDeliveryStatus.failed:
|
||||
if (usedFloodFallback) {
|
||||
return 'Failed (tried flood)';
|
||||
}
|
||||
if (retryAttempt > 0) {
|
||||
final retryWord = retryAttempt == 1 ? 'retry' : 'retries';
|
||||
return 'Failed (after $retryAttempt $retryWord)';
|
||||
}
|
||||
return 'Failed';
|
||||
|
||||
case MessageDeliveryStatus.received:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a sent message (not received)
|
||||
bool get isSentMessage => deliveryStatus != MessageDeliveryStatus.received;
|
||||
|
||||
/// Check if this message is from self (own message)
|
||||
/// [selfPublicKey] - the device's own public key (first 6 bytes)
|
||||
bool isFromSelf(Uint8List? selfPublicKey) {
|
||||
if (selfPublicKey == null || selfPublicKey.length < 6) return false;
|
||||
|
||||
// Compare sender public key prefix with self public key prefix
|
||||
if (senderPublicKeyPrefix != null && senderPublicKeyPrefix!.length >= 6) {
|
||||
return senderPublicKeyPrefix![0] == selfPublicKey[0] &&
|
||||
senderPublicKeyPrefix![1] == selfPublicKey[1] &&
|
||||
senderPublicKeyPrefix![2] == selfPublicKey[2] &&
|
||||
senderPublicKeyPrefix![3] == selfPublicKey[3] &&
|
||||
senderPublicKeyPrefix![4] == selfPublicKey[4] &&
|
||||
senderPublicKeyPrefix![5] == selfPublicKey[5];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Get drawing metadata from message text (returns null if not a drawing)
|
||||
/// Extracts basic info for display in message bubbles
|
||||
Map<String, dynamic>? get drawingMetadata {
|
||||
if (!isDrawing || !text.startsWith('D:')) return null;
|
||||
|
||||
try {
|
||||
// Return basic metadata (actual parsing happens in DrawingMessageParser)
|
||||
return {'hasDrawing': true, 'drawingId': drawingId};
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Message copyWith({
|
||||
String? id,
|
||||
MessageType? messageType,
|
||||
Uint8List? senderPublicKeyPrefix,
|
||||
int? channelIdx,
|
||||
int? pathLen,
|
||||
MessageTextType? textType,
|
||||
int? senderTimestamp,
|
||||
String? text,
|
||||
bool? isSarMarker,
|
||||
LatLng? sarGpsCoordinates,
|
||||
String? sarNotes,
|
||||
String? sarCustomEmoji,
|
||||
int? sarColorIndex,
|
||||
DateTime? receivedAt,
|
||||
String? senderName,
|
||||
MessageDeliveryStatus? deliveryStatus,
|
||||
int? expectedAckTag,
|
||||
int? suggestedTimeoutMs,
|
||||
int? roundTripTimeMs,
|
||||
DateTime? deliveredAt,
|
||||
Uint8List? recipientPublicKey,
|
||||
int? retryAttempt,
|
||||
DateTime? lastRetryAt,
|
||||
bool? usedFloodFallback,
|
||||
bool? isRead,
|
||||
int? echoCount,
|
||||
DateTime? firstEchoAt,
|
||||
bool? isDrawing,
|
||||
String? drawingId,
|
||||
String? groupId,
|
||||
List<MessageRecipient>? recipients,
|
||||
}) {
|
||||
return Message(
|
||||
id: id ?? this.id,
|
||||
messageType: messageType ?? this.messageType,
|
||||
senderPublicKeyPrefix:
|
||||
senderPublicKeyPrefix ?? this.senderPublicKeyPrefix,
|
||||
channelIdx: channelIdx ?? this.channelIdx,
|
||||
pathLen: pathLen ?? this.pathLen,
|
||||
textType: textType ?? this.textType,
|
||||
senderTimestamp: senderTimestamp ?? this.senderTimestamp,
|
||||
text: text ?? this.text,
|
||||
isSarMarker: isSarMarker ?? this.isSarMarker,
|
||||
sarGpsCoordinates: sarGpsCoordinates ?? this.sarGpsCoordinates,
|
||||
sarNotes: sarNotes ?? this.sarNotes,
|
||||
sarCustomEmoji: sarCustomEmoji ?? this.sarCustomEmoji,
|
||||
sarColorIndex: sarColorIndex ?? this.sarColorIndex,
|
||||
receivedAt: receivedAt ?? this.receivedAt,
|
||||
senderName: senderName ?? this.senderName,
|
||||
deliveryStatus: deliveryStatus ?? this.deliveryStatus,
|
||||
expectedAckTag: expectedAckTag ?? this.expectedAckTag,
|
||||
suggestedTimeoutMs: suggestedTimeoutMs ?? this.suggestedTimeoutMs,
|
||||
roundTripTimeMs: roundTripTimeMs ?? this.roundTripTimeMs,
|
||||
deliveredAt: deliveredAt ?? this.deliveredAt,
|
||||
recipientPublicKey: recipientPublicKey ?? this.recipientPublicKey,
|
||||
retryAttempt: retryAttempt ?? this.retryAttempt,
|
||||
lastRetryAt: lastRetryAt ?? this.lastRetryAt,
|
||||
usedFloodFallback: usedFloodFallback ?? this.usedFloodFallback,
|
||||
isRead: isRead ?? this.isRead,
|
||||
echoCount: echoCount ?? this.echoCount,
|
||||
firstEchoAt: firstEchoAt ?? this.firstEchoAt,
|
||||
isDrawing: isDrawing ?? this.isDrawing,
|
||||
drawingId: drawingId ?? this.drawingId,
|
||||
groupId: groupId ?? this.groupId,
|
||||
recipients: recipients ?? this.recipients,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if this is a grouped message (sent to multiple recipients)
|
||||
bool get isGroupedMessage =>
|
||||
groupId != null && recipients != null && recipients!.isNotEmpty;
|
||||
|
||||
/// Get count of recipients who have received/delivered the message
|
||||
int get deliveredRecipientsCount {
|
||||
if (recipients == null) return 0;
|
||||
return recipients!
|
||||
.where((r) => r.deliveryStatus == MessageDeliveryStatus.delivered)
|
||||
.length;
|
||||
}
|
||||
|
||||
/// Get count of recipients who are pending (sending/sent)
|
||||
int get pendingRecipientsCount {
|
||||
if (recipients == null) return 0;
|
||||
return recipients!
|
||||
.where(
|
||||
(r) =>
|
||||
r.deliveryStatus == MessageDeliveryStatus.sending ||
|
||||
r.deliveryStatus == MessageDeliveryStatus.sent,
|
||||
)
|
||||
.length;
|
||||
}
|
||||
|
||||
/// Get count of recipients who failed to receive
|
||||
int get failedRecipientsCount {
|
||||
if (recipients == null) return 0;
|
||||
return recipients!
|
||||
.where((r) => r.deliveryStatus == MessageDeliveryStatus.failed)
|
||||
.length;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
if (isSarMarker) {
|
||||
return 'Message(SAR: ${sarMarkerType?.displayName}, from: $displaySender)';
|
||||
}
|
||||
return 'Message(from: $displaySender, text: ${text.length > 30 ? '${text.substring(0, 30)}...' : text})';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is Message && id == other.id;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
111
lib/models/room_login_state.dart
Normal file
111
lib/models/room_login_state.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Represents the login state for a room
|
||||
class RoomLoginState {
|
||||
final Uint8List publicKeyPrefix;
|
||||
final bool isLoggedIn;
|
||||
final bool isAdmin;
|
||||
final int permissions;
|
||||
final int? tag;
|
||||
final DateTime? loginTime;
|
||||
final bool hasPassword; // Whether we have a saved password
|
||||
|
||||
const RoomLoginState({
|
||||
required this.publicKeyPrefix,
|
||||
this.isLoggedIn = false,
|
||||
this.isAdmin = false,
|
||||
this.permissions = 0,
|
||||
this.tag,
|
||||
this.loginTime,
|
||||
this.hasPassword = false,
|
||||
});
|
||||
|
||||
/// Create a logged-in state
|
||||
factory RoomLoginState.loggedIn({
|
||||
required Uint8List publicKeyPrefix,
|
||||
required int permissions,
|
||||
required bool isAdmin,
|
||||
required int tag,
|
||||
required bool hasPassword,
|
||||
}) {
|
||||
return RoomLoginState(
|
||||
publicKeyPrefix: publicKeyPrefix,
|
||||
isLoggedIn: true,
|
||||
isAdmin: isAdmin,
|
||||
permissions: permissions,
|
||||
tag: tag,
|
||||
loginTime: DateTime.now(),
|
||||
hasPassword: hasPassword,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a logged-out state
|
||||
factory RoomLoginState.loggedOut({
|
||||
required Uint8List publicKeyPrefix,
|
||||
bool hasPassword = false,
|
||||
}) {
|
||||
return RoomLoginState(
|
||||
publicKeyPrefix: publicKeyPrefix,
|
||||
isLoggedIn: false,
|
||||
hasPassword: hasPassword,
|
||||
);
|
||||
}
|
||||
|
||||
/// Copy with modified fields
|
||||
RoomLoginState copyWith({
|
||||
Uint8List? publicKeyPrefix,
|
||||
bool? isLoggedIn,
|
||||
bool? isAdmin,
|
||||
int? permissions,
|
||||
int? tag,
|
||||
DateTime? loginTime,
|
||||
bool? hasPassword,
|
||||
}) {
|
||||
return RoomLoginState(
|
||||
publicKeyPrefix: publicKeyPrefix ?? this.publicKeyPrefix,
|
||||
isLoggedIn: isLoggedIn ?? this.isLoggedIn,
|
||||
isAdmin: isAdmin ?? this.isAdmin,
|
||||
permissions: permissions ?? this.permissions,
|
||||
tag: tag ?? this.tag,
|
||||
loginTime: loginTime ?? this.loginTime,
|
||||
hasPassword: hasPassword ?? this.hasPassword,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get formatted public key prefix (e.g., "15:59:89:54:b4:d4")
|
||||
String get publicKeyPrefixHex {
|
||||
return publicKeyPrefix
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join(':');
|
||||
}
|
||||
|
||||
/// Get login duration if logged in
|
||||
Duration? get loginDuration {
|
||||
if (!isLoggedIn || loginTime == null) return null;
|
||||
return DateTime.now().difference(loginTime!);
|
||||
}
|
||||
|
||||
/// Get formatted login duration (e.g., "2h 15m ago")
|
||||
String? get loginDurationFormatted {
|
||||
final duration = loginDuration;
|
||||
if (duration == null) return null;
|
||||
|
||||
if (duration.inMinutes < 1) {
|
||||
return 'just now';
|
||||
} else if (duration.inMinutes < 60) {
|
||||
return '${duration.inMinutes}m ago';
|
||||
} else if (duration.inHours < 24) {
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes % 60;
|
||||
return minutes > 0 ? '${hours}h ${minutes}m ago' : '${hours}h ago';
|
||||
} else {
|
||||
final days = duration.inDays;
|
||||
return '${days}d ago';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'RoomLoginState(prefix: $publicKeyPrefixHex, loggedIn: $isLoggedIn, admin: $isAdmin, hasPassword: $hasPassword)';
|
||||
}
|
||||
}
|
||||
197
lib/models/sar_marker.dart
Normal file
197
lib/models/sar_marker.dart
Normal file
@@ -0,0 +1,197 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../services/sar_template_service.dart';
|
||||
|
||||
/// SAR (Search & Rescue) marker types
|
||||
enum SarMarkerType {
|
||||
foundPerson('🧑', 'Found Person'),
|
||||
fire('🔥', 'Fire'),
|
||||
stagingArea('🏕️', 'Staging Area'),
|
||||
object('📦', 'Object'),
|
||||
unknown('❓', 'Unknown');
|
||||
|
||||
const SarMarkerType(this.emoji, this.displayName);
|
||||
final String emoji;
|
||||
final String displayName;
|
||||
|
||||
/// Get localized display name
|
||||
String getLocalizedName(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
switch (this) {
|
||||
case SarMarkerType.foundPerson:
|
||||
return l10n.sarMarkerFoundPerson;
|
||||
case SarMarkerType.fire:
|
||||
return l10n.sarMarkerFire;
|
||||
case SarMarkerType.stagingArea:
|
||||
return l10n.sarMarkerStagingArea;
|
||||
case SarMarkerType.object:
|
||||
return l10n.sarMarkerObject;
|
||||
case SarMarkerType.unknown:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
static SarMarkerType fromEmoji(String emoji) {
|
||||
switch (emoji) {
|
||||
case '🧑':
|
||||
case '👤':
|
||||
return SarMarkerType.foundPerson;
|
||||
case '🔥':
|
||||
return SarMarkerType.fire;
|
||||
case '🏕️':
|
||||
case '⛺':
|
||||
return SarMarkerType.stagingArea;
|
||||
case '📦':
|
||||
return SarMarkerType.object;
|
||||
default:
|
||||
return SarMarkerType.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get map marker color
|
||||
String get markerColor {
|
||||
switch (this) {
|
||||
case SarMarkerType.foundPerson:
|
||||
return '#4CAF50'; // Green
|
||||
case SarMarkerType.fire:
|
||||
return '#F44336'; // Red
|
||||
case SarMarkerType.stagingArea:
|
||||
return '#2196F3'; // Blue
|
||||
case SarMarkerType.object:
|
||||
return '#9C27B0'; // Purple
|
||||
default:
|
||||
return '#9E9E9E'; // Gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SAR marker from special messages
|
||||
class SarMarker {
|
||||
final String id;
|
||||
final SarMarkerType type;
|
||||
final LatLng location;
|
||||
final DateTime timestamp;
|
||||
final Uint8List? senderPublicKey;
|
||||
final String? senderName;
|
||||
final String? notes;
|
||||
final String? customEmoji; // For custom SAR markers not in predefined types
|
||||
final int? colorIndex; // Color index (0-7) from standard palette
|
||||
|
||||
SarMarker({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.location,
|
||||
required this.timestamp,
|
||||
this.senderPublicKey,
|
||||
this.senderName,
|
||||
this.notes,
|
||||
this.customEmoji,
|
||||
this.colorIndex,
|
||||
});
|
||||
|
||||
/// Get sender public key as hex string (short)
|
||||
String? get senderKeyShort {
|
||||
if (senderPublicKey == null || senderPublicKey!.length < 8) return null;
|
||||
return senderPublicKey!
|
||||
.sublist(0, 8)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/// Get friendly time since marker was created
|
||||
String get timeAgo {
|
||||
final diff = DateTime.now().difference(timestamp);
|
||||
if (diff.inMinutes < 1) return 'Just now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
return '${diff.inDays}d ago';
|
||||
}
|
||||
|
||||
/// Check if marker is recent (within last hour)
|
||||
bool get isRecent {
|
||||
return DateTime.now().difference(timestamp).inHours < 1;
|
||||
}
|
||||
|
||||
/// Get the emoji to display (custom emoji if available, otherwise type emoji)
|
||||
String get emoji {
|
||||
return customEmoji ?? type.emoji;
|
||||
}
|
||||
|
||||
/// Get display name - uses notes if available, otherwise looks up template by emoji, otherwise type name
|
||||
String get displayName {
|
||||
if (notes != null && notes!.isNotEmpty) {
|
||||
return notes!;
|
||||
}
|
||||
|
||||
// If no notes and we have a custom emoji, try to look up the template
|
||||
if (customEmoji != null) {
|
||||
// Import the service here to avoid circular dependencies
|
||||
// We'll use a static lookup method
|
||||
return _lookupTemplateNameByEmoji(customEmoji!) ?? type.displayName;
|
||||
}
|
||||
|
||||
return type.displayName;
|
||||
}
|
||||
|
||||
/// Look up template name by emoji from SarTemplateService
|
||||
static String? _lookupTemplateNameByEmoji(String emoji) {
|
||||
try {
|
||||
// Use the singleton instance
|
||||
final service = SarTemplateService();
|
||||
if (!service.isInitialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find template with matching emoji
|
||||
final template = service.templates.firstWhere(
|
||||
(t) => t.emoji == emoji,
|
||||
orElse: () => throw StateError('No template found'),
|
||||
);
|
||||
|
||||
return template.name;
|
||||
} catch (e) {
|
||||
// Template not found or service not initialized
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
SarMarker copyWith({
|
||||
String? id,
|
||||
SarMarkerType? type,
|
||||
LatLng? location,
|
||||
DateTime? timestamp,
|
||||
Uint8List? senderPublicKey,
|
||||
String? senderName,
|
||||
String? notes,
|
||||
String? customEmoji,
|
||||
int? colorIndex,
|
||||
}) {
|
||||
return SarMarker(
|
||||
id: id ?? this.id,
|
||||
type: type ?? this.type,
|
||||
location: location ?? this.location,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
senderPublicKey: senderPublicKey ?? this.senderPublicKey,
|
||||
senderName: senderName ?? this.senderName,
|
||||
notes: notes ?? this.notes,
|
||||
customEmoji: customEmoji ?? this.customEmoji,
|
||||
colorIndex: colorIndex ?? this.colorIndex,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SarMarker(type: ${type.displayName}, location: $location, sender: $senderName, time: $timeAgo)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is SarMarker && id == other.id;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
302
lib/models/sar_template.dart
Normal file
302
lib/models/sar_template.dart
Normal file
@@ -0,0 +1,302 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// SAR Template - Customizable template for SAR (Cursor on Target) messages
|
||||
class SarTemplate {
|
||||
final String id;
|
||||
final String emoji;
|
||||
final String name;
|
||||
final String description;
|
||||
final String colorHex;
|
||||
final bool isDefault;
|
||||
|
||||
/// Standard color palette for SAR markers (index 0-7)
|
||||
/// This palette is used for transmission to ensure consistent colors across devices
|
||||
static const List<String> colorPalette = [
|
||||
'#F44336', // 0 - Red
|
||||
'#2196F3', // 1 - Blue
|
||||
'#4CAF50', // 2 - Green
|
||||
'#FFC107', // 3 - Yellow
|
||||
'#FF9800', // 4 - Orange
|
||||
'#9C27B0', // 5 - Purple
|
||||
'#E91E63', // 6 - Pink
|
||||
'#00BCD4', // 7 - Cyan
|
||||
];
|
||||
|
||||
SarTemplate({
|
||||
required this.id,
|
||||
required this.emoji,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.colorHex,
|
||||
this.isDefault = false,
|
||||
});
|
||||
|
||||
/// Get color from hex string
|
||||
Color get color {
|
||||
final hexCode = colorHex.replaceAll('#', '');
|
||||
return Color(int.parse('FF$hexCode', radix: 16));
|
||||
}
|
||||
|
||||
/// Get localized display name for this template
|
||||
/// Returns localized name for default templates, or the stored name for custom templates
|
||||
String getLocalizedName(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
if (l10n == null) return name;
|
||||
|
||||
// Return localized names for default templates
|
||||
switch (id) {
|
||||
case 'default_found_person':
|
||||
return l10n.sarMarkerFoundPerson;
|
||||
case 'default_fire':
|
||||
return l10n.sarMarkerFire;
|
||||
case 'default_staging_area':
|
||||
return l10n.sarMarkerStagingArea;
|
||||
case 'default_object':
|
||||
return l10n.sarMarkerObject;
|
||||
default:
|
||||
// For custom templates, return the stored name
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the closest color index from the standard palette
|
||||
/// Returns 0-7 for standard colors, or the closest match
|
||||
int getColorIndex() {
|
||||
// Normalize both colors to uppercase for comparison
|
||||
final normalizedColorHex = colorHex.toUpperCase();
|
||||
|
||||
// Check for exact match first
|
||||
for (int i = 0; i < colorPalette.length; i++) {
|
||||
if (colorPalette[i].toUpperCase() == normalizedColorHex) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
// If no exact match, find closest color by calculating distance
|
||||
// Parse RGB values
|
||||
final hexCode = colorHex.replaceAll('#', '');
|
||||
final r = int.parse(hexCode.substring(0, 2), radix: 16);
|
||||
final g = int.parse(hexCode.substring(2, 4), radix: 16);
|
||||
final b = int.parse(hexCode.substring(4, 6), radix: 16);
|
||||
|
||||
int closestIndex = 0;
|
||||
double minDistance = double.infinity;
|
||||
|
||||
for (int i = 0; i < colorPalette.length; i++) {
|
||||
final paletteHex = colorPalette[i].replaceAll('#', '');
|
||||
final pr = int.parse(paletteHex.substring(0, 2), radix: 16);
|
||||
final pg = int.parse(paletteHex.substring(2, 4), radix: 16);
|
||||
final pb = int.parse(paletteHex.substring(4, 6), radix: 16);
|
||||
|
||||
// Calculate Euclidean distance in RGB space
|
||||
final distance = ((r - pr) * (r - pr) + (g - pg) * (g - pg) + (b - pb) * (b - pb)).toDouble();
|
||||
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return closestIndex;
|
||||
}
|
||||
|
||||
/// Get color hex from palette index
|
||||
static String getColorFromIndex(int index) {
|
||||
if (index < 0 || index >= colorPalette.length) {
|
||||
return '#9E9E9E'; // Gray for invalid index
|
||||
}
|
||||
return colorPalette[index];
|
||||
}
|
||||
|
||||
/// Create from JSON
|
||||
factory SarTemplate.fromJson(Map<String, dynamic> json) {
|
||||
return SarTemplate(
|
||||
id: json['id'] as String,
|
||||
emoji: json['emoji'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String? ?? '',
|
||||
colorHex: json['colorHex'] as String,
|
||||
isDefault: json['isDefault'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'emoji': emoji,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'colorHex': colorHex,
|
||||
'isDefault': isDefault,
|
||||
};
|
||||
}
|
||||
|
||||
/// Create from SAR message format (S:emoji:0,0:description)
|
||||
/// Example: S:🧑:0,0:Person found
|
||||
factory SarTemplate.fromSarMessage(String message) {
|
||||
final trimmed = message.trim();
|
||||
if (!trimmed.startsWith('S:')) {
|
||||
throw FormatException('SAR message must start with "S:"');
|
||||
}
|
||||
|
||||
// Parse format: S:emoji:lat,lon:description
|
||||
final parts = trimmed.split(':');
|
||||
if (parts.length < 3) {
|
||||
throw FormatException('Invalid SAR message format');
|
||||
}
|
||||
|
||||
final emoji = parts[1].trim();
|
||||
if (emoji.isEmpty) {
|
||||
throw FormatException('Emoji cannot be empty');
|
||||
}
|
||||
|
||||
// Extract description (everything after the third colon)
|
||||
String description = '';
|
||||
if (parts.length > 3) {
|
||||
description = parts.sublist(3).join(':').trim();
|
||||
}
|
||||
|
||||
// Generate ID from emoji + description
|
||||
final id = '${emoji}_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
// Auto-assign color based on emoji
|
||||
String colorHex = _getColorForEmoji(emoji);
|
||||
|
||||
return SarTemplate(
|
||||
id: id,
|
||||
emoji: emoji,
|
||||
name: description.isNotEmpty ? description : emoji,
|
||||
description: description,
|
||||
colorHex: colorHex,
|
||||
isDefault: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to SAR message format with placeholder coordinates
|
||||
/// New format: S:emoji:colorIndex:0,0:description
|
||||
/// Example: S:🧑:2:0,0:Person found (2 = Green)
|
||||
String toSarMessage() {
|
||||
final colorIndex = getColorIndex();
|
||||
if (description.isNotEmpty) {
|
||||
return 'S:$emoji:$colorIndex:0,0:$description';
|
||||
}
|
||||
return 'S:$emoji:$colorIndex:0,0';
|
||||
}
|
||||
|
||||
/// Auto-assign color based on emoji (uses standard color palette)
|
||||
static String _getColorForEmoji(String emoji) {
|
||||
// Default emoji to color mapping using standard palette
|
||||
final colorMap = {
|
||||
// Green (index 2) - Person, Safe, Nature
|
||||
'🧑': colorPalette[2],
|
||||
'👤': colorPalette[2],
|
||||
'✅': colorPalette[2],
|
||||
'🌲': colorPalette[2],
|
||||
|
||||
// Red (index 0) - Fire, Hazard, Medical, Emergency
|
||||
'🔥': colorPalette[0],
|
||||
'🚒': colorPalette[0],
|
||||
'🚑': colorPalette[0],
|
||||
'❌': colorPalette[0],
|
||||
'🏥': colorPalette[0],
|
||||
|
||||
// Orange (index 4) - Staging, Assembly
|
||||
'🏕️': colorPalette[4],
|
||||
'⛺': colorPalette[4],
|
||||
|
||||
// Purple (index 5) - Objects
|
||||
'📦': colorPalette[5],
|
||||
|
||||
// Blue (index 1) - Water, Air support
|
||||
'🚁': colorPalette[1],
|
||||
'💧': colorPalette[1],
|
||||
|
||||
// Yellow (index 3) - Warning, Caution
|
||||
'⚠️': colorPalette[3],
|
||||
};
|
||||
|
||||
return colorMap[emoji] ?? '#9E9E9E'; // Default gray for unknown emojis
|
||||
}
|
||||
|
||||
/// Copy with modifications
|
||||
SarTemplate copyWith({
|
||||
String? id,
|
||||
String? emoji,
|
||||
String? name,
|
||||
String? description,
|
||||
String? colorHex,
|
||||
bool? isDefault,
|
||||
}) {
|
||||
return SarTemplate(
|
||||
id: id ?? this.id,
|
||||
emoji: emoji ?? this.emoji,
|
||||
name: name ?? this.name,
|
||||
description: description ?? this.description,
|
||||
colorHex: colorHex ?? this.colorHex,
|
||||
isDefault: isDefault ?? this.isDefault,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SarTemplate(id: $id, emoji: $emoji, name: $name, description: $description)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is SarTemplate && id == other.id;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
|
||||
/// Default templates (uses standard color palette)
|
||||
/// Colors reference:
|
||||
/// - 0 Red (#F44336) - Fire, Hazard, Medical
|
||||
/// - 1 Blue (#2196F3) - Water, Helicopter
|
||||
/// - 2 Green (#4CAF50) - Found Person, Safe
|
||||
/// - 3 Yellow (#FFC107) - Warning
|
||||
/// - 4 Orange (#FF9800) - Staging Area
|
||||
/// - 5 Purple (#9C27B0) - Object
|
||||
/// - 6 Pink (#E91E63) - Reserved
|
||||
/// - 7 Cyan (#00BCD4) - Reserved
|
||||
static List<SarTemplate> get defaults {
|
||||
return [
|
||||
SarTemplate(
|
||||
id: 'default_found_person',
|
||||
emoji: '🧑',
|
||||
name: 'Found Person',
|
||||
description: '',
|
||||
colorHex: colorPalette[2], // Green
|
||||
isDefault: true,
|
||||
),
|
||||
SarTemplate(
|
||||
id: 'default_fire',
|
||||
emoji: '🔥',
|
||||
name: 'Fire',
|
||||
description: '',
|
||||
colorHex: colorPalette[0], // Red
|
||||
isDefault: true,
|
||||
),
|
||||
SarTemplate(
|
||||
id: 'default_staging_area',
|
||||
emoji: '🏕️',
|
||||
name: 'Staging Area',
|
||||
description: '',
|
||||
colorHex: colorPalette[4], // Orange
|
||||
isDefault: true,
|
||||
),
|
||||
SarTemplate(
|
||||
id: 'default_object',
|
||||
emoji: '📦',
|
||||
name: 'Object',
|
||||
description: '',
|
||||
colorHex: colorPalette[5], // Purple
|
||||
isDefault: true,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
83
lib/models/sent_message_tracker.dart
Normal file
83
lib/models/sent_message_tracker.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Tracks sent public channel messages for echo detection
|
||||
///
|
||||
/// When a message is sent to the public channel, it's encrypted with AES128-ECB
|
||||
/// which is deterministic. When another node receives and rebroadcasts it,
|
||||
/// the raw packet will be byte-for-byte identical. We can detect these echoes
|
||||
/// by comparing the raw packet data from PUSH_CODE_LOG_RX_DATA (0x88) against
|
||||
/// packets we've sent.
|
||||
class SentMessageTracker {
|
||||
/// Unique identifier for the message (timestamp-based)
|
||||
final String messageId;
|
||||
|
||||
/// SHA256 hash of the encrypted packet for fast O(1) lookup
|
||||
final String packetHashHex;
|
||||
|
||||
/// Original raw encrypted packet bytes (for verification)
|
||||
final Uint8List? rawPacket;
|
||||
|
||||
/// When the message was sent
|
||||
final DateTime sentTime;
|
||||
|
||||
/// When this tracker expires (default: 5 minutes)
|
||||
final DateTime expiryTime;
|
||||
|
||||
/// Number of times we've detected this message being rebroadcast
|
||||
int echoCount;
|
||||
|
||||
/// Unique echo paths detected (SNR/RSSI signatures)
|
||||
/// Format: "snr_rssi" e.g., "20_-56" means SNR=5.0dB (20/4), RSSI=-56dBm
|
||||
final Set<String> uniqueEchoPaths;
|
||||
|
||||
/// Timestamps when echoes were detected
|
||||
final List<DateTime> echoTimestamps;
|
||||
|
||||
SentMessageTracker({
|
||||
required this.messageId,
|
||||
required this.packetHashHex,
|
||||
this.rawPacket,
|
||||
required this.sentTime,
|
||||
required this.expiryTime,
|
||||
this.echoCount = 0,
|
||||
Set<String>? uniqueEchoPaths,
|
||||
List<DateTime>? echoTimestamps,
|
||||
}) : uniqueEchoPaths = uniqueEchoPaths ?? {},
|
||||
echoTimestamps = echoTimestamps ?? [];
|
||||
|
||||
/// Check if this tracker has expired
|
||||
bool get isExpired => DateTime.now().isAfter(expiryTime);
|
||||
|
||||
/// Time until expiry
|
||||
Duration get timeUntilExpiry => expiryTime.difference(DateTime.now());
|
||||
|
||||
/// Add an echo detection
|
||||
void addEcho(int snrRaw, int rssiDbm) {
|
||||
echoCount++;
|
||||
uniqueEchoPaths.add('${snrRaw}_$rssiDbm');
|
||||
echoTimestamps.add(DateTime.now());
|
||||
}
|
||||
|
||||
/// Get the SNR in dB from raw value
|
||||
static double snrRawToDb(int snrRaw) {
|
||||
return snrRaw.toSigned(8) / 4.0;
|
||||
}
|
||||
|
||||
/// Get formatted echo statistics
|
||||
String get echoStats {
|
||||
if (echoCount == 0) return 'No echoes detected';
|
||||
if (echoCount == 1) return '1 echo from ${uniqueEchoPaths.length} path(s)';
|
||||
return '$echoCount echoes from ${uniqueEchoPaths.length} path(s)';
|
||||
}
|
||||
|
||||
/// Get average time to first echo
|
||||
Duration? get timeToFirstEcho {
|
||||
if (echoTimestamps.isEmpty) return null;
|
||||
return echoTimestamps.first.difference(sentTime);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SentMessageTracker(id=$messageId, echoes=$echoCount, paths=${uniqueEchoPaths.length}, expired=$isExpired)';
|
||||
}
|
||||
}
|
||||
71
lib/models/sse_server_config.dart
Normal file
71
lib/models/sse_server_config.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
/// SSE Server Configuration Model
|
||||
///
|
||||
/// Configuration for the SSE (Server-Sent Events) web server that enables
|
||||
/// multiple app instances to share a single MeshCore BLE device.
|
||||
class SseServerConfig {
|
||||
/// Server bind address (e.g., "0.0.0.0" for all interfaces, "127.0.0.1" for localhost)
|
||||
final String host;
|
||||
|
||||
/// Server port (default: 12929)
|
||||
final int port;
|
||||
|
||||
/// Whether the SSE server is enabled
|
||||
final bool enabled;
|
||||
|
||||
/// Optional authentication token for basic security
|
||||
/// Clients must include this token in Authorization header
|
||||
final String? authToken;
|
||||
|
||||
const SseServerConfig({
|
||||
this.host = '0.0.0.0',
|
||||
this.port = 12929,
|
||||
this.enabled = false,
|
||||
this.authToken,
|
||||
});
|
||||
|
||||
/// Create a copy with updated fields
|
||||
SseServerConfig copyWith({
|
||||
String? host,
|
||||
int? port,
|
||||
bool? enabled,
|
||||
String? authToken,
|
||||
}) {
|
||||
return SseServerConfig(
|
||||
host: host ?? this.host,
|
||||
port: port ?? this.port,
|
||||
enabled: enabled ?? this.enabled,
|
||||
authToken: authToken ?? this.authToken,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get server URL for clients to connect to
|
||||
String getServerUrl({String? ipAddress}) {
|
||||
final ip = ipAddress ?? host;
|
||||
return 'http://$ip:$port';
|
||||
}
|
||||
|
||||
/// Convert to JSON for persistence
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'host': host,
|
||||
'port': port,
|
||||
'enabled': enabled,
|
||||
'authToken': authToken,
|
||||
};
|
||||
}
|
||||
|
||||
/// Create from JSON
|
||||
factory SseServerConfig.fromJson(Map<String, dynamic> json) {
|
||||
return SseServerConfig(
|
||||
host: json['host'] as String? ?? '0.0.0.0',
|
||||
port: json['port'] as int? ?? 12929,
|
||||
enabled: json['enabled'] as bool? ?? false,
|
||||
authToken: json['authToken'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SseServerConfig(host: $host, port: $port, enabled: $enabled, hasAuth: ${authToken != null})';
|
||||
}
|
||||
}
|
||||
52
lib/models/update_info.dart
Normal file
52
lib/models/update_info.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
/// Information about an available app update
|
||||
class UpdateInfo {
|
||||
final bool isAvailable;
|
||||
final String currentCommitHash;
|
||||
final String? latestCommitHash;
|
||||
final String? downloadUrl;
|
||||
final String? buildId;
|
||||
final String? timestamp;
|
||||
|
||||
const UpdateInfo({
|
||||
required this.isAvailable,
|
||||
required this.currentCommitHash,
|
||||
this.latestCommitHash,
|
||||
this.downloadUrl,
|
||||
this.buildId,
|
||||
this.timestamp,
|
||||
});
|
||||
|
||||
/// Factory constructor for when no update is available
|
||||
factory UpdateInfo.noUpdate(String currentCommitHash) {
|
||||
return UpdateInfo(
|
||||
isAvailable: false,
|
||||
currentCommitHash: currentCommitHash,
|
||||
);
|
||||
}
|
||||
|
||||
/// Factory constructor for when an update is available
|
||||
factory UpdateInfo.available({
|
||||
required String currentCommitHash,
|
||||
required String latestCommitHash,
|
||||
required String downloadUrl,
|
||||
String? buildId,
|
||||
String? timestamp,
|
||||
}) {
|
||||
return UpdateInfo(
|
||||
isAvailable: true,
|
||||
currentCommitHash: currentCommitHash,
|
||||
latestCommitHash: latestCommitHash,
|
||||
downloadUrl: downloadUrl,
|
||||
buildId: buildId,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UpdateInfo(isAvailable: $isAvailable, '
|
||||
'current: $currentCommitHash, '
|
||||
'latest: $latestCommitHash, '
|
||||
'downloadUrl: $downloadUrl)';
|
||||
}
|
||||
}
|
||||
700
lib/providers/app_provider.dart
Normal file
700
lib/providers/app_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
110
lib/providers/channels_provider.dart
Normal file
110
lib/providers/channels_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
2210
lib/providers/connection_provider.dart
Normal file
2210
lib/providers/connection_provider.dart
Normal file
File diff suppressed because it is too large
Load Diff
446
lib/providers/contacts_provider.dart
Normal file
446
lib/providers/contacts_provider.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
496
lib/providers/drawing_provider.dart
Normal file
496
lib/providers/drawing_provider.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
150
lib/providers/helpers/message_delivery_tracker.dart
Normal file
150
lib/providers/helpers/message_delivery_tracker.dart
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
101
lib/providers/helpers/message_retry_manager.dart
Normal file
101
lib/providers/helpers/message_retry_manager.dart
Normal 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];
|
||||
}
|
||||
}
|
||||
103
lib/providers/helpers/ping_tracker.dart
Normal file
103
lib/providers/helpers/ping_tracker.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
84
lib/providers/helpers/room_login_manager.dart
Normal file
84
lib/providers/helpers/room_login_manager.dart
Normal 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(':');
|
||||
}
|
||||
}
|
||||
433
lib/providers/map_provider.dart
Normal file
433
lib/providers/map_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
1525
lib/providers/messages_provider.dart
Normal file
1525
lib/providers/messages_provider.dart
Normal file
File diff suppressed because it is too large
Load Diff
324
lib/screens/contacts_tab.dart
Normal file
324
lib/screens/contacts_tab.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
834
lib/screens/device_config_screen.dart
Normal file
834
lib/screens/device_config_screen.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
780
lib/screens/home_screen.dart
Normal file
780
lib/screens/home_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
1323
lib/screens/map_management_screen.dart
Normal file
1323
lib/screens/map_management_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
2593
lib/screens/map_tab.dart
Normal file
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
10
lib/screens/mcp.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"dart": {
|
||||
"command": "dart",
|
||||
"args": [
|
||||
"mcp-server"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
920
lib/screens/messages_tab.dart
Normal file
920
lib/screens/messages_tab.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
590
lib/screens/packet_log_screen.dart
Normal file
590
lib/screens/packet_log_screen.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
456
lib/screens/sar_template_management_screen.dart
Normal file
456
lib/screens/sar_template_management_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
970
lib/screens/settings_screen.dart
Normal file
970
lib/screens/settings_screen.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
399
lib/screens/welcome_wizard_screen.dart
Normal file
399
lib/screens/welcome_wizard_screen.dart
Normal 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});
|
||||
}
|
||||
174
lib/services/background_location_service.dart
Normal file
174
lib/services/background_location_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
308
lib/services/ble/ble_command_queue.dart
Normal file
308
lib/services/ble/ble_command_queue.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
230
lib/services/ble/ble_command_sender.dart
Normal file
230
lib/services/ble/ble_command_sender.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
398
lib/services/ble/ble_connection_manager.dart
Normal file
398
lib/services/ble/ble_connection_manager.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
1184
lib/services/ble/ble_response_handler.dart
Normal file
1184
lib/services/ble/ble_response_handler.dart
Normal file
File diff suppressed because it is too large
Load Diff
151
lib/services/buffer_reader.dart
Normal file
151
lib/services/buffer_reader.dart
Normal 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)';
|
||||
}
|
||||
}
|
||||
129
lib/services/buffer_writer.dart
Normal file
129
lib/services/buffer_writer.dart
Normal 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()})';
|
||||
}
|
||||
}
|
||||
54
lib/services/build_info_service.dart
Normal file
54
lib/services/build_info_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
321
lib/services/cayenne_lpp_parser.dart
Normal file
321
lib/services/cayenne_lpp_parser.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
207
lib/services/contact_storage_service.dart
Normal file
207
lib/services/contact_storage_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
341
lib/services/gpx_service.dart
Normal file
341
lib/services/gpx_service.dart
Normal 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')}';
|
||||
}
|
||||
}
|
||||
90
lib/services/locale_preferences.dart
Normal file
90
lib/services/locale_preferences.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
517
lib/services/location_tracking_service.dart
Normal file
517
lib/services/location_tracking_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
508
lib/services/map_marker_service.dart
Normal file
508
lib/services/map_marker_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
273
lib/services/mbtiles_service.dart
Normal file
273
lib/services/mbtiles_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
740
lib/services/meshcore_ble_service.dart
Normal file
740
lib/services/meshcore_ble_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
153
lib/services/meshcore_constants.dart
Normal file
153
lib/services/meshcore_constants.dart
Normal 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
|
||||
}
|
||||
190
lib/services/meshcore_opcode_names.dart
Normal file
190
lib/services/meshcore_opcode_names.dart
Normal 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
|
||||
}
|
||||
71
lib/services/message_destination_preferences.dart
Normal file
71
lib/services/message_destination_preferences.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
254
lib/services/message_storage_service.dart
Normal file
254
lib/services/message_storage_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
347
lib/services/network_scanner_service.dart
Normal file
347
lib/services/network_scanner_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
621
lib/services/notification_service.dart
Normal file
621
lib/services/notification_service.dart
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
292
lib/services/protocol/frame_builder.dart
Normal file
292
lib/services/protocol/frame_builder.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
436
lib/services/protocol/frame_parser.dart
Normal file
436
lib/services/protocol/frame_parser.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
244
lib/services/sar_template_service.dart
Normal file
244
lib/services/sar_template_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
625
lib/services/sse_client_service.dart
Normal file
625
lib/services/sse_client_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
744
lib/services/sse_server_service.dart
Normal file
744
lib/services/sse_server_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
284
lib/services/tile_cache_service.dart
Normal file
284
lib/services/tile_cache_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
226
lib/services/trail_color_service.dart
Normal file
226
lib/services/trail_color_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
135
lib/services/update_checker_service.dart
Normal file
135
lib/services/update_checker_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
511
lib/services/validation_service.dart
Normal file
511
lib/services/validation_service.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
42
lib/services/wizard_preferences.dart
Normal file
42
lib/services/wizard_preferences.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
73
lib/services/wms_tile_provider.dart
Normal file
73
lib/services/wms_tile_provider.dart
Normal 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
287
lib/theme/app_theme.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
27
lib/utils/battery_display_helper.dart
Normal file
27
lib/utils/battery_display_helper.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Utility class for battery and signal display helpers
|
||||
/// Used across home_screen.dart and contact_tile.dart
|
||||
class BatteryDisplayHelper {
|
||||
/// Get battery icon based on percentage level
|
||||
static IconData getBatteryIcon(double percentage) {
|
||||
if (percentage > 80) return Icons.battery_full;
|
||||
if (percentage > 50) return Icons.battery_5_bar;
|
||||
if (percentage > 20) return Icons.battery_3_bar;
|
||||
return Icons.battery_1_bar;
|
||||
}
|
||||
|
||||
/// Get battery color based on percentage level
|
||||
static Color getBatteryColor(double percentage) {
|
||||
if (percentage > 50) return Colors.green;
|
||||
if (percentage > 20) return Colors.orange;
|
||||
return Colors.red;
|
||||
}
|
||||
|
||||
/// Get signal strength color based on RSSI value
|
||||
static Color getSignalColor(int rssi) {
|
||||
if (rssi > -60) return Colors.green;
|
||||
if (rssi > -70) return Colors.orange;
|
||||
return Colors.red;
|
||||
}
|
||||
}
|
||||
8
lib/utils/debug_print.dart
Normal file
8
lib/utils/debug_print.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Debug print that only outputs in debug builds
|
||||
void debugPrint(Object? message) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(message);
|
||||
}
|
||||
}
|
||||
161
lib/utils/drawing_message_parser.dart
Normal file
161
lib/utils/drawing_message_parser.dart
Normal file
@@ -0,0 +1,161 @@
|
||||
import 'dart:convert';
|
||||
import '../models/map_drawing.dart';
|
||||
|
||||
/// Parser for drawing messages transmitted over mesh network
|
||||
class DrawingMessageParser {
|
||||
/// Drawing message prefix
|
||||
static const String prefix = 'D:';
|
||||
|
||||
/// Check if message is a drawing message
|
||||
static bool isDrawingMessage(String text) {
|
||||
return text.startsWith(prefix);
|
||||
}
|
||||
|
||||
/// Parse drawing message text into MapDrawing object
|
||||
/// senderName and messageId should be extracted from packet metadata
|
||||
/// Returns null if parsing fails
|
||||
static MapDrawing? parseDrawingMessage(
|
||||
String text, {
|
||||
String? senderName,
|
||||
String? messageId,
|
||||
}) {
|
||||
if (!isDrawingMessage(text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Remove prefix
|
||||
final jsonStr = text.substring(prefix.length);
|
||||
|
||||
// Parse JSON
|
||||
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||
|
||||
// Use ultra-compact network format parser
|
||||
// Sender name and message ID come from packet metadata, not JSON
|
||||
return MapDrawing.fromNetworkJson(
|
||||
json,
|
||||
senderName: senderName,
|
||||
messageId: messageId,
|
||||
);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create drawing message text from MapDrawing object
|
||||
/// Sender will be determined from packet metadata on receiving end
|
||||
static String createDrawingMessage(MapDrawing drawing) {
|
||||
final json = drawing.toNetworkJson();
|
||||
final jsonStr = jsonEncode(json).toString();
|
||||
return '$prefix$jsonStr';
|
||||
}
|
||||
|
||||
/// Get drawing type display name from drawing message text
|
||||
/// Returns "Line" or "Rectangle", or null if parsing fails
|
||||
static String? getDrawingTypeDisplay(String text) {
|
||||
if (!isDrawingMessage(text)) return null;
|
||||
|
||||
try {
|
||||
final jsonStr = text.substring(prefix.length);
|
||||
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||
final typeNum = json['t'] as int?;
|
||||
|
||||
if (typeNum == null) return null;
|
||||
|
||||
switch (typeNum) {
|
||||
case 0:
|
||||
return 'Line';
|
||||
case 1:
|
||||
return 'Rectangle';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get color name from drawing message text
|
||||
/// Returns color name like "Red", "Blue", etc., or null if parsing fails
|
||||
static String? getColorName(String text) {
|
||||
if (!isDrawingMessage(text)) return null;
|
||||
|
||||
try {
|
||||
final jsonStr = text.substring(prefix.length);
|
||||
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||
final colorIndex = json['c'] as int?;
|
||||
|
||||
if (colorIndex == null) return null;
|
||||
|
||||
// Color mapping from DrawingColor enum
|
||||
const colorNames = [
|
||||
'Red', // 0
|
||||
'Blue', // 1
|
||||
'Green', // 2
|
||||
'Yellow', // 3
|
||||
'Orange', // 4
|
||||
'Purple', // 5
|
||||
'Pink', // 6
|
||||
'Cyan', // 7
|
||||
];
|
||||
|
||||
if (colorIndex >= 0 && colorIndex < colorNames.length) {
|
||||
return colorNames[colorIndex];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get drawing metadata for display in message bubbles
|
||||
/// Returns map with type, color, and pointCount, or null if parsing fails
|
||||
static Map<String, dynamic>? getDrawingMetadata(String text) {
|
||||
if (!isDrawingMessage(text)) return null;
|
||||
|
||||
try {
|
||||
final jsonStr = text.substring(prefix.length);
|
||||
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||
|
||||
final typeNum = json['t'] as int?;
|
||||
final colorIndex = json['c'] as int?;
|
||||
|
||||
if (typeNum == null || colorIndex == null) return null;
|
||||
|
||||
// Get type display name
|
||||
String type;
|
||||
int? pointCount;
|
||||
|
||||
switch (typeNum) {
|
||||
case 0: // Line
|
||||
type = 'Line';
|
||||
final points = json['p'] as List?;
|
||||
pointCount = points != null ? points.length ~/ 2 : null;
|
||||
break;
|
||||
case 1: // Rectangle
|
||||
type = 'Rectangle';
|
||||
pointCount = 4; // Rectangles always have 4 corners
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get color name
|
||||
const colorNames = [
|
||||
'Red', 'Blue', 'Green', 'Yellow', 'Orange', 'Purple', 'Pink', 'Cyan',
|
||||
];
|
||||
final color = colorIndex >= 0 && colorIndex < colorNames.length
|
||||
? colorNames[colorIndex]
|
||||
: 'Unknown';
|
||||
|
||||
return {
|
||||
'type': type,
|
||||
'color': color,
|
||||
'pointCount': pointCount,
|
||||
};
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
18
lib/utils/key_comparison.dart
Normal file
18
lib/utils/key_comparison.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Extension on Uint8List to provide comparison functionality for public keys.
|
||||
///
|
||||
/// This extension is used to compare MeshCore public keys (32 bytes) or their
|
||||
/// prefixes (6 bytes) across the application.
|
||||
extension Uint8ListComparison on Uint8List {
|
||||
/// Compares this Uint8List with another for exact equality.
|
||||
///
|
||||
/// Returns true if both lists have the same length and identical bytes.
|
||||
bool matches(Uint8List other) {
|
||||
if (length != other.length) return false;
|
||||
for (int i = 0; i < length; i++) {
|
||||
if (this[i] != other[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
49
lib/utils/message_extensions.dart
Normal file
49
lib/utils/message_extensions.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import '../models/message.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// Extension for Message to provide localized delivery status
|
||||
extension MessageLocalization on Message {
|
||||
/// Get localized delivery status text
|
||||
String getLocalizedDeliveryStatus(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
// For channel messages, show echo count instead of delivery status
|
||||
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
|
||||
if (echoCount == 0) {
|
||||
return l10n.broadcast; // "Broadcast (no echoes yet)"
|
||||
} else if (echoCount == 1) {
|
||||
return 'Rebroadcast by 1 node';
|
||||
} else {
|
||||
return 'Rebroadcast by $echoCount nodes';
|
||||
}
|
||||
}
|
||||
|
||||
switch (deliveryStatus) {
|
||||
case MessageDeliveryStatus.sending:
|
||||
return l10n.sending;
|
||||
case MessageDeliveryStatus.sent:
|
||||
return l10n.sent;
|
||||
case MessageDeliveryStatus.delivered:
|
||||
if (roundTripTimeMs != null) {
|
||||
return l10n.deliveredWithTime(roundTripTimeMs!);
|
||||
}
|
||||
return l10n.delivered;
|
||||
case MessageDeliveryStatus.failed:
|
||||
return l10n.failed;
|
||||
case MessageDeliveryStatus.received:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get localized time ago string
|
||||
String getLocalizedTimeAgo(BuildContext context) {
|
||||
final diff = DateTime.now().difference(sentAt);
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
if (diff.inMinutes < 1) return l10n.justNow;
|
||||
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
|
||||
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
|
||||
return l10n.daysAgo(diff.inDays);
|
||||
}
|
||||
}
|
||||
407
lib/utils/sample_data_generator.dart
Normal file
407
lib/utils/sample_data_generator.dart
Normal file
@@ -0,0 +1,407 @@
|
||||
import 'dart:typed_data';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/contact_telemetry.dart';
|
||||
import '../models/message.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// Generates sample data for testing/demo purposes
|
||||
class SampleDataGenerator {
|
||||
static final Random _random = Random();
|
||||
|
||||
/// Generate sample contacts around a center location
|
||||
static List<Contact> generateContacts({
|
||||
required LatLng centerLocation,
|
||||
required AppLocalizations l10n,
|
||||
int teamMemberCount = 5,
|
||||
int channelCount = 2,
|
||||
}) {
|
||||
final contacts = <Contact>[];
|
||||
final now = DateTime.now();
|
||||
|
||||
final teamNames = [
|
||||
'👮${l10n.samplePoliceLead}',
|
||||
'🚁${l10n.sampleDroneOperator}',
|
||||
'🧑🏻🚒${l10n.sampleFirefighterAlpha}',
|
||||
'🧑⚕️${l10n.sampleMedicCharlie}',
|
||||
'📡${l10n.sampleCommandDelta}',
|
||||
'🚒${l10n.sampleFireEngine}',
|
||||
'👨✈️${l10n.sampleAirSupport}',
|
||||
'🧑💼${l10n.sampleBaseCoordinator}',
|
||||
];
|
||||
|
||||
final channelNames = [
|
||||
l10n.general,
|
||||
l10n.channelEmergency,
|
||||
l10n.channelCoordination,
|
||||
l10n.channelUpdates,
|
||||
];
|
||||
|
||||
// Generate team members (chat contacts)
|
||||
for (int i = 0; i < teamMemberCount && i < teamNames.length; i++) {
|
||||
// Generate location within ~1km radius
|
||||
final latOffset = (_random.nextDouble() - 0.5) * 0.02; // ~1km
|
||||
final lonOffset = (_random.nextDouble() - 0.5) * 0.02;
|
||||
final lat = centerLocation.latitude + latOffset;
|
||||
final lon = centerLocation.longitude + lonOffset;
|
||||
|
||||
// Generate random public key
|
||||
final publicKey = Uint8List.fromList(
|
||||
List.generate(32, (_) => _random.nextInt(256)),
|
||||
);
|
||||
|
||||
// Random battery 20-100%
|
||||
final battery = 20 + _random.nextInt(81);
|
||||
|
||||
// Random temperature 15-35°C
|
||||
final temp = 15.0 + _random.nextDouble() * 20.0;
|
||||
|
||||
final telemetry = ContactTelemetry(
|
||||
gpsLocation: LatLng(lat, lon),
|
||||
batteryPercentage: battery.toDouble(),
|
||||
batteryMilliVolts: 3000.0 + (battery / 100.0) * 1200.0,
|
||||
temperature: temp,
|
||||
timestamp: now.subtract(Duration(minutes: _random.nextInt(10))),
|
||||
);
|
||||
|
||||
final contact = Contact(
|
||||
publicKey: publicKey,
|
||||
type: ContactType.chat,
|
||||
flags: 0,
|
||||
outPathLen: 1,
|
||||
outPath: Uint8List(32),
|
||||
advName: teamNames[i],
|
||||
lastAdvert: now.millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: (lat * 1e6).toInt(),
|
||||
advLon: (lon * 1e6).toInt(),
|
||||
lastMod: now.millisecondsSinceEpoch ~/ 1000,
|
||||
telemetry: telemetry,
|
||||
);
|
||||
|
||||
contacts.add(contact);
|
||||
}
|
||||
|
||||
// Generate channels/rooms
|
||||
for (int i = 0; i < channelCount && i < channelNames.length; i++) {
|
||||
// Generate random public key
|
||||
final publicKey = Uint8List.fromList(
|
||||
List.generate(32, (_) => _random.nextInt(256)),
|
||||
);
|
||||
|
||||
// Channel index stored in outPath[0]
|
||||
final outPath = Uint8List(32);
|
||||
outPath[0] = i; // Channel index
|
||||
|
||||
final channel = Contact(
|
||||
publicKey: publicKey,
|
||||
type: ContactType.room,
|
||||
flags: 0,
|
||||
outPathLen: 1,
|
||||
outPath: outPath,
|
||||
advName: channelNames[i],
|
||||
lastAdvert: now.millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: 0, // Channels don't have location
|
||||
advLon: 0,
|
||||
lastMod: now.millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
|
||||
contacts.add(channel);
|
||||
}
|
||||
|
||||
return contacts;
|
||||
}
|
||||
|
||||
/// Generate sample SAR markers around a center location
|
||||
static List<Message> generateSarMarkerMessages({
|
||||
required LatLng centerLocation,
|
||||
required AppLocalizations l10n,
|
||||
int foundPersonCount = 2,
|
||||
int fireCount = 1,
|
||||
int stagingCount = 1,
|
||||
int objectCount = 1,
|
||||
}) {
|
||||
final messages = <Message>[];
|
||||
final now = DateTime.now();
|
||||
int messageId = 1;
|
||||
|
||||
// Generate found person markers
|
||||
for (int i = 0; i < foundPersonCount; i++) {
|
||||
final latOffset = (_random.nextDouble() - 0.5) * 0.015;
|
||||
final lonOffset = (_random.nextDouble() - 0.5) * 0.015;
|
||||
final lat = centerLocation.latitude + latOffset;
|
||||
final lon = centerLocation.longitude + lonOffset;
|
||||
|
||||
final senderKey = Uint8List.fromList(
|
||||
List.generate(32, (_) => _random.nextInt(256)),
|
||||
);
|
||||
|
||||
final timestamp = now.subtract(Duration(minutes: 10 + i * 5));
|
||||
messages.add(Message(
|
||||
id: 'sample_fp_$messageId',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
text: 'S:🧑:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}',
|
||||
receivedAt: timestamp,
|
||||
isSarMarker: true,
|
||||
sarGpsCoordinates: LatLng(lat, lon),
|
||||
sarCustomEmoji: '🧑',
|
||||
senderName: l10n.sampleTeamMember,
|
||||
));
|
||||
messageId++;
|
||||
}
|
||||
|
||||
// Generate fire markers
|
||||
for (int i = 0; i < fireCount; i++) {
|
||||
final latOffset = (_random.nextDouble() - 0.5) * 0.015;
|
||||
final lonOffset = (_random.nextDouble() - 0.5) * 0.015;
|
||||
final lat = centerLocation.latitude + latOffset;
|
||||
final lon = centerLocation.longitude + lonOffset;
|
||||
|
||||
final senderKey = Uint8List.fromList(
|
||||
List.generate(32, (_) => _random.nextInt(256)),
|
||||
);
|
||||
|
||||
final timestamp = now.subtract(Duration(minutes: 20 + i * 5));
|
||||
messages.add(Message(
|
||||
id: 'sample_fire_$messageId',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
text: 'S:🔥:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}',
|
||||
receivedAt: timestamp,
|
||||
isSarMarker: true,
|
||||
sarGpsCoordinates: LatLng(lat, lon),
|
||||
sarCustomEmoji: '🔥',
|
||||
senderName: l10n.sampleScout,
|
||||
));
|
||||
messageId++;
|
||||
}
|
||||
|
||||
// Generate staging area markers
|
||||
for (int i = 0; i < stagingCount; i++) {
|
||||
final latOffset = (_random.nextDouble() - 0.5) * 0.015;
|
||||
final lonOffset = (_random.nextDouble() - 0.5) * 0.015;
|
||||
final lat = centerLocation.latitude + latOffset;
|
||||
final lon = centerLocation.longitude + lonOffset;
|
||||
|
||||
final senderKey = Uint8List.fromList(
|
||||
List.generate(32, (_) => _random.nextInt(256)),
|
||||
);
|
||||
|
||||
final timestamp = now.subtract(Duration(minutes: 30 + i * 5));
|
||||
messages.add(Message(
|
||||
id: 'sample_staging_$messageId',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
text: 'S:🏕️:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}',
|
||||
receivedAt: timestamp,
|
||||
isSarMarker: true,
|
||||
sarGpsCoordinates: LatLng(lat, lon),
|
||||
sarCustomEmoji: '🏕️',
|
||||
senderName: l10n.sampleBase,
|
||||
));
|
||||
messageId++;
|
||||
}
|
||||
|
||||
// Generate object markers
|
||||
for (int i = 0; i < objectCount; i++) {
|
||||
final latOffset = (_random.nextDouble() - 0.5) * 0.015;
|
||||
final lonOffset = (_random.nextDouble() - 0.5) * 0.015;
|
||||
final lat = centerLocation.latitude + latOffset;
|
||||
final lon = centerLocation.longitude + lonOffset;
|
||||
|
||||
final senderKey = Uint8List.fromList(
|
||||
List.generate(32, (_) => _random.nextInt(256)),
|
||||
);
|
||||
|
||||
final timestamp = now.subtract(Duration(minutes: 40 + i * 5));
|
||||
final notes = [
|
||||
l10n.sampleObjectBackpack,
|
||||
l10n.sampleObjectVehicle,
|
||||
l10n.sampleObjectCamping,
|
||||
l10n.sampleObjectTrailMarker,
|
||||
];
|
||||
|
||||
messages.add(Message(
|
||||
id: 'sample_object_$messageId',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
text: 'S:📦:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}${notes[i % notes.length]}',
|
||||
receivedAt: timestamp,
|
||||
isSarMarker: true,
|
||||
sarGpsCoordinates: LatLng(lat, lon),
|
||||
sarCustomEmoji: '📦',
|
||||
senderName: l10n.sampleSearcher,
|
||||
));
|
||||
messageId++;
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/// Generate sample map drawings
|
||||
static List<dynamic> generateDrawings({
|
||||
required LatLng centerLocation,
|
||||
required AppLocalizations l10n,
|
||||
}) {
|
||||
final drawings = <dynamic>[];
|
||||
final now = DateTime.now();
|
||||
|
||||
// Generate a line drawing (e.g., search path)
|
||||
final linePoints = <LatLng>[
|
||||
LatLng(centerLocation.latitude + 0.002, centerLocation.longitude - 0.003),
|
||||
LatLng(centerLocation.latitude + 0.004, centerLocation.longitude - 0.002),
|
||||
LatLng(centerLocation.latitude + 0.005, centerLocation.longitude + 0.001),
|
||||
LatLng(centerLocation.latitude + 0.003, centerLocation.longitude + 0.003),
|
||||
];
|
||||
|
||||
drawings.add({
|
||||
'type': 'line',
|
||||
'id': 'sample_line_${now.millisecondsSinceEpoch}',
|
||||
'color': Colors.blue.toARGB32(),
|
||||
'createdAt': now.subtract(const Duration(minutes: 15)).toIso8601String(),
|
||||
'points': linePoints.map((p) => {'lat': p.latitude, 'lon': p.longitude}).toList(),
|
||||
'sender': l10n.sampleTeamMember,
|
||||
});
|
||||
|
||||
// Generate a rectangle drawing (e.g., search area)
|
||||
drawings.add({
|
||||
'type': 'rectangle',
|
||||
'id': 'sample_rect_${now.millisecondsSinceEpoch + 1}',
|
||||
'color': Colors.red.toARGB32(),
|
||||
'createdAt': now.subtract(const Duration(minutes: 10)).toIso8601String(),
|
||||
'topLeft': {
|
||||
'lat': centerLocation.latitude - 0.003,
|
||||
'lon': centerLocation.longitude - 0.004,
|
||||
},
|
||||
'bottomRight': {
|
||||
'lat': centerLocation.latitude - 0.001,
|
||||
'lon': centerLocation.longitude - 0.001,
|
||||
},
|
||||
'sender': l10n.sampleScout,
|
||||
});
|
||||
|
||||
return drawings;
|
||||
}
|
||||
|
||||
/// Generate sample channel messages for public channels
|
||||
static List<Message> generateChannelMessages({
|
||||
LatLng? centerLocation,
|
||||
required AppLocalizations l10n,
|
||||
int generalChannelMessages = 8,
|
||||
int emergencyChannelMessages = 5,
|
||||
}) {
|
||||
// Use provided location or default to Ljubljana, Slovenia
|
||||
final center = centerLocation ?? const LatLng(46.0569, 14.5058);
|
||||
final messages = <Message>[];
|
||||
final now = DateTime.now();
|
||||
int messageId = 1000; // Start with high ID to avoid conflicts
|
||||
|
||||
// Sample messages for General channel (index 0)
|
||||
final generalMessages = [
|
||||
l10n.sampleMsgAllTeamsCheckIn,
|
||||
l10n.sampleMsgWeatherUpdate,
|
||||
l10n.sampleMsgBaseCamp,
|
||||
l10n.sampleMsgTeamAlpha,
|
||||
l10n.sampleMsgRadioCheck,
|
||||
l10n.sampleMsgWaterSupply,
|
||||
l10n.sampleMsgTeamBravo,
|
||||
l10n.sampleMsgEtaRallyPoint,
|
||||
l10n.sampleMsgSupplyDrop,
|
||||
l10n.sampleMsgDroneSurvey,
|
||||
l10n.sampleMsgTeamCharlie,
|
||||
l10n.sampleMsgRadioDiscipline,
|
||||
];
|
||||
|
||||
// Sample messages for Emergency channel (index 1)
|
||||
// Mix regular messages and SAR markers
|
||||
final emergencyMessages = [
|
||||
l10n.sampleMsgUrgentMedical,
|
||||
'S:🧑:${center.latitude.toStringAsFixed(5)},${(center.longitude + 0.005).toStringAsFixed(5)}${l10n.sampleMsgAdultMale}',
|
||||
l10n.sampleMsgFireSpotted,
|
||||
'S:🔥:${(center.latitude + 0.008).toStringAsFixed(5)},${(center.longitude + 0.003).toStringAsFixed(5)}${l10n.sampleMsgSpreadingRapidly}',
|
||||
l10n.sampleMsgPriorityHelicopter,
|
||||
l10n.sampleMsgMedicalTeamEnRoute,
|
||||
l10n.sampleMsgEvacHelicopter,
|
||||
l10n.sampleMsgEmergencyResolved,
|
||||
'S:🏕️:${(center.latitude - 0.002).toStringAsFixed(5)},${(center.longitude - 0.004).toStringAsFixed(5)}${l10n.sampleMsgEmergencyStagingArea}',
|
||||
l10n.sampleMsgEmergencyServices,
|
||||
];
|
||||
|
||||
final teamNames = [
|
||||
l10n.sampleAlphaTeamLead,
|
||||
l10n.sampleBravoScout,
|
||||
l10n.sampleCharlieMedic,
|
||||
l10n.sampleDeltaNavigator,
|
||||
l10n.sampleEchoSupport,
|
||||
l10n.sampleBaseCommand,
|
||||
l10n.sampleFieldCoordinator,
|
||||
l10n.sampleMedicalTeam,
|
||||
];
|
||||
|
||||
// Generate General channel messages
|
||||
for (int i = 0; i < generalChannelMessages && i < generalMessages.length; i++) {
|
||||
final senderKey = Uint8List.fromList(
|
||||
List.generate(32, (_) => _random.nextInt(256)),
|
||||
);
|
||||
|
||||
// Messages spread over the last 2 hours
|
||||
final minutesAgo = 120 - (i * 15) - _random.nextInt(10);
|
||||
final timestamp = now.subtract(Duration(minutes: minutesAgo));
|
||||
|
||||
messages.add(Message(
|
||||
id: 'sample_general_$messageId',
|
||||
messageType: MessageType.channel,
|
||||
channelIdx: 0, // General channel
|
||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
text: generalMessages[i],
|
||||
receivedAt: timestamp,
|
||||
senderName: teamNames[_random.nextInt(teamNames.length)],
|
||||
));
|
||||
messageId++;
|
||||
}
|
||||
|
||||
// Generate Emergency channel messages
|
||||
for (int i = 0; i < emergencyChannelMessages && i < emergencyMessages.length; i++) {
|
||||
final senderKey = Uint8List.fromList(
|
||||
List.generate(32, (_) => _random.nextInt(256)),
|
||||
);
|
||||
|
||||
// Emergency messages more recent (last hour)
|
||||
final minutesAgo = 60 - (i * 10) - _random.nextInt(5);
|
||||
final timestamp = now.subtract(Duration(minutes: minutesAgo));
|
||||
|
||||
messages.add(Message(
|
||||
id: 'sample_emergency_$messageId',
|
||||
messageType: MessageType.channel,
|
||||
channelIdx: 1, // Emergency channel
|
||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
text: emergencyMessages[i],
|
||||
receivedAt: timestamp,
|
||||
senderName: teamNames[_random.nextInt(teamNames.length)],
|
||||
));
|
||||
messageId++;
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
24
lib/utils/sar_marker_extensions.dart
Normal file
24
lib/utils/sar_marker_extensions.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// Extension for SarMarkerType to provide localized display names
|
||||
extension SarMarkerTypeLocalization on SarMarkerType {
|
||||
/// Get localized display name for this SAR marker type
|
||||
String getLocalizedName(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
switch (this) {
|
||||
case SarMarkerType.foundPerson:
|
||||
return l10n.foundPerson;
|
||||
case SarMarkerType.fire:
|
||||
return l10n.fire;
|
||||
case SarMarkerType.stagingArea:
|
||||
return l10n.stagingArea;
|
||||
case SarMarkerType.object:
|
||||
return 'Object'; // Not commonly used, keeping English for now
|
||||
case SarMarkerType.unknown:
|
||||
return 'Unknown'; // Not commonly used, keeping English for now
|
||||
}
|
||||
}
|
||||
}
|
||||
227
lib/utils/sar_message_parser.dart
Normal file
227
lib/utils/sar_message_parser.dart
Normal file
@@ -0,0 +1,227 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
import '../models/message.dart';
|
||||
|
||||
/// Parser for SAR (Search & Rescue) special messages
|
||||
/// Old format: `S:<emoji>:<latitude>,<longitude>:<optional_message>`
|
||||
/// New format: `S:<emoji>:<colorIndex>:<latitude>,<longitude>:<optional_message>`
|
||||
/// Examples:
|
||||
/// S:🧑:37.7749,-122.4194 (old format)
|
||||
/// S:🧑:2:37.7749,-122.4194 (new format with green color)
|
||||
/// S:🔥:0:40.7128,-74.0060:Large wildfire spreading (new format with red color)
|
||||
class SarMessageParser {
|
||||
// Regex for new format with color index: S:emoji:colorIndex:lat,lon:notes
|
||||
// Captures: emoji, colorIndex (single digit), latitude, longitude, optional message
|
||||
static final RegExp _sarPatternNew = RegExp(
|
||||
r'^S:([^:]+):(\d):(-?\d+\.?\d*),(-?\d+\.?\d*):?(.*)',
|
||||
multiLine: false,
|
||||
);
|
||||
|
||||
// Regex for old format (backward compatibility): S:emoji:lat,lon:notes
|
||||
// Captures: emoji, latitude, longitude, optional message
|
||||
static final RegExp _sarPatternOld = RegExp(
|
||||
r'^S:([^:]+):(-?\d+\.?\d*),(-?\d+\.?\d*):?(.*)',
|
||||
multiLine: false,
|
||||
);
|
||||
|
||||
/// Check if a message is a SAR marker message
|
||||
static bool isSarMessage(String text) {
|
||||
// Extract just the first line for matching
|
||||
final firstLine = text.trim().split('\n').first;
|
||||
return firstLine.startsWith('S:') &&
|
||||
(_sarPatternNew.hasMatch(firstLine) ||
|
||||
_sarPatternOld.hasMatch(firstLine));
|
||||
}
|
||||
|
||||
/// Parse a SAR message and extract marker information
|
||||
/// Returns null if the message is not a valid SAR message
|
||||
/// Supports both old format (S:emoji:lat,lon:notes) and new format (S:emoji:colorIndex:lat,lon:notes)
|
||||
static SarMarkerInfo? parse(String text) {
|
||||
final trimmed = text.trim();
|
||||
if (!trimmed.startsWith('S:')) return null;
|
||||
|
||||
// Extract first line (actual SAR marker)
|
||||
final firstLine = trimmed.split('\n').first;
|
||||
|
||||
// Try new format first (with color index)
|
||||
var match = _sarPatternNew.firstMatch(firstLine);
|
||||
bool isNewFormat = match != null;
|
||||
|
||||
// If new format didn't match, try old format
|
||||
if (match == null) {
|
||||
match = _sarPatternOld.firstMatch(firstLine);
|
||||
if (match == null) return null;
|
||||
}
|
||||
|
||||
try {
|
||||
String emoji;
|
||||
double latitude;
|
||||
double longitude;
|
||||
String? inlineMessage;
|
||||
int? colorIndex;
|
||||
|
||||
if (isNewFormat) {
|
||||
// New format: S:emoji:colorIndex:lat,lon:notes
|
||||
emoji = match.group(1)!;
|
||||
colorIndex = int.parse(match.group(2)!);
|
||||
latitude = double.parse(match.group(3)!);
|
||||
longitude = double.parse(match.group(4)!);
|
||||
inlineMessage = match.group(5)?.trim();
|
||||
} else {
|
||||
// Old format: S:emoji:lat,lon:notes
|
||||
emoji = match.group(1)!;
|
||||
colorIndex = null; // No color index in old format
|
||||
latitude = double.parse(match.group(2)!);
|
||||
longitude = double.parse(match.group(3)!);
|
||||
inlineMessage = match.group(4)?.trim();
|
||||
}
|
||||
|
||||
// Validate coordinates
|
||||
if (latitude < -90 || latitude > 90) return null;
|
||||
if (longitude < -180 || longitude > 180) return null;
|
||||
|
||||
// Validate color index if present
|
||||
if (colorIndex != null && (colorIndex < 0 || colorIndex > 7)) {
|
||||
colorIndex = null; // Invalid index, ignore it
|
||||
}
|
||||
|
||||
final markerType = SarMarkerType.fromEmoji(emoji);
|
||||
final location = LatLng(latitude, longitude);
|
||||
|
||||
// Combine inline message with multi-line notes
|
||||
String? notes;
|
||||
if (inlineMessage != null && inlineMessage.isNotEmpty) {
|
||||
notes = inlineMessage;
|
||||
}
|
||||
|
||||
// Check for multi-line notes (lines after the first line)
|
||||
final additionalNotes = extractNotes(text);
|
||||
if (additionalNotes != null) {
|
||||
notes = notes != null ? '$notes\n$additionalNotes' : additionalNotes;
|
||||
}
|
||||
|
||||
return SarMarkerInfo(
|
||||
type: markerType,
|
||||
location: location,
|
||||
emoji: emoji,
|
||||
notes: notes,
|
||||
colorIndex: colorIndex,
|
||||
);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Enhance a Message with SAR marker information
|
||||
static Message enhanceMessage(Message message) {
|
||||
final sarInfo = parse(message.text);
|
||||
if (sarInfo == null) return message;
|
||||
|
||||
return message.copyWith(
|
||||
isSarMarker: true,
|
||||
sarGpsCoordinates: sarInfo.location,
|
||||
sarNotes: sarInfo.notes, // Extract and store notes
|
||||
sarCustomEmoji: sarInfo.emoji, // Always store emoji for type inference
|
||||
sarColorIndex: sarInfo.colorIndex, // Store color index
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a SAR marker message text (new format with color index)
|
||||
static String createSarMessage({
|
||||
required SarMarkerType type,
|
||||
required LatLng location,
|
||||
String? notes,
|
||||
int? colorIndex,
|
||||
}) {
|
||||
// New format: S:emoji:colorIndex:lat,lon:notes
|
||||
final colorIdx = colorIndex ?? 0; // Default to red if not specified
|
||||
final text =
|
||||
'S:${type.emoji}:$colorIdx:${location.latitude.toString()},${location.longitude.toString()}';
|
||||
if (notes != null && notes.isNotEmpty) {
|
||||
// Use colon-separated format for inline message
|
||||
return '$text:$notes';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/// Extract additional notes from SAR message (text after the marker)
|
||||
static String? extractNotes(String text) {
|
||||
final trimmed = text.trim();
|
||||
final lines = trimmed.split('\n');
|
||||
if (lines.length <= 1) return null;
|
||||
|
||||
// Everything after the first line is considered notes
|
||||
return lines.sublist(1).join('\n').trim();
|
||||
}
|
||||
|
||||
/// Validate SAR message format
|
||||
static bool isValidFormat(String text) {
|
||||
return isSarMessage(text) && parse(text) != null;
|
||||
}
|
||||
|
||||
/// Get a user-friendly error message for invalid SAR format
|
||||
static String? getFormatError(String text) {
|
||||
if (!text.trim().startsWith('S:')) {
|
||||
return 'SAR message must start with "S:"';
|
||||
}
|
||||
|
||||
final parts = text.trim().split(':');
|
||||
if (parts.length < 3) {
|
||||
return 'Invalid format. Use: S:<emoji>:<latitude>,<longitude>';
|
||||
}
|
||||
|
||||
final emoji = parts[1];
|
||||
if (emoji.isEmpty) {
|
||||
return 'Missing emoji marker (🧑, 🔥, or 🏕️)';
|
||||
}
|
||||
|
||||
final coords = parts[2];
|
||||
if (!coords.contains(',')) {
|
||||
return 'Coordinates must be separated by comma';
|
||||
}
|
||||
|
||||
final coordParts = coords.split(',');
|
||||
if (coordParts.length != 2) {
|
||||
return 'Invalid coordinates format';
|
||||
}
|
||||
|
||||
try {
|
||||
final lat = double.parse(coordParts[0]);
|
||||
final lon = double.parse(coordParts[1]);
|
||||
|
||||
if (lat < -90 || lat > 90) {
|
||||
return 'Latitude must be between -90 and 90';
|
||||
}
|
||||
if (lon < -180 || lon > 180) {
|
||||
return 'Longitude must be between -180 and 180';
|
||||
}
|
||||
} catch (e) {
|
||||
return 'Invalid coordinate values';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed SAR marker information
|
||||
class SarMarkerInfo {
|
||||
final SarMarkerType type;
|
||||
final LatLng location;
|
||||
final String emoji;
|
||||
final String? notes;
|
||||
final int?
|
||||
colorIndex; // Color index from standard palette (0-7), null for backward compatibility
|
||||
|
||||
SarMarkerInfo({
|
||||
required this.type,
|
||||
required this.location,
|
||||
required this.emoji,
|
||||
this.notes,
|
||||
this.colorIndex,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SarMarkerInfo(type: ${type.displayName}, location: $location, colorIndex: $colorIndex, notes: $notes)';
|
||||
}
|
||||
}
|
||||
87
lib/utils/slovenian_crs.dart
Normal file
87
lib/utils/slovenian_crs.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'dart:math' show Point;
|
||||
import 'dart:ui' show Rect;
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:proj4dart/proj4dart.dart' as proj4;
|
||||
|
||||
/// EPSG:3794 - Slovenia 1996 / Slovene National Grid
|
||||
/// Transverse Mercator projection for Slovenia
|
||||
///
|
||||
/// Official definition from https://epsg.io/3794:
|
||||
/// +proj=tmerc +lat_0=0 +lon_0=15 +k=0.9999 +x_0=500000 +y_0=-5000000
|
||||
/// +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs
|
||||
///
|
||||
/// This CRS is used by Slovenian government WMS services (prostor.zgs.gov.si)
|
||||
|
||||
/// Register and get EPSG:3794 projection
|
||||
proj4.Projection getEpsg3794Projection() {
|
||||
const epsg3794Def =
|
||||
'+proj=tmerc +lat_0=0 +lon_0=15 +k=0.9999 '
|
||||
'+x_0=500000 +y_0=-5000000 +ellps=GRS80 '
|
||||
'+towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs';
|
||||
|
||||
// Register projection if not already registered
|
||||
try {
|
||||
return proj4.Projection.get('EPSG:3794') ??
|
||||
proj4.Projection.add('EPSG:3794', epsg3794Def);
|
||||
} catch (e) {
|
||||
// If already registered, get it
|
||||
return proj4.Projection.get('EPSG:3794')!;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create Proj4Crs for EPSG:3794
|
||||
///
|
||||
/// Configuration matches the GeoWebCache tile grid used by prostor.zgs.gov.si
|
||||
///
|
||||
/// Official tile grid from WMTS GetCapabilities:
|
||||
/// - TopLeftCorner: 373217.6542445397, 246158.298050262
|
||||
/// - Bounds: X: 373217.65 to 695777.65, Y: 31118.30 to 246158.30
|
||||
/// - Tile size: 256x256 pixels
|
||||
/// - Scale denominators converted to resolutions using: resolution = scaleDenom * 0.00028
|
||||
Crs getSlovenianCrs() {
|
||||
final projection = getEpsg3794Projection();
|
||||
|
||||
// Resolutions calculated from GeoWebCache scale denominators
|
||||
// Formula: resolution (m/px) = scaleDenominator * 0.00028 (OGC standard)
|
||||
final resolutions = <double>[
|
||||
420.0, // Zoom 0 - ScaleDenom: 1500000
|
||||
280.0, // Zoom 1 - ScaleDenom: 1000000
|
||||
210.0, // Zoom 2 - ScaleDenom: 750000
|
||||
140.0, // Zoom 3 - ScaleDenom: 500000
|
||||
70.0, // Zoom 4 - ScaleDenom: 250000
|
||||
28.0, // Zoom 5 - ScaleDenom: 100000
|
||||
14.0, // Zoom 6 - ScaleDenom: 50000
|
||||
7.0, // Zoom 7 - ScaleDenom: 25000
|
||||
4.2, // Zoom 8 - ScaleDenom: 15000
|
||||
2.8, // Zoom 9 - ScaleDenom: 10000
|
||||
1.4, // Zoom 10 - ScaleDenom: 5000
|
||||
0.56, // Zoom 11 - ScaleDenom: 2000
|
||||
0.28, // Zoom 12 - ScaleDenom: 1000
|
||||
0.14, // Zoom 13 - ScaleDenom: 500
|
||||
0.07, // Zoom 14 - ScaleDenom: 250
|
||||
0.028, // Zoom 15 - ScaleDenom: 100
|
||||
];
|
||||
|
||||
// Bounds from WMS capabilities (actual data extent in Slovenia)
|
||||
final bounds = Rect.fromLTRB(
|
||||
373217.65, // min X (west)
|
||||
31118.30, // min Y (south) - top in Rect coordinates
|
||||
695777.65, // max X (east)
|
||||
246158.30, // max Y (north) - bottom in Rect coordinates
|
||||
);
|
||||
|
||||
// Origin from WMTS TileMatrixSet TopLeftCorner
|
||||
// This is the top-left corner of the tile pyramid (min X, max Y)
|
||||
final origin = Point<double>(373217.6542445397, 246158.298050262);
|
||||
|
||||
return Proj4Crs.fromFactory(
|
||||
code: 'EPSG:3794',
|
||||
proj4Projection: projection,
|
||||
resolutions: resolutions,
|
||||
bounds: bounds,
|
||||
origins: [origin],
|
||||
);
|
||||
}
|
||||
|
||||
/// Singleton instance of Slovenian CRS for reuse
|
||||
final Crs slovenianCrs = getSlovenianCrs();
|
||||
25
lib/utils/time_ago_extensions.dart
Normal file
25
lib/utils/time_ago_extensions.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// Extension to provide localized "time ago" formatting
|
||||
extension TimeAgoExtension on Duration {
|
||||
/// Get localized time ago string
|
||||
String toLocalizedTimeAgo(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
if (inMinutes < 1) return l10n.justNow;
|
||||
if (inMinutes < 60) return l10n.minutesAgo(inMinutes);
|
||||
if (inHours < 24) return l10n.hoursAgo(inHours);
|
||||
return l10n.daysAgo(inDays);
|
||||
}
|
||||
|
||||
/// Get localized time ago string with seconds support
|
||||
String toLocalizedTimeAgoWithSeconds(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
if (inSeconds < 60) return l10n.secondsAgo(inSeconds);
|
||||
if (inMinutes < 60) return l10n.minutesAgo(inMinutes);
|
||||
if (inHours < 24) return l10n.hoursAgo(inHours);
|
||||
return l10n.daysAgo(inDays);
|
||||
}
|
||||
}
|
||||
37
lib/utils/toast_logger.dart
Normal file
37
lib/utils/toast_logger.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/messages_provider.dart';
|
||||
|
||||
/// Toast logger utility - replaces SnackBar with system messages in the channels tab
|
||||
class ToastLogger {
|
||||
/// Log an info message
|
||||
static void info(BuildContext context, String message) {
|
||||
_log(context, message, 'info');
|
||||
}
|
||||
|
||||
/// Log a success message
|
||||
static void success(BuildContext context, String message) {
|
||||
_log(context, message, 'success');
|
||||
}
|
||||
|
||||
/// Log a warning message
|
||||
static void warning(BuildContext context, String message) {
|
||||
_log(context, message, 'warning');
|
||||
}
|
||||
|
||||
/// Log an error message
|
||||
static void error(BuildContext context, String message) {
|
||||
_log(context, message, 'error');
|
||||
}
|
||||
|
||||
/// Internal method to log a system message
|
||||
static void _log(BuildContext context, String message, String level) {
|
||||
try {
|
||||
final messagesProvider = context.read<MessagesProvider>();
|
||||
messagesProvider.logSystemMessage(text: message, level: level);
|
||||
} catch (e) {
|
||||
// Fallback to print if provider is not available
|
||||
debugPrint('[$level] $message');
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user