mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-10-09 19:55:05 +00:00
chore: Initial commit
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPovRHpejR4zRxuhjY4Qs3
This commit is contained in:
1999
lib/l10n/app_de.arb
Normal file
1999
lib/l10n/app_de.arb
Normal file
File diff suppressed because it is too large
Load Diff
2592
lib/l10n/app_el.arb
Normal file
2592
lib/l10n/app_el.arb
Normal file
File diff suppressed because it is too large
Load Diff
3447
lib/l10n/app_en.arb
Normal file
3447
lib/l10n/app_en.arb
Normal file
File diff suppressed because it is too large
Load Diff
1999
lib/l10n/app_es.arb
Normal file
1999
lib/l10n/app_es.arb
Normal file
File diff suppressed because it is too large
Load Diff
2002
lib/l10n/app_fr.arb
Normal file
2002
lib/l10n/app_fr.arb
Normal file
File diff suppressed because it is too large
Load Diff
1032
lib/l10n/app_hr.arb
Normal file
1032
lib/l10n/app_hr.arb
Normal file
File diff suppressed because it is too large
Load Diff
2002
lib/l10n/app_it.arb
Normal file
2002
lib/l10n/app_it.arb
Normal file
File diff suppressed because it is too large
Load Diff
6380
lib/l10n/app_localizations.dart
Normal file
6380
lib/l10n/app_localizations.dart
Normal file
File diff suppressed because it is too large
Load Diff
3460
lib/l10n/app_localizations_de.dart
Normal file
3460
lib/l10n/app_localizations_de.dart
Normal file
File diff suppressed because it is too large
Load Diff
3460
lib/l10n/app_localizations_el.dart
Normal file
3460
lib/l10n/app_localizations_el.dart
Normal file
File diff suppressed because it is too large
Load Diff
3420
lib/l10n/app_localizations_en.dart
Normal file
3420
lib/l10n/app_localizations_en.dart
Normal file
File diff suppressed because it is too large
Load Diff
3461
lib/l10n/app_localizations_es.dart
Normal file
3461
lib/l10n/app_localizations_es.dart
Normal file
File diff suppressed because it is too large
Load Diff
3470
lib/l10n/app_localizations_fr.dart
Normal file
3470
lib/l10n/app_localizations_fr.dart
Normal file
File diff suppressed because it is too large
Load Diff
3436
lib/l10n/app_localizations_hr.dart
Normal file
3436
lib/l10n/app_localizations_hr.dart
Normal file
File diff suppressed because it is too large
Load Diff
3454
lib/l10n/app_localizations_it.dart
Normal file
3454
lib/l10n/app_localizations_it.dart
Normal file
File diff suppressed because it is too large
Load Diff
3450
lib/l10n/app_localizations_pl.dart
Normal file
3450
lib/l10n/app_localizations_pl.dart
Normal file
File diff suppressed because it is too large
Load Diff
3459
lib/l10n/app_localizations_pt.dart
Normal file
3459
lib/l10n/app_localizations_pt.dart
Normal file
File diff suppressed because it is too large
Load Diff
3447
lib/l10n/app_localizations_ru.dart
Normal file
3447
lib/l10n/app_localizations_ru.dart
Normal file
File diff suppressed because it is too large
Load Diff
3437
lib/l10n/app_localizations_sl.dart
Normal file
3437
lib/l10n/app_localizations_sl.dart
Normal file
File diff suppressed because it is too large
Load Diff
3424
lib/l10n/app_localizations_tr.dart
Normal file
3424
lib/l10n/app_localizations_tr.dart
Normal file
File diff suppressed because it is too large
Load Diff
3456
lib/l10n/app_localizations_uk.dart
Normal file
3456
lib/l10n/app_localizations_uk.dart
Normal file
File diff suppressed because it is too large
Load Diff
3303
lib/l10n/app_localizations_zh.dart
Normal file
3303
lib/l10n/app_localizations_zh.dart
Normal file
File diff suppressed because it is too large
Load Diff
2617
lib/l10n/app_pl.arb
Normal file
2617
lib/l10n/app_pl.arb
Normal file
File diff suppressed because it is too large
Load Diff
2617
lib/l10n/app_pt.arb
Normal file
2617
lib/l10n/app_pt.arb
Normal file
File diff suppressed because it is too large
Load Diff
1032
lib/l10n/app_ru.arb
Normal file
1032
lib/l10n/app_ru.arb
Normal file
File diff suppressed because it is too large
Load Diff
1032
lib/l10n/app_sl.arb
Normal file
1032
lib/l10n/app_sl.arb
Normal file
File diff suppressed because it is too large
Load Diff
2617
lib/l10n/app_tr.arb
Normal file
2617
lib/l10n/app_tr.arb
Normal file
File diff suppressed because it is too large
Load Diff
2617
lib/l10n/app_uk.arb
Normal file
2617
lib/l10n/app_uk.arb
Normal file
File diff suppressed because it is too large
Load Diff
2617
lib/l10n/app_zh.arb
Normal file
2617
lib/l10n/app_zh.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 @@
|
||||
{}
|
||||
440
lib/main.dart
Normal file
440
lib/main.dart
Normal file
@@ -0,0 +1,440 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart'
|
||||
show TargetPlatform, defaultTargetPlatform, kIsWeb;
|
||||
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 'package:wakelock_plus/wakelock_plus.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/voice_provider.dart';
|
||||
import 'providers/image_provider.dart' as ip;
|
||||
import 'providers/app_provider.dart';
|
||||
import 'providers/offline_tiles_provider.dart';
|
||||
import 'providers/sensors_provider.dart';
|
||||
import 'services/voice_codec_service.dart';
|
||||
import 'services/voice_player_service.dart';
|
||||
import 'services/notification_service.dart';
|
||||
import 'services/locale_preferences.dart';
|
||||
import 'services/mesh_map_nodes_service.dart';
|
||||
import 'services/profile_manager.dart';
|
||||
import 'services/profile_workspace_coordinator.dart';
|
||||
import 'services/profiles_feature_service.dart';
|
||||
import 'services/update_checker_service.dart';
|
||||
import 'services/wizard_preferences.dart';
|
||||
import 'screens/discovery_screen.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/welcome_wizard_screen.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'utils/toast_logger.dart';
|
||||
import 'l10n/app_localizations.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await WakelockPlus.enable();
|
||||
runApp(const MeshCoreSarApp());
|
||||
}
|
||||
|
||||
class MeshCoreSarApp extends StatefulWidget {
|
||||
const MeshCoreSarApp({super.key});
|
||||
|
||||
@override
|
||||
State<MeshCoreSarApp> createState() => _MeshCoreSarAppState();
|
||||
}
|
||||
|
||||
class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
static const bool _isScreenshotRun = bool.fromEnvironment(
|
||||
'MESHCORE_SCREENSHOTS',
|
||||
);
|
||||
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
|
||||
AppThemeMode _themeMode = AppThemeMode.system;
|
||||
Locale? _locale;
|
||||
bool _isInitialized = false;
|
||||
bool _shouldShowPermissionDialog = false;
|
||||
bool _wizardCompleted = true; // Will be updated in _initializeApp()
|
||||
String? _pendingNotificationPayload;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeApp();
|
||||
}
|
||||
|
||||
Future<void> _initializeApp() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final profilesEnabled = await ProfilesFeatureService.isEnabled();
|
||||
final activeProfileId =
|
||||
prefs.getString(ProfileManager.activeProfileIdKey) ?? 'default';
|
||||
await ProfileStorageScope.bootstrap(
|
||||
profilesEnabled: profilesEnabled,
|
||||
activeProfileId: activeProfileId,
|
||||
);
|
||||
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;
|
||||
_pendingNotificationPayload = NotificationService().consumeLaunchPayload();
|
||||
|
||||
// Check if we need to request location permissions
|
||||
if (!_isScreenshotRun) {
|
||||
await _checkLocationPermissions();
|
||||
}
|
||||
|
||||
// Check for app updates (Android only) - runs in background
|
||||
// Shows notification if update is available
|
||||
_checkForUpdates();
|
||||
|
||||
// Refresh remote mesh node cache in background when the app starts.
|
||||
unawaited(MeshMapNodesService.syncInBackgroundIfStale());
|
||||
|
||||
setState(() {
|
||||
_wizardCompleted = wizardCompleted;
|
||||
_isInitialized = true;
|
||||
});
|
||||
|
||||
if (_pendingNotificationPayload != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final payload = _pendingNotificationPayload;
|
||||
_pendingNotificationPayload = null;
|
||||
_handleNotificationTap(payload);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle notification tap
|
||||
void _handleNotificationTap(String? payload) {
|
||||
if (payload == null) return;
|
||||
|
||||
debugPrint('[Main] Notification tapped: $payload');
|
||||
|
||||
if (!_wizardCompleted) {
|
||||
_pendingNotificationPayload = payload;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle update notification tap
|
||||
if (payload.startsWith('update:')) {
|
||||
final downloadUrl = payload.substring(7); // Remove 'update:' prefix
|
||||
_launchUpdateDownload(downloadUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.startsWith('discovery:')) {
|
||||
final navigator = _navigatorKey.currentState;
|
||||
if (navigator == null) {
|
||||
_pendingNotificationPayload = payload;
|
||||
return;
|
||||
}
|
||||
navigator.push(
|
||||
MaterialPageRoute(builder: (context) => const DiscoveryScreen()),
|
||||
);
|
||||
}
|
||||
// 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 (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||
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()),
|
||||
ChangeNotifierProvider(create: (_) => SensorsProvider()),
|
||||
ChangeNotifierProvider(create: (_) => OfflineTilesProvider()),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) {
|
||||
final manager = ProfileManager();
|
||||
manager.initialize();
|
||||
return manager;
|
||||
},
|
||||
),
|
||||
|
||||
// Voice provider (packet reassembly + playback)
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => VoiceProvider(
|
||||
codec: VoiceCodecService(),
|
||||
player: VoicePlayerService(),
|
||||
),
|
||||
),
|
||||
|
||||
// Image provider (fragment reassembly + outgoing session cache)
|
||||
ChangeNotifierProvider(create: (_) => ip.ImageProvider()),
|
||||
|
||||
// App provider that coordinates everything
|
||||
// VoiceProvider is read via context.read inside create since it's already registered above
|
||||
ChangeNotifierProxyProvider5<
|
||||
ConnectionProvider,
|
||||
ContactsProvider,
|
||||
MessagesProvider,
|
||||
DrawingProvider,
|
||||
ChannelsProvider,
|
||||
AppProvider
|
||||
>(
|
||||
create: (context) => AppProvider(
|
||||
connectionProvider: context.read<ConnectionProvider>(),
|
||||
contactsProvider: context.read<ContactsProvider>(),
|
||||
messagesProvider: context.read<MessagesProvider>(),
|
||||
drawingProvider: context.read<DrawingProvider>(),
|
||||
channelsProvider: context.read<ChannelsProvider>(),
|
||||
voiceProvider: context.read<VoiceProvider>(),
|
||||
imageProvider: context.read<ip.ImageProvider>(),
|
||||
),
|
||||
update:
|
||||
(
|
||||
context,
|
||||
conn,
|
||||
contacts,
|
||||
messages,
|
||||
drawings,
|
||||
channels,
|
||||
previous,
|
||||
) =>
|
||||
previous ??
|
||||
AppProvider(
|
||||
connectionProvider: conn,
|
||||
contactsProvider: contacts,
|
||||
messagesProvider: messages,
|
||||
drawingProvider: drawings,
|
||||
channelsProvider: channels,
|
||||
voiceProvider: context.read<VoiceProvider>(),
|
||||
imageProvider: context.read<ip.ImageProvider>(),
|
||||
),
|
||||
),
|
||||
ProxyProvider6<
|
||||
ProfileManager,
|
||||
AppProvider,
|
||||
ConnectionProvider,
|
||||
ContactsProvider,
|
||||
MessagesProvider,
|
||||
MapProvider,
|
||||
ProfileWorkspaceCoordinator
|
||||
>(
|
||||
update:
|
||||
(
|
||||
context,
|
||||
profileManager,
|
||||
appProvider,
|
||||
connectionProvider,
|
||||
contactsProvider,
|
||||
messagesProvider,
|
||||
mapProvider,
|
||||
previous,
|
||||
) => ProfileWorkspaceCoordinator(
|
||||
profileManager: profileManager,
|
||||
connectionProvider: connectionProvider,
|
||||
contactsProvider: contactsProvider,
|
||||
messagesProvider: messagesProvider,
|
||||
sensorsProvider: context.read<SensorsProvider>(),
|
||||
mapProvider: mapProvider,
|
||||
drawingProvider: context.read<DrawingProvider>(),
|
||||
channelsProvider: context.read<ChannelsProvider>(),
|
||||
voiceProvider: context.read<VoiceProvider>(),
|
||||
imageProvider: context.read<ip.ImageProvider>(),
|
||||
appProvider: appProvider,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: _buildMaterialApp(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMaterialApp() {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
final systemBrightness = MediaQuery.platformBrightnessOf(context);
|
||||
final materialApp = MaterialApp(
|
||||
navigatorKey: _navigatorKey,
|
||||
scaffoldMessengerKey: rootScaffoldMessengerKey,
|
||||
key: ValueKey<String?>(
|
||||
'${_locale?.languageCode ?? 'system'}_${_themeMode.name}',
|
||||
),
|
||||
onGenerateTitle: (context) => AppLocalizations.of(context)?.appTitle ?? '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 (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
return SafeArea(
|
||||
left: false,
|
||||
right: false,
|
||||
top: false, // prevents black status bar background
|
||||
child: materialApp,
|
||||
);
|
||||
}
|
||||
|
||||
return materialApp;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/models/advert_location.dart
Normal file
1
lib/models/advert_location.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'package:meshcore_client/meshcore_client.dart' show AdvertLocation;
|
||||
2
lib/models/ble_packet_log.dart
Normal file
2
lib/models/ble_packet_log.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
export 'package:meshcore_client/meshcore_client.dart'
|
||||
show BlePacketLog, PacketDirection, LogRxDataInfo;
|
||||
204
lib/models/channel.dart
Normal file
204
lib/models/channel.dart
Normal file
@@ -0,0 +1,204 @@
|
||||
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));
|
||||
}
|
||||
|
||||
static bool isHashChannelName(String channelName) {
|
||||
return channelName.trim().startsWith('#');
|
||||
}
|
||||
|
||||
static String pskBase64ForHashChannelName(String channelName) {
|
||||
final normalized = channelName.trim();
|
||||
if (!isHashChannelName(normalized)) {
|
||||
throw ArgumentError('Only #channels can export derived psk_base64');
|
||||
}
|
||||
return base64.encode(_generateHashChannelSecret(normalized));
|
||||
}
|
||||
|
||||
/// 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('#');
|
||||
|
||||
/// Base64-encoded PSK for sharing with firmware CLI and related tooling.
|
||||
String get pskBase64 => base64.encode(secret);
|
||||
|
||||
/// MeshCore group packets carry the first byte of SHA256(channel secret).
|
||||
int get hashByte {
|
||||
final digest = sha256.convert(secret);
|
||||
return digest.bytes.first;
|
||||
}
|
||||
|
||||
String get hashHex =>
|
||||
hashByte.toRadixString(16).padLeft(2, '0').toUpperCase();
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
518
lib/models/config_profile.dart
Normal file
518
lib/models/config_profile.dart
Normal file
@@ -0,0 +1,518 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'channel.dart';
|
||||
|
||||
@immutable
|
||||
class DeviceConfigProfileSection {
|
||||
final int? frequencyKhz;
|
||||
final int? bandwidth;
|
||||
final int? spreadingFactor;
|
||||
final int? codingRate;
|
||||
final bool? repeatEnabled;
|
||||
final int? txPower;
|
||||
final int? telemetryModes;
|
||||
final int? advertLocationPolicy;
|
||||
final int? multiAcks;
|
||||
final bool? manualAddContacts;
|
||||
final bool? autoAddUsers;
|
||||
final bool? autoAddRepeaters;
|
||||
final bool? autoAddRoomServers;
|
||||
final bool? autoAddSensors;
|
||||
final bool? autoAddOverwriteOldest;
|
||||
final double? publicLatitude;
|
||||
final double? publicLongitude;
|
||||
|
||||
const DeviceConfigProfileSection({
|
||||
this.frequencyKhz,
|
||||
this.bandwidth,
|
||||
this.spreadingFactor,
|
||||
this.codingRate,
|
||||
this.repeatEnabled,
|
||||
this.txPower,
|
||||
this.telemetryModes,
|
||||
this.advertLocationPolicy,
|
||||
this.multiAcks,
|
||||
this.manualAddContacts,
|
||||
this.autoAddUsers,
|
||||
this.autoAddRepeaters,
|
||||
this.autoAddRoomServers,
|
||||
this.autoAddSensors,
|
||||
this.autoAddOverwriteOldest,
|
||||
this.publicLatitude,
|
||||
this.publicLongitude,
|
||||
});
|
||||
|
||||
bool get isEmpty =>
|
||||
frequencyKhz == null &&
|
||||
bandwidth == null &&
|
||||
spreadingFactor == null &&
|
||||
codingRate == null &&
|
||||
repeatEnabled == null &&
|
||||
txPower == null &&
|
||||
telemetryModes == null &&
|
||||
advertLocationPolicy == null &&
|
||||
multiAcks == null &&
|
||||
manualAddContacts == null &&
|
||||
autoAddUsers == null &&
|
||||
autoAddRepeaters == null &&
|
||||
autoAddRoomServers == null &&
|
||||
autoAddSensors == null &&
|
||||
autoAddOverwriteOldest == null &&
|
||||
publicLatitude == null &&
|
||||
publicLongitude == null;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'frequencyKhz': frequencyKhz,
|
||||
'bandwidth': bandwidth,
|
||||
'spreadingFactor': spreadingFactor,
|
||||
'codingRate': codingRate,
|
||||
'repeatEnabled': repeatEnabled,
|
||||
'txPower': txPower,
|
||||
'telemetryModes': telemetryModes,
|
||||
'advertLocationPolicy': advertLocationPolicy,
|
||||
'multiAcks': multiAcks,
|
||||
'manualAddContacts': manualAddContacts,
|
||||
'autoAddUsers': autoAddUsers,
|
||||
'autoAddRepeaters': autoAddRepeaters,
|
||||
'autoAddRoomServers': autoAddRoomServers,
|
||||
'autoAddSensors': autoAddSensors,
|
||||
'autoAddOverwriteOldest': autoAddOverwriteOldest,
|
||||
'publicLatitude': publicLatitude,
|
||||
'publicLongitude': publicLongitude,
|
||||
};
|
||||
|
||||
factory DeviceConfigProfileSection.fromJson(Map<String, dynamic> json) {
|
||||
return DeviceConfigProfileSection(
|
||||
frequencyKhz: json['frequencyKhz'] as int?,
|
||||
bandwidth: json['bandwidth'] as int?,
|
||||
spreadingFactor: json['spreadingFactor'] as int?,
|
||||
codingRate: json['codingRate'] as int?,
|
||||
repeatEnabled: json['repeatEnabled'] as bool?,
|
||||
txPower: json['txPower'] as int?,
|
||||
telemetryModes: json['telemetryModes'] as int?,
|
||||
advertLocationPolicy: json['advertLocationPolicy'] as int?,
|
||||
multiAcks: json['multiAcks'] as int?,
|
||||
manualAddContacts: json['manualAddContacts'] as bool?,
|
||||
autoAddUsers: json['autoAddUsers'] as bool?,
|
||||
autoAddRepeaters: json['autoAddRepeaters'] as bool?,
|
||||
autoAddRoomServers: json['autoAddRoomServers'] as bool?,
|
||||
autoAddSensors: json['autoAddSensors'] as bool?,
|
||||
autoAddOverwriteOldest: json['autoAddOverwriteOldest'] as bool?,
|
||||
publicLatitude: (json['publicLatitude'] as num?)?.toDouble(),
|
||||
publicLongitude: (json['publicLongitude'] as num?)?.toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class AppSettingsProfileSection {
|
||||
final bool? mapEnabled;
|
||||
final bool? contactsEnabled;
|
||||
final bool? sensorsEnabled;
|
||||
final bool? voiceSilenceTrimmingEnabled;
|
||||
final bool? voiceBandPassFilterEnabled;
|
||||
final bool? voiceCompressorEnabled;
|
||||
final bool? voiceLimiterEnabled;
|
||||
final bool? voiceAutoGainEnabled;
|
||||
final bool? voiceEchoCancellationEnabled;
|
||||
final bool? voiceNoiseSuppressionEnabled;
|
||||
final double? messageFontScale;
|
||||
final bool? clearPathOnMaxRetry;
|
||||
final bool? nearestRelayFallbackEnabled;
|
||||
final int? voiceBitrate;
|
||||
final int? routeHashSize;
|
||||
final int? imageMaxSize;
|
||||
final int? imageCompression;
|
||||
final bool? imageGrayscale;
|
||||
final bool? imageUltraMode;
|
||||
final bool? showRxTxIndicators;
|
||||
final bool? fastLocationUpdatesEnabled;
|
||||
final double? fastLocationMovementThresholdMeters;
|
||||
final int? fastLocationActiveCadenceSeconds;
|
||||
|
||||
const AppSettingsProfileSection({
|
||||
this.mapEnabled,
|
||||
this.contactsEnabled,
|
||||
this.sensorsEnabled,
|
||||
this.voiceSilenceTrimmingEnabled,
|
||||
this.voiceBandPassFilterEnabled,
|
||||
this.voiceCompressorEnabled,
|
||||
this.voiceLimiterEnabled,
|
||||
this.voiceAutoGainEnabled,
|
||||
this.voiceEchoCancellationEnabled,
|
||||
this.voiceNoiseSuppressionEnabled,
|
||||
this.messageFontScale,
|
||||
this.clearPathOnMaxRetry,
|
||||
this.nearestRelayFallbackEnabled,
|
||||
this.voiceBitrate,
|
||||
this.routeHashSize,
|
||||
this.imageMaxSize,
|
||||
this.imageCompression,
|
||||
this.imageGrayscale,
|
||||
this.imageUltraMode,
|
||||
this.showRxTxIndicators,
|
||||
this.fastLocationUpdatesEnabled,
|
||||
this.fastLocationMovementThresholdMeters,
|
||||
this.fastLocationActiveCadenceSeconds,
|
||||
});
|
||||
|
||||
bool get isEmpty =>
|
||||
mapEnabled == null &&
|
||||
contactsEnabled == null &&
|
||||
sensorsEnabled == null &&
|
||||
voiceSilenceTrimmingEnabled == null &&
|
||||
voiceBandPassFilterEnabled == null &&
|
||||
voiceCompressorEnabled == null &&
|
||||
voiceLimiterEnabled == null &&
|
||||
voiceAutoGainEnabled == null &&
|
||||
voiceEchoCancellationEnabled == null &&
|
||||
voiceNoiseSuppressionEnabled == null &&
|
||||
messageFontScale == null &&
|
||||
clearPathOnMaxRetry == null &&
|
||||
nearestRelayFallbackEnabled == null &&
|
||||
voiceBitrate == null &&
|
||||
routeHashSize == null &&
|
||||
imageMaxSize == null &&
|
||||
imageCompression == null &&
|
||||
imageGrayscale == null &&
|
||||
imageUltraMode == null &&
|
||||
showRxTxIndicators == null &&
|
||||
fastLocationUpdatesEnabled == null &&
|
||||
fastLocationMovementThresholdMeters == null &&
|
||||
fastLocationActiveCadenceSeconds == null;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'mapEnabled': mapEnabled,
|
||||
'contactsEnabled': contactsEnabled,
|
||||
'sensorsEnabled': sensorsEnabled,
|
||||
'voiceSilenceTrimmingEnabled': voiceSilenceTrimmingEnabled,
|
||||
'voiceBandPassFilterEnabled': voiceBandPassFilterEnabled,
|
||||
'voiceCompressorEnabled': voiceCompressorEnabled,
|
||||
'voiceLimiterEnabled': voiceLimiterEnabled,
|
||||
'voiceAutoGainEnabled': voiceAutoGainEnabled,
|
||||
'voiceEchoCancellationEnabled': voiceEchoCancellationEnabled,
|
||||
'voiceNoiseSuppressionEnabled': voiceNoiseSuppressionEnabled,
|
||||
'messageFontScale': messageFontScale,
|
||||
'clearPathOnMaxRetry': clearPathOnMaxRetry,
|
||||
'nearestRelayFallbackEnabled': nearestRelayFallbackEnabled,
|
||||
'voiceBitrate': voiceBitrate,
|
||||
'routeHashSize': routeHashSize,
|
||||
'imageMaxSize': imageMaxSize,
|
||||
'imageCompression': imageCompression,
|
||||
'imageGrayscale': imageGrayscale,
|
||||
'imageUltraMode': imageUltraMode,
|
||||
'showRxTxIndicators': showRxTxIndicators,
|
||||
'fastLocationUpdatesEnabled': fastLocationUpdatesEnabled,
|
||||
'fastLocationMovementThresholdMeters': fastLocationMovementThresholdMeters,
|
||||
'fastLocationActiveCadenceSeconds': fastLocationActiveCadenceSeconds,
|
||||
};
|
||||
|
||||
factory AppSettingsProfileSection.fromJson(Map<String, dynamic> json) {
|
||||
return AppSettingsProfileSection(
|
||||
mapEnabled: json['mapEnabled'] as bool?,
|
||||
contactsEnabled: json['contactsEnabled'] as bool?,
|
||||
sensorsEnabled: json['sensorsEnabled'] as bool?,
|
||||
voiceSilenceTrimmingEnabled: json['voiceSilenceTrimmingEnabled'] as bool?,
|
||||
voiceBandPassFilterEnabled: json['voiceBandPassFilterEnabled'] as bool?,
|
||||
voiceCompressorEnabled: json['voiceCompressorEnabled'] as bool?,
|
||||
voiceLimiterEnabled: json['voiceLimiterEnabled'] as bool?,
|
||||
voiceAutoGainEnabled: json['voiceAutoGainEnabled'] as bool?,
|
||||
voiceEchoCancellationEnabled:
|
||||
json['voiceEchoCancellationEnabled'] as bool?,
|
||||
voiceNoiseSuppressionEnabled:
|
||||
json['voiceNoiseSuppressionEnabled'] as bool?,
|
||||
messageFontScale: (json['messageFontScale'] as num?)?.toDouble(),
|
||||
clearPathOnMaxRetry: json['clearPathOnMaxRetry'] as bool?,
|
||||
nearestRelayFallbackEnabled: json['nearestRelayFallbackEnabled'] as bool?,
|
||||
voiceBitrate: json['voiceBitrate'] as int?,
|
||||
routeHashSize: json['routeHashSize'] as int?,
|
||||
imageMaxSize: json['imageMaxSize'] as int?,
|
||||
imageCompression: json['imageCompression'] as int?,
|
||||
imageGrayscale: json['imageGrayscale'] as bool?,
|
||||
imageUltraMode: json['imageUltraMode'] as bool?,
|
||||
showRxTxIndicators: json['showRxTxIndicators'] as bool?,
|
||||
fastLocationUpdatesEnabled: json['fastLocationUpdatesEnabled'] as bool?,
|
||||
fastLocationMovementThresholdMeters:
|
||||
(json['fastLocationMovementThresholdMeters'] as num?)?.toDouble(),
|
||||
fastLocationActiveCadenceSeconds:
|
||||
json['fastLocationActiveCadenceSeconds'] as int?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class MapWorkspaceProfileSection {
|
||||
final Map<String, dynamic>? mapPrefs;
|
||||
final List<Map<String, dynamic>> drawings;
|
||||
final Map<String, dynamic>? currentTrail;
|
||||
final List<Map<String, dynamic>> trailHistory;
|
||||
final Map<String, dynamic>? importedTrail;
|
||||
final bool? isTrailVisible;
|
||||
final bool? showCadastralOverlay;
|
||||
final bool? showForestRoadsOverlay;
|
||||
final bool? showHikingTrailsOverlay;
|
||||
final bool? showMainRoadsOverlay;
|
||||
final bool? showHouseNumbersOverlay;
|
||||
final bool? showFireHazardZonesOverlay;
|
||||
final bool? showHistoricalFiresOverlay;
|
||||
final bool? showFirebreaksOverlay;
|
||||
final bool? showKrasFireZonesOverlay;
|
||||
final bool? showPlaceNamesOverlay;
|
||||
final bool? showMunicipalityBordersOverlay;
|
||||
final bool? hideRepeatersOnMap;
|
||||
|
||||
const MapWorkspaceProfileSection({
|
||||
this.mapPrefs,
|
||||
this.drawings = const [],
|
||||
this.currentTrail,
|
||||
this.trailHistory = const [],
|
||||
this.importedTrail,
|
||||
this.isTrailVisible,
|
||||
this.showCadastralOverlay,
|
||||
this.showForestRoadsOverlay,
|
||||
this.showHikingTrailsOverlay,
|
||||
this.showMainRoadsOverlay,
|
||||
this.showHouseNumbersOverlay,
|
||||
this.showFireHazardZonesOverlay,
|
||||
this.showHistoricalFiresOverlay,
|
||||
this.showFirebreaksOverlay,
|
||||
this.showKrasFireZonesOverlay,
|
||||
this.showPlaceNamesOverlay,
|
||||
this.showMunicipalityBordersOverlay,
|
||||
this.hideRepeatersOnMap,
|
||||
});
|
||||
|
||||
bool get isEmpty =>
|
||||
mapPrefs == null &&
|
||||
drawings.isEmpty &&
|
||||
currentTrail == null &&
|
||||
trailHistory.isEmpty &&
|
||||
importedTrail == null &&
|
||||
isTrailVisible == null &&
|
||||
showCadastralOverlay == null &&
|
||||
showForestRoadsOverlay == null &&
|
||||
showHikingTrailsOverlay == null &&
|
||||
showMainRoadsOverlay == null &&
|
||||
showHouseNumbersOverlay == null &&
|
||||
showFireHazardZonesOverlay == null &&
|
||||
showHistoricalFiresOverlay == null &&
|
||||
showFirebreaksOverlay == null &&
|
||||
showKrasFireZonesOverlay == null &&
|
||||
showPlaceNamesOverlay == null &&
|
||||
showMunicipalityBordersOverlay == null &&
|
||||
hideRepeatersOnMap == null;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'mapPrefs': mapPrefs,
|
||||
'drawings': drawings,
|
||||
'currentTrail': currentTrail,
|
||||
'trailHistory': trailHistory,
|
||||
'importedTrail': importedTrail,
|
||||
'isTrailVisible': isTrailVisible,
|
||||
'showCadastralOverlay': showCadastralOverlay,
|
||||
'showForestRoadsOverlay': showForestRoadsOverlay,
|
||||
'showHikingTrailsOverlay': showHikingTrailsOverlay,
|
||||
'showMainRoadsOverlay': showMainRoadsOverlay,
|
||||
'showHouseNumbersOverlay': showHouseNumbersOverlay,
|
||||
'showFireHazardZonesOverlay': showFireHazardZonesOverlay,
|
||||
'showHistoricalFiresOverlay': showHistoricalFiresOverlay,
|
||||
'showFirebreaksOverlay': showFirebreaksOverlay,
|
||||
'showKrasFireZonesOverlay': showKrasFireZonesOverlay,
|
||||
'showPlaceNamesOverlay': showPlaceNamesOverlay,
|
||||
'showMunicipalityBordersOverlay': showMunicipalityBordersOverlay,
|
||||
'hideRepeatersOnMap': hideRepeatersOnMap,
|
||||
};
|
||||
|
||||
factory MapWorkspaceProfileSection.fromJson(Map<String, dynamic> json) {
|
||||
List<Map<String, dynamic>> decodeList(String key) {
|
||||
final raw = json[key] as List<dynamic>? ?? const [];
|
||||
return raw.whereType<Map<String, dynamic>>().toList();
|
||||
}
|
||||
|
||||
return MapWorkspaceProfileSection(
|
||||
mapPrefs: json['mapPrefs'] as Map<String, dynamic>?,
|
||||
drawings: decodeList('drawings'),
|
||||
currentTrail: json['currentTrail'] as Map<String, dynamic>?,
|
||||
trailHistory: decodeList('trailHistory'),
|
||||
importedTrail: json['importedTrail'] as Map<String, dynamic>?,
|
||||
isTrailVisible: json['isTrailVisible'] as bool?,
|
||||
showCadastralOverlay: json['showCadastralOverlay'] as bool?,
|
||||
showForestRoadsOverlay: json['showForestRoadsOverlay'] as bool?,
|
||||
showHikingTrailsOverlay: json['showHikingTrailsOverlay'] as bool?,
|
||||
showMainRoadsOverlay: json['showMainRoadsOverlay'] as bool?,
|
||||
showHouseNumbersOverlay: json['showHouseNumbersOverlay'] as bool?,
|
||||
showFireHazardZonesOverlay: json['showFireHazardZonesOverlay'] as bool?,
|
||||
showHistoricalFiresOverlay: json['showHistoricalFiresOverlay'] as bool?,
|
||||
showFirebreaksOverlay: json['showFirebreaksOverlay'] as bool?,
|
||||
showKrasFireZonesOverlay: json['showKrasFireZonesOverlay'] as bool?,
|
||||
showPlaceNamesOverlay: json['showPlaceNamesOverlay'] as bool?,
|
||||
showMunicipalityBordersOverlay:
|
||||
json['showMunicipalityBordersOverlay'] as bool?,
|
||||
hideRepeatersOnMap: json['hideRepeatersOnMap'] as bool?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ConfigProfileSections {
|
||||
final DeviceConfigProfileSection? deviceConfig;
|
||||
final AppSettingsProfileSection? appSettings;
|
||||
final MapWorkspaceProfileSection? mapWorkspace;
|
||||
final List<Channel> channels;
|
||||
|
||||
const ConfigProfileSections({
|
||||
this.deviceConfig,
|
||||
this.appSettings,
|
||||
this.mapWorkspace,
|
||||
this.channels = const [],
|
||||
});
|
||||
|
||||
bool get isEmpty =>
|
||||
(deviceConfig == null || deviceConfig!.isEmpty) &&
|
||||
(appSettings == null || appSettings!.isEmpty) &&
|
||||
(mapWorkspace == null || mapWorkspace!.isEmpty) &&
|
||||
channels.isEmpty;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'deviceConfig': deviceConfig?.toJson(),
|
||||
'appSettings': appSettings?.toJson(),
|
||||
'mapWorkspace': mapWorkspace?.toJson(),
|
||||
'channels': channels.map((channel) => channel.toJson()).toList(),
|
||||
};
|
||||
|
||||
factory ConfigProfileSections.fromJson(Map<String, dynamic> json) {
|
||||
final rawChannels = json['channels'] as List<dynamic>? ?? const [];
|
||||
return ConfigProfileSections(
|
||||
deviceConfig: json['deviceConfig'] is Map<String, dynamic>
|
||||
? DeviceConfigProfileSection.fromJson(
|
||||
json['deviceConfig'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
appSettings: json['appSettings'] is Map<String, dynamic>
|
||||
? AppSettingsProfileSection.fromJson(
|
||||
json['appSettings'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
mapWorkspace: json['mapWorkspace'] is Map<String, dynamic>
|
||||
? MapWorkspaceProfileSection.fromJson(
|
||||
json['mapWorkspace'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
channels: rawChannels
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Channel.fromJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ConfigProfile {
|
||||
static const String defaultProfileId = 'default';
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final String? notes;
|
||||
final ConfigProfileSections sections;
|
||||
|
||||
const ConfigProfile({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.sections,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
bool get isDefault => id == defaultProfileId;
|
||||
|
||||
factory ConfigProfile.defaultProfile() {
|
||||
final now = DateTime.now();
|
||||
return ConfigProfile(
|
||||
id: defaultProfileId,
|
||||
name: 'Default',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
sections: const ConfigProfileSections(),
|
||||
);
|
||||
}
|
||||
|
||||
ConfigProfile copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
String? notes,
|
||||
ConfigProfileSections? sections,
|
||||
}) {
|
||||
return ConfigProfile(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
notes: notes ?? this.notes,
|
||||
sections: sections ?? this.sections,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
'notes': notes,
|
||||
'sections': sections.toJson(),
|
||||
};
|
||||
|
||||
factory ConfigProfile.fromJson(Map<String, dynamic> json) {
|
||||
return ConfigProfile(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
notes: json['notes'] as String?,
|
||||
sections: ConfigProfileSections.fromJson(
|
||||
json['sections'] as Map<String, dynamic>? ?? const {},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ProfileDiff {
|
||||
final List<String> changedSections;
|
||||
|
||||
const ProfileDiff({required this.changedSections});
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ProfileTransferRecord {
|
||||
final String profileId;
|
||||
final String direction;
|
||||
final DateTime timestamp;
|
||||
final String detail;
|
||||
|
||||
const ProfileTransferRecord({
|
||||
required this.profileId,
|
||||
required this.direction,
|
||||
required this.timestamp,
|
||||
required this.detail,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'profileId': profileId,
|
||||
'direction': direction,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'detail': detail,
|
||||
};
|
||||
|
||||
factory ProfileTransferRecord.fromJson(Map<String, dynamic> json) {
|
||||
return ProfileTransferRecord(
|
||||
profileId: json['profileId'] as String,
|
||||
direction: json['direction'] as String,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
detail: json['detail'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
309
lib/models/contact.dart
Normal file
309
lib/models/contact.dart
Normal file
@@ -0,0 +1,309 @@
|
||||
export 'package:meshcore_client/meshcore_client.dart'
|
||||
show Contact, ContactType, ContactTelemetry, AdvertLocation;
|
||||
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
|
||||
class ParsedContactRoute {
|
||||
final String canonicalText;
|
||||
final int hashSize;
|
||||
final int hopCount;
|
||||
final int encodedPathLen;
|
||||
final int signedEncodedPathLen;
|
||||
final Uint8List pathBytes;
|
||||
final Uint8List paddedPathBytes;
|
||||
|
||||
const ParsedContactRoute({
|
||||
required this.canonicalText,
|
||||
required this.hashSize,
|
||||
required this.hopCount,
|
||||
required this.encodedPathLen,
|
||||
required this.signedEncodedPathLen,
|
||||
required this.pathBytes,
|
||||
required this.paddedPathBytes,
|
||||
});
|
||||
|
||||
int get byteLength => pathBytes.length;
|
||||
String get summary => hopCount == 0
|
||||
? 'Direct'
|
||||
: '$hopCount hop${hopCount == 1 ? '' : 's'} via $hashSize-byte hashes';
|
||||
}
|
||||
|
||||
class ContactRouteFormatException implements Exception {
|
||||
final String message;
|
||||
|
||||
const ContactRouteFormatException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class ContactRouteCodec {
|
||||
static const int maxHashSize = 3;
|
||||
static const int maxPathBytes = 64;
|
||||
static const int _unknownDescriptor = 0xFF;
|
||||
|
||||
static ParsedContactRoute parse(String input, {int? expectedHashSize}) {
|
||||
final normalized = input.trim().toUpperCase();
|
||||
if (normalized.isEmpty) {
|
||||
throw const ContactRouteFormatException('Route cannot be empty.');
|
||||
}
|
||||
|
||||
if (expectedHashSize != null &&
|
||||
(expectedHashSize < 1 || expectedHashSize > maxHashSize)) {
|
||||
throw const ContactRouteFormatException(
|
||||
'Hash size must be 1, 2, or 3 bytes.',
|
||||
);
|
||||
}
|
||||
|
||||
final hopTokens = normalized
|
||||
.split(',')
|
||||
.map((token) => token.trim())
|
||||
.toList();
|
||||
if (hopTokens.any((token) => token.isEmpty)) {
|
||||
throw const ContactRouteFormatException('Route contains an empty hop.');
|
||||
}
|
||||
|
||||
final hopBytes = <List<int>>[];
|
||||
int? hashSize;
|
||||
for (final token in hopTokens) {
|
||||
final compact = token.replaceAll(':', '');
|
||||
if (compact.isEmpty || !RegExp(r'^[0-9A-F]+$').hasMatch(compact)) {
|
||||
throw ContactRouteFormatException('Invalid hop "$token".');
|
||||
}
|
||||
if (compact.length.isOdd) {
|
||||
throw ContactRouteFormatException(
|
||||
'Hop "$token" must contain full bytes.',
|
||||
);
|
||||
}
|
||||
|
||||
final currentHashSize = compact.length ~/ 2;
|
||||
if (currentHashSize < 1 || currentHashSize > maxHashSize) {
|
||||
throw ContactRouteFormatException(
|
||||
'Hop "$token" must be 1, 2, or 3 bytes.',
|
||||
);
|
||||
}
|
||||
|
||||
if (expectedHashSize != null && currentHashSize != expectedHashSize) {
|
||||
throw ContactRouteFormatException(
|
||||
'Hop "$token" must be $expectedHashSize '
|
||||
'byte${expectedHashSize == 1 ? '' : 's'}.',
|
||||
);
|
||||
}
|
||||
|
||||
hashSize ??= currentHashSize;
|
||||
if (hashSize != currentHashSize) {
|
||||
throw const ContactRouteFormatException(
|
||||
'All hops in a route must use the same hash size.',
|
||||
);
|
||||
}
|
||||
|
||||
final bytes = <int>[];
|
||||
for (var i = 0; i < compact.length; i += 2) {
|
||||
bytes.add(int.parse(compact.substring(i, i + 2), radix: 16));
|
||||
}
|
||||
hopBytes.add(bytes);
|
||||
}
|
||||
|
||||
final resolvedHashSize = hashSize ?? 1;
|
||||
final flatBytes = Uint8List.fromList(
|
||||
hopBytes.expand((hop) => hop).toList(),
|
||||
);
|
||||
if (flatBytes.length > maxPathBytes) {
|
||||
throw const ContactRouteFormatException(
|
||||
'Route exceeds the 64-byte firmware limit.',
|
||||
);
|
||||
}
|
||||
|
||||
final encodedPathLen =
|
||||
((resolvedHashSize - 1) << 6) | (hopBytes.length & 0x3F);
|
||||
final padded = Uint8List(maxPathBytes);
|
||||
padded.setRange(0, flatBytes.length, flatBytes);
|
||||
|
||||
return ParsedContactRoute(
|
||||
canonicalText: hopBytes
|
||||
.map(
|
||||
(hop) => hop
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toUpperCase(),
|
||||
)
|
||||
.join(','),
|
||||
hashSize: resolvedHashSize,
|
||||
hopCount: hopBytes.length,
|
||||
encodedPathLen: encodedPathLen,
|
||||
signedEncodedPathLen: toSignedDescriptor(encodedPathLen),
|
||||
pathBytes: flatBytes,
|
||||
paddedPathBytes: padded,
|
||||
);
|
||||
}
|
||||
|
||||
static ParsedContactRoute direct({int hashSize = 1}) {
|
||||
if (hashSize < 1 || hashSize > maxHashSize) {
|
||||
throw ArgumentError.value(hashSize, 'hashSize', 'Must be 1, 2, or 3.');
|
||||
}
|
||||
|
||||
final encodedPathLen = (hashSize - 1) << 6;
|
||||
return ParsedContactRoute(
|
||||
canonicalText: '',
|
||||
hashSize: hashSize,
|
||||
hopCount: 0,
|
||||
encodedPathLen: encodedPathLen,
|
||||
signedEncodedPathLen: toSignedDescriptor(encodedPathLen),
|
||||
pathBytes: Uint8List(0),
|
||||
paddedPathBytes: Uint8List(maxPathBytes),
|
||||
);
|
||||
}
|
||||
|
||||
static ParsedContactRoute? fromContact(Contact contact) {
|
||||
if (!contact.routeHasPath || contact.routeHopCount == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ParsedContactRoute(
|
||||
canonicalText: contact.routeCanonicalText,
|
||||
hashSize: contact.routeHashSize,
|
||||
hopCount: contact.routeHopCount,
|
||||
encodedPathLen: contact.routeEncodedPathLen,
|
||||
signedEncodedPathLen: contact.routeSignedPathLen,
|
||||
pathBytes: contact.routePathBytes,
|
||||
paddedPathBytes: _padPath(contact.routePathBytes),
|
||||
);
|
||||
}
|
||||
|
||||
static Uint8List _padPath(Uint8List bytes) {
|
||||
final padded = Uint8List(maxPathBytes);
|
||||
padded.setRange(0, math.min(bytes.length, maxPathBytes), bytes);
|
||||
return padded;
|
||||
}
|
||||
|
||||
static int toSignedDescriptor(int encodedPathLen) =>
|
||||
encodedPathLen > 127 ? encodedPathLen - 256 : encodedPathLen;
|
||||
|
||||
static int toUnsignedDescriptor(int signedPathLen) => signedPathLen & 0xFF;
|
||||
|
||||
static bool isUnknownDescriptor(int signedPathLen) =>
|
||||
toUnsignedDescriptor(signedPathLen) == _unknownDescriptor;
|
||||
|
||||
static bool isValidDescriptor(int signedPathLen) {
|
||||
final raw = toUnsignedDescriptor(signedPathLen);
|
||||
if (raw == _unknownDescriptor) return false;
|
||||
final hashSize = ((raw >> 6) + 1);
|
||||
if (hashSize > maxHashSize) return false;
|
||||
final hopCount = raw & 0x3F;
|
||||
return hopCount * hashSize <= maxPathBytes;
|
||||
}
|
||||
|
||||
/// Converts an encoded MeshCore route descriptor + path into the legacy raw
|
||||
/// path format expected by the `CMD_SEND_RAW_DATA` (0x19) handler in
|
||||
/// companion firmware v1.15.
|
||||
///
|
||||
/// The raw-data handler treats the path-length byte as a *literal* hop count
|
||||
/// (one byte per hop) rather than as an encoded descriptor, so a 1-hop route
|
||||
/// using 2-byte hashes (descriptor `0x41`) would otherwise be misread as
|
||||
/// "65 path bytes" and rejected with `ERROR: Unsupported command`. We
|
||||
/// down-sample every hop to its first hash byte and emit a plain hop count.
|
||||
///
|
||||
/// Routes that are already legacy (hash size 1, descriptor `< 0x40`) pass
|
||||
/// through unchanged. Unknown/flood descriptors collapse to a zero-hop
|
||||
/// direct path (raw transport does not support flood routing).
|
||||
static LegacyRawPath toLegacyRawPath(int descriptor, Uint8List path) {
|
||||
final raw = toUnsignedDescriptor(descriptor);
|
||||
if (raw == _unknownDescriptor) {
|
||||
return const LegacyRawPath(length: 0, path: <int>[]);
|
||||
}
|
||||
if (raw < 0x40) {
|
||||
// Hash size 1: the encoded path is already one byte per hop.
|
||||
final hopCount = raw;
|
||||
final available = math.min(hopCount, path.length);
|
||||
return LegacyRawPath(
|
||||
length: hopCount,
|
||||
path: Uint8List.fromList(path.sublist(0, available)),
|
||||
);
|
||||
}
|
||||
final hopCount = raw & 0x3F;
|
||||
final hashSize = ((raw >> 6) & 0x03) + 1;
|
||||
final legacy = Uint8List(hopCount);
|
||||
for (var hop = 0; hop < hopCount; hop++) {
|
||||
final srcIndex = hop * hashSize;
|
||||
if (srcIndex < path.length) {
|
||||
legacy[hop] = path[srcIndex];
|
||||
}
|
||||
}
|
||||
return LegacyRawPath(length: hopCount, path: legacy);
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy raw-transport path: a literal hop count plus one byte per hop, as
|
||||
/// expected by the firmware `CMD_SEND_RAW_DATA` handler.
|
||||
class LegacyRawPath {
|
||||
const LegacyRawPath({required this.length, required this.path});
|
||||
|
||||
final int length;
|
||||
final List<int> path;
|
||||
}
|
||||
|
||||
extension ContactLocalization on Contact {
|
||||
/// Returns the localized display name for special contacts (e.g. Public Channel).
|
||||
/// For all other contacts, returns [displayName].
|
||||
String getLocalizedDisplayName(BuildContext context) {
|
||||
if (isPublicChannel) {
|
||||
return AppLocalizations.of(context)!.publicChannel;
|
||||
}
|
||||
return displayName;
|
||||
}
|
||||
|
||||
int get routeEncodedPathLen =>
|
||||
ContactRouteCodec.toUnsignedDescriptor(outPathLen);
|
||||
|
||||
int get routeSignedPathLen =>
|
||||
ContactRouteCodec.toSignedDescriptor(routeEncodedPathLen);
|
||||
|
||||
bool get routeIsUnknown => ContactRouteCodec.isUnknownDescriptor(outPathLen);
|
||||
|
||||
bool get routeHasPath =>
|
||||
!routeIsUnknown && ContactRouteCodec.isValidDescriptor(outPathLen);
|
||||
|
||||
int get routeHashSize => routeHasPath ? ((routeEncodedPathLen >> 6) + 1) : 1;
|
||||
|
||||
int get routeHopCount => routeHasPath ? (routeEncodedPathLen & 0x3F) : -1;
|
||||
|
||||
int get routeByteLength => routeHasPath
|
||||
? math.min(routeHopCount * routeHashSize, outPath.length)
|
||||
: 0;
|
||||
|
||||
Uint8List get routePathBytes => routeByteLength <= 0
|
||||
? Uint8List(0)
|
||||
: Uint8List.fromList(outPath.sublist(0, routeByteLength));
|
||||
|
||||
String get routeCanonicalText {
|
||||
if (!routeHasPath || routeHopCount <= 0) return '';
|
||||
final bytes = routePathBytes;
|
||||
final hops = <String>[];
|
||||
for (var i = 0; i < bytes.length; i += routeHashSize) {
|
||||
hops.add(
|
||||
bytes
|
||||
.sublist(i, i + routeHashSize)
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toUpperCase(),
|
||||
);
|
||||
}
|
||||
return hops.join(',');
|
||||
}
|
||||
|
||||
String get routeSummary {
|
||||
if (routeIsUnknown) {
|
||||
return 'Flood/Unknown';
|
||||
}
|
||||
if (!routeHasPath || routeHopCount == 0) {
|
||||
return 'Direct';
|
||||
}
|
||||
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
|
||||
}
|
||||
|
||||
bool get routeSupportsLegacyRawTransport => routeHasPath;
|
||||
}
|
||||
39
lib/models/contact_group.dart
Normal file
39
lib/models/contact_group.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
class SavedContactGroup {
|
||||
final String id;
|
||||
final String sectionKey;
|
||||
final String label;
|
||||
final String query;
|
||||
final DateTime createdAt;
|
||||
final List<String>? matchPrefixes;
|
||||
final bool isAutoGroup;
|
||||
|
||||
const SavedContactGroup({
|
||||
required this.id,
|
||||
required this.sectionKey,
|
||||
required this.label,
|
||||
required this.query,
|
||||
required this.createdAt,
|
||||
this.matchPrefixes,
|
||||
this.isAutoGroup = false,
|
||||
});
|
||||
|
||||
SavedContactGroup copyWith({
|
||||
String? id,
|
||||
String? sectionKey,
|
||||
String? label,
|
||||
String? query,
|
||||
DateTime? createdAt,
|
||||
List<String>? matchPrefixes,
|
||||
bool? isAutoGroup,
|
||||
}) {
|
||||
return SavedContactGroup(
|
||||
id: id ?? this.id,
|
||||
sectionKey: sectionKey ?? this.sectionKey,
|
||||
label: label ?? this.label,
|
||||
query: query ?? this.query,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
matchPrefixes: matchPrefixes ?? this.matchPrefixes,
|
||||
isAutoGroup: isAutoGroup ?? this.isAutoGroup,
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/models/contact_telemetry.dart
Normal file
1
lib/models/contact_telemetry.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'package:meshcore_client/meshcore_client.dart' show ContactTelemetry;
|
||||
151
lib/models/custom_map_config.dart
Normal file
151
lib/models/custom_map_config.dart
Normal file
@@ -0,0 +1,151 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../utils/custom_map_id.dart';
|
||||
|
||||
class CustomMapConfig {
|
||||
final String filePath;
|
||||
final String displayName;
|
||||
final String mapId;
|
||||
final int imageWidth;
|
||||
final int imageHeight;
|
||||
final double? metersPerPixel;
|
||||
final LatLng? calibrationPointA;
|
||||
final LatLng? calibrationPointB;
|
||||
|
||||
const CustomMapConfig({
|
||||
required this.filePath,
|
||||
required this.displayName,
|
||||
required this.mapId,
|
||||
required this.imageWidth,
|
||||
required this.imageHeight,
|
||||
this.metersPerPixel,
|
||||
this.calibrationPointA,
|
||||
this.calibrationPointB,
|
||||
});
|
||||
|
||||
bool get isCalibrated => metersPerPixel != null && metersPerPixel! > 0;
|
||||
|
||||
double get _displayScale {
|
||||
final heightScale = imageHeight / 90.0;
|
||||
final widthScale = imageWidth / 180.0;
|
||||
return math.max(1.0, math.max(heightScale, widthScale));
|
||||
}
|
||||
|
||||
double get _displayHeight => imageHeight / _displayScale;
|
||||
|
||||
double get _displayWidth => imageWidth / _displayScale;
|
||||
|
||||
LatLngBounds get bounds =>
|
||||
LatLngBounds(const LatLng(0, 0), LatLng(_displayHeight, _displayWidth));
|
||||
|
||||
LatLngBounds get displayBounds =>
|
||||
LatLngBounds(LatLng(_displayHeight, 0), LatLng(0, _displayWidth));
|
||||
|
||||
LatLng toDisplayPoint(LatLng point) {
|
||||
return LatLng(
|
||||
_displayHeight - (point.latitude / _displayScale),
|
||||
point.longitude / _displayScale,
|
||||
);
|
||||
}
|
||||
|
||||
LatLng fromDisplayPoint(LatLng point) {
|
||||
return LatLng(
|
||||
(_displayHeight - point.latitude) * _displayScale,
|
||||
point.longitude * _displayScale,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'filePath': filePath,
|
||||
'displayName': displayName,
|
||||
'mapId': mapId,
|
||||
'imageWidth': imageWidth,
|
||||
'imageHeight': imageHeight,
|
||||
'metersPerPixel': metersPerPixel,
|
||||
'calibrationPointA': calibrationPointA != null
|
||||
? {
|
||||
'lat': calibrationPointA!.latitude,
|
||||
'lon': calibrationPointA!.longitude,
|
||||
}
|
||||
: null,
|
||||
'calibrationPointB': calibrationPointB != null
|
||||
? {
|
||||
'lat': calibrationPointB!.latitude,
|
||||
'lon': calibrationPointB!.longitude,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
CustomMapConfig copyWith({
|
||||
String? filePath,
|
||||
String? displayName,
|
||||
String? mapId,
|
||||
int? imageWidth,
|
||||
int? imageHeight,
|
||||
double? metersPerPixel,
|
||||
bool clearMetersPerPixel = false,
|
||||
LatLng? calibrationPointA,
|
||||
bool clearCalibrationPointA = false,
|
||||
LatLng? calibrationPointB,
|
||||
bool clearCalibrationPointB = false,
|
||||
}) {
|
||||
return CustomMapConfig(
|
||||
filePath: filePath ?? this.filePath,
|
||||
displayName: displayName ?? this.displayName,
|
||||
mapId: normalizeCustomMapId(mapId) ?? this.mapId,
|
||||
imageWidth: imageWidth ?? this.imageWidth,
|
||||
imageHeight: imageHeight ?? this.imageHeight,
|
||||
metersPerPixel: clearMetersPerPixel
|
||||
? null
|
||||
: (metersPerPixel ?? this.metersPerPixel),
|
||||
calibrationPointA: clearCalibrationPointA
|
||||
? null
|
||||
: (calibrationPointA ?? this.calibrationPointA),
|
||||
calibrationPointB: clearCalibrationPointB
|
||||
? null
|
||||
: (calibrationPointB ?? this.calibrationPointB),
|
||||
);
|
||||
}
|
||||
|
||||
static CustomMapConfig? fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) return null;
|
||||
|
||||
final filePath = json['filePath'];
|
||||
final displayName = json['displayName'];
|
||||
final mapId = json['mapId'];
|
||||
final imageWidth = json['imageWidth'];
|
||||
final imageHeight = json['imageHeight'];
|
||||
if (filePath is! String ||
|
||||
displayName is! String ||
|
||||
mapId is! String ||
|
||||
imageWidth is! int ||
|
||||
imageHeight is! int) {
|
||||
return null;
|
||||
}
|
||||
|
||||
LatLng? parsePoint(dynamic raw) {
|
||||
if (raw is! Map<String, dynamic>) return null;
|
||||
final lat = raw['lat'];
|
||||
final lon = raw['lon'];
|
||||
if (lat is! num || lon is! num) return null;
|
||||
return LatLng(lat.toDouble(), lon.toDouble());
|
||||
}
|
||||
|
||||
final metersPerPixel = json['metersPerPixel'];
|
||||
return CustomMapConfig(
|
||||
filePath: filePath,
|
||||
displayName: displayName,
|
||||
mapId: normalizeCustomMapId(mapId) ?? mapId,
|
||||
imageWidth: imageWidth,
|
||||
imageHeight: imageHeight,
|
||||
metersPerPixel: metersPerPixel is num ? metersPerPixel.toDouble() : null,
|
||||
calibrationPointA: parsePoint(json['calibrationPointA']),
|
||||
calibrationPointB: parsePoint(json['calibrationPointB']),
|
||||
);
|
||||
}
|
||||
}
|
||||
338
lib/models/device_info.dart
Normal file
338
lib/models/device_info.dart
Normal file
@@ -0,0 +1,338 @@
|
||||
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,
|
||||
|
||||
/// Direct TCP/WiFi connection to MeshCore device (port 5000)
|
||||
tcp,
|
||||
|
||||
/// USB serial connection (Android OTG or Web Serial API)
|
||||
usb,
|
||||
}
|
||||
|
||||
extension ConnectionModeExtension on ConnectionMode {
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case ConnectionMode.ble:
|
||||
return 'Direct (BLE)';
|
||||
case ConnectionMode.tcp:
|
||||
return 'Direct (WiFi)';
|
||||
case ConnectionMode.usb:
|
||||
return 'Direct (Serial)';
|
||||
}
|
||||
}
|
||||
|
||||
String get description {
|
||||
switch (this) {
|
||||
case ConnectionMode.ble:
|
||||
return 'Direct BLE connection to MeshCore device';
|
||||
case ConnectionMode.tcp:
|
||||
return 'Direct WiFi/TCP connection to MeshCore device';
|
||||
case ConnectionMode.usb:
|
||||
return 'Direct serial connection to MeshCore device';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 bool? autoAddUsers;
|
||||
final bool? autoAddRepeaters;
|
||||
final bool? autoAddRoomServers;
|
||||
final bool? autoAddSensors;
|
||||
final bool? autoAddOverwriteOldest;
|
||||
final int? autoAddMaxHops;
|
||||
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;
|
||||
|
||||
// Repeat mode (firmware v9+)
|
||||
final bool? clientRepeat;
|
||||
final int? pathHashMode;
|
||||
final bool? supportsSpectrumScan;
|
||||
final int? spectrumScanMinKhz;
|
||||
final int? spectrumScanMaxKhz;
|
||||
final List<({int lower, int upper})>? allowedRepeatFreqRanges;
|
||||
|
||||
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.autoAddUsers,
|
||||
this.autoAddRepeaters,
|
||||
this.autoAddRoomServers,
|
||||
this.autoAddSensors,
|
||||
this.autoAddOverwriteOldest,
|
||||
this.autoAddMaxHops,
|
||||
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,
|
||||
this.clientRepeat,
|
||||
this.pathHashMode,
|
||||
this.supportsSpectrumScan,
|
||||
this.spectrumScanMinKhz,
|
||||
this.spectrumScanMaxKhz,
|
||||
this.allowedRepeatFreqRanges,
|
||||
});
|
||||
|
||||
/// 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,
|
||||
bool? autoAddUsers,
|
||||
bool? autoAddRepeaters,
|
||||
bool? autoAddRoomServers,
|
||||
bool? autoAddSensors,
|
||||
bool? autoAddOverwriteOldest,
|
||||
int? autoAddMaxHops,
|
||||
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,
|
||||
bool? clientRepeat,
|
||||
int? pathHashMode,
|
||||
bool? supportsSpectrumScan,
|
||||
int? spectrumScanMinKhz,
|
||||
int? spectrumScanMaxKhz,
|
||||
List<({int lower, int upper})>? allowedRepeatFreqRanges,
|
||||
}) {
|
||||
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,
|
||||
autoAddUsers: autoAddUsers ?? this.autoAddUsers,
|
||||
autoAddRepeaters: autoAddRepeaters ?? this.autoAddRepeaters,
|
||||
autoAddRoomServers: autoAddRoomServers ?? this.autoAddRoomServers,
|
||||
autoAddSensors: autoAddSensors ?? this.autoAddSensors,
|
||||
autoAddOverwriteOldest:
|
||||
autoAddOverwriteOldest ?? this.autoAddOverwriteOldest,
|
||||
autoAddMaxHops: autoAddMaxHops ?? this.autoAddMaxHops,
|
||||
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,
|
||||
clientRepeat: clientRepeat ?? this.clientRepeat,
|
||||
pathHashMode: pathHashMode ?? this.pathHashMode,
|
||||
supportsSpectrumScan: supportsSpectrumScan ?? this.supportsSpectrumScan,
|
||||
spectrumScanMinKhz: spectrumScanMinKhz ?? this.spectrumScanMinKhz,
|
||||
spectrumScanMaxKhz: spectrumScanMaxKhz ?? this.spectrumScanMaxKhz,
|
||||
allowedRepeatFreqRanges:
|
||||
allowedRepeatFreqRanges ?? this.allowedRepeatFreqRanges,
|
||||
);
|
||||
}
|
||||
|
||||
@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,
|
||||
);
|
||||
}
|
||||
}
|
||||
11
lib/models/map_coordinate_space.dart
Normal file
11
lib/models/map_coordinate_space.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
enum MapCoordinateSpace {
|
||||
geo,
|
||||
customMap;
|
||||
|
||||
static MapCoordinateSpace fromName(String? value) {
|
||||
return MapCoordinateSpace.values.firstWhere(
|
||||
(space) => space.name == value,
|
||||
orElse: () => MapCoordinateSpace.geo,
|
||||
);
|
||||
}
|
||||
}
|
||||
495
lib/models/map_drawing.dart
Normal file
495
lib/models/map_drawing.dart
Normal file
@@ -0,0 +1,495 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import 'map_coordinate_space.dart';
|
||||
import '../utils/custom_map_id.dart';
|
||||
|
||||
/// Drawing shape type
|
||||
enum DrawingShapeType { line, rectangle }
|
||||
|
||||
/// Drawing color enum for compact network transmission
|
||||
enum DrawingColor { red, blue, green, yellow, orange, purple, pink, cyan }
|
||||
|
||||
/// Drawing colors available for user selection
|
||||
class DrawingColors {
|
||||
static const List<Color> palette = [
|
||||
Colors.red,
|
||||
Colors.blue,
|
||||
Colors.green,
|
||||
Colors.yellow,
|
||||
Colors.orange,
|
||||
Colors.purple,
|
||||
Colors.pink,
|
||||
Colors.cyan,
|
||||
];
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
static int colorToIndex(Color color) {
|
||||
for (int i = 0; i < palette.length; i++) {
|
||||
if (palette[i].toARGB32() == color.toARGB32()) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static Color indexToColor(int index) {
|
||||
if (index >= 0 && index < palette.length) {
|
||||
return palette[index];
|
||||
}
|
||||
return palette[0];
|
||||
}
|
||||
}
|
||||
|
||||
abstract class MapDrawing {
|
||||
final String id;
|
||||
final DrawingShapeType type;
|
||||
final Color color;
|
||||
final DateTime createdAt;
|
||||
final String? senderName;
|
||||
final bool isReceived;
|
||||
final String? messageId;
|
||||
final bool isShared;
|
||||
final bool isSent;
|
||||
final bool isHidden;
|
||||
final MapCoordinateSpace coordinateSpace;
|
||||
final String? mapId;
|
||||
|
||||
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,
|
||||
this.coordinateSpace = MapCoordinateSpace.geo,
|
||||
this.mapId,
|
||||
});
|
||||
|
||||
bool get isCustomMap => coordinateSpace == MapCoordinateSpace.customMap;
|
||||
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
Map<String, dynamic> toNetworkJson();
|
||||
|
||||
static MapDrawing? fromNetworkJson(
|
||||
Map<String, dynamic> json, {
|
||||
String? senderName,
|
||||
String? messageId,
|
||||
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
|
||||
String? mapId,
|
||||
}) {
|
||||
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,
|
||||
coordinateSpace: coordinateSpace,
|
||||
mapId: mapId,
|
||||
);
|
||||
case DrawingShapeType.rectangle:
|
||||
return RectangleDrawing.fromNetworkJson(
|
||||
json,
|
||||
senderName: senderName,
|
||||
messageId: messageId,
|
||||
coordinateSpace: coordinateSpace,
|
||||
mapId: mapId,
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static MapDrawing? fromJson(Map<String, dynamic> json) {
|
||||
final typeStr = json['type'] as String?;
|
||||
if (typeStr == null) return null;
|
||||
|
||||
try {
|
||||
final type = DrawingShapeType.values.firstWhere(
|
||||
(value) => value.name == typeStr,
|
||||
);
|
||||
switch (type) {
|
||||
case DrawingShapeType.line:
|
||||
return LineDrawing.fromJson(json);
|
||||
case DrawingShapeType.rectangle:
|
||||
return RectangleDrawing.fromJson(json);
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
LatLng getCenter();
|
||||
|
||||
LatLngBounds getBounds();
|
||||
}
|
||||
|
||||
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.coordinateSpace,
|
||||
super.mapId,
|
||||
}) : super(type: DrawingShapeType.line);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'type': type.name,
|
||||
'color': color.toARGB32(),
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'points': points
|
||||
.map((point) => {'lat': point.latitude, 'lon': point.longitude})
|
||||
.toList(),
|
||||
'isShared': isShared,
|
||||
'coordinateSpace': coordinateSpace.name,
|
||||
'mapId': normalizeCustomMapId(mapId),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toNetworkJson() {
|
||||
final payload = <String, dynamic>{
|
||||
't': type.index,
|
||||
'c': DrawingColors.colorToIndex(color),
|
||||
};
|
||||
|
||||
if (coordinateSpace == MapCoordinateSpace.customMap) {
|
||||
payload['m'] = normalizeCustomMapId(mapId);
|
||||
payload['p'] = points
|
||||
.expand((point) => [point.latitude.round(), point.longitude.round()])
|
||||
.toList();
|
||||
return payload;
|
||||
}
|
||||
|
||||
payload['p'] = points
|
||||
.expand(
|
||||
(point) => [
|
||||
double.parse(point.latitude.toStringAsFixed(5)),
|
||||
double.parse(point.longitude.toStringAsFixed(5)),
|
||||
],
|
||||
)
|
||||
.toList();
|
||||
return payload;
|
||||
}
|
||||
|
||||
static LineDrawing fromJson(Map<String, dynamic> json) {
|
||||
final pointsJson = json['points'] as List<dynamic>;
|
||||
final points = pointsJson
|
||||
.map(
|
||||
(point) => LatLng(
|
||||
(point['lat'] as num).toDouble(),
|
||||
(point['lon'] as num).toDouble(),
|
||||
),
|
||||
)
|
||||
.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,
|
||||
isShared: json['isShared'] as bool? ?? false,
|
||||
coordinateSpace: MapCoordinateSpace.fromName(
|
||||
json['coordinateSpace'] as String?,
|
||||
),
|
||||
mapId: normalizeCustomMapId(json['mapId'] as String?),
|
||||
);
|
||||
}
|
||||
|
||||
static LineDrawing fromNetworkJson(
|
||||
Map<String, dynamic> json, {
|
||||
String? senderName,
|
||||
String? messageId,
|
||||
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
|
||||
String? mapId,
|
||||
}) {
|
||||
final flatPoints = (json['p'] as List<dynamic>).cast<num>();
|
||||
final points = <LatLng>[];
|
||||
for (int i = 0; i < flatPoints.length; i += 2) {
|
||||
points.add(
|
||||
LatLng(flatPoints[i].toDouble(), flatPoints[i + 1].toDouble()),
|
||||
);
|
||||
}
|
||||
|
||||
return LineDrawing(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
color: DrawingColors.indexToColor(json['c'] as int),
|
||||
createdAt: DateTime.now(),
|
||||
points: points,
|
||||
senderName: senderName,
|
||||
isReceived: true,
|
||||
messageId: messageId,
|
||||
coordinateSpace: coordinateSpace,
|
||||
mapId: normalizeCustomMapId(mapId),
|
||||
);
|
||||
}
|
||||
|
||||
LineDrawing copyWith({
|
||||
List<LatLng>? points,
|
||||
bool? isHidden,
|
||||
bool? isShared,
|
||||
bool? isReceived,
|
||||
String? messageId,
|
||||
String? senderName,
|
||||
MapCoordinateSpace? coordinateSpace,
|
||||
String? mapId,
|
||||
}) {
|
||||
return LineDrawing(
|
||||
id: id,
|
||||
color: color,
|
||||
createdAt: createdAt,
|
||||
points: points ?? this.points,
|
||||
isHidden: isHidden ?? this.isHidden,
|
||||
isShared: isShared ?? this.isShared,
|
||||
isReceived: isReceived ?? this.isReceived,
|
||||
messageId: messageId ?? this.messageId,
|
||||
senderName: senderName ?? this.senderName,
|
||||
coordinateSpace: coordinateSpace ?? this.coordinateSpace,
|
||||
mapId: mapId ?? this.mapId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
LatLng getCenter() {
|
||||
if (points.isEmpty) return const LatLng(0, 0);
|
||||
if (points.length == 1) return points[0];
|
||||
|
||||
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(const LatLng(0, 0), const 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));
|
||||
}
|
||||
}
|
||||
|
||||
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.coordinateSpace,
|
||||
super.mapId,
|
||||
}) : super(type: DrawingShapeType.rectangle);
|
||||
|
||||
List<LatLng> get corners => [
|
||||
topLeft,
|
||||
LatLng(topLeft.latitude, bottomRight.longitude),
|
||||
bottomRight,
|
||||
LatLng(bottomRight.latitude, topLeft.longitude),
|
||||
topLeft,
|
||||
];
|
||||
|
||||
@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,
|
||||
'coordinateSpace': coordinateSpace.name,
|
||||
'mapId': normalizeCustomMapId(mapId),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toNetworkJson() {
|
||||
final payload = <String, dynamic>{
|
||||
't': type.index,
|
||||
'c': DrawingColors.colorToIndex(color),
|
||||
};
|
||||
|
||||
if (coordinateSpace == MapCoordinateSpace.customMap) {
|
||||
payload['m'] = normalizeCustomMapId(mapId);
|
||||
payload['b'] = [
|
||||
topLeft.latitude.round(),
|
||||
topLeft.longitude.round(),
|
||||
bottomRight.latitude.round(),
|
||||
bottomRight.longitude.round(),
|
||||
];
|
||||
return payload;
|
||||
}
|
||||
|
||||
payload['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)),
|
||||
];
|
||||
return payload;
|
||||
}
|
||||
|
||||
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 num).toDouble(),
|
||||
(topLeftJson['lon'] as num).toDouble(),
|
||||
),
|
||||
bottomRight: LatLng(
|
||||
(bottomRightJson['lat'] as num).toDouble(),
|
||||
(bottomRightJson['lon'] as num).toDouble(),
|
||||
),
|
||||
senderName: senderName,
|
||||
isReceived: senderName != null,
|
||||
isShared: json['isShared'] as bool? ?? false,
|
||||
coordinateSpace: MapCoordinateSpace.fromName(
|
||||
json['coordinateSpace'] as String?,
|
||||
),
|
||||
mapId: normalizeCustomMapId(json['mapId'] as String?),
|
||||
);
|
||||
}
|
||||
|
||||
static RectangleDrawing fromNetworkJson(
|
||||
Map<String, dynamic> json, {
|
||||
String? senderName,
|
||||
String? messageId,
|
||||
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
|
||||
String? mapId,
|
||||
}) {
|
||||
final bounds = (json['b'] as List<dynamic>).cast<num>();
|
||||
|
||||
return RectangleDrawing(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
color: DrawingColors.indexToColor(json['c'] as int),
|
||||
createdAt: DateTime.now(),
|
||||
topLeft: LatLng(bounds[0].toDouble(), bounds[1].toDouble()),
|
||||
bottomRight: LatLng(bounds[2].toDouble(), bounds[3].toDouble()),
|
||||
senderName: senderName,
|
||||
isReceived: true,
|
||||
messageId: messageId,
|
||||
coordinateSpace: coordinateSpace,
|
||||
mapId: normalizeCustomMapId(mapId),
|
||||
);
|
||||
}
|
||||
|
||||
RectangleDrawing copyWith({
|
||||
LatLng? topLeft,
|
||||
LatLng? bottomRight,
|
||||
bool? isHidden,
|
||||
bool? isShared,
|
||||
bool? isReceived,
|
||||
String? messageId,
|
||||
String? senderName,
|
||||
MapCoordinateSpace? coordinateSpace,
|
||||
String? mapId,
|
||||
}) {
|
||||
return RectangleDrawing(
|
||||
id: id,
|
||||
color: color,
|
||||
createdAt: createdAt,
|
||||
topLeft: topLeft ?? this.topLeft,
|
||||
bottomRight: bottomRight ?? this.bottomRight,
|
||||
isHidden: isHidden ?? this.isHidden,
|
||||
isShared: isShared ?? this.isShared,
|
||||
isReceived: isReceived ?? this.isReceived,
|
||||
messageId: messageId ?? this.messageId,
|
||||
senderName: senderName ?? this.senderName,
|
||||
coordinateSpace: coordinateSpace ?? this.coordinateSpace,
|
||||
mapId: mapId ?? this.mapId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
LatLng getCenter() {
|
||||
return LatLng(
|
||||
(topLeft.latitude + bottomRight.latitude) / 2,
|
||||
(topLeft.longitude + bottomRight.longitude) / 2,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
LatLngBounds getBounds() {
|
||||
return LatLngBounds(topLeft, bottomRight);
|
||||
}
|
||||
}
|
||||
194
lib/models/map_layer.dart
Normal file
194
lib/models/map_layer.dart
Normal file
@@ -0,0 +1,194 @@
|
||||
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,
|
||||
// Kept for stored preference compatibility after MBTiles removal.
|
||||
vectorMbtiles,
|
||||
wmsBase,
|
||||
// Appended last: stored layer preference is the enum index.
|
||||
mapyOutdoor,
|
||||
}
|
||||
|
||||
class MapLayer {
|
||||
final MapLayerType type;
|
||||
final String name;
|
||||
final String urlTemplate;
|
||||
final String attribution;
|
||||
final double maxZoom;
|
||||
|
||||
/// Extra HTTP headers required by the tile server (e.g. Referer).
|
||||
final Map<String, String>? headers;
|
||||
|
||||
// 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.headers,
|
||||
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:
|
||||
// Legacy value kept only for preference migration compatibility.
|
||||
return name;
|
||||
case MapLayerType.mapyOutdoor:
|
||||
// Brand name, not translated.
|
||||
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)
|
||||
);
|
||||
|
||||
/// Mapy.cz outdoor map: OSM data with LIDAR-based shaded relief.
|
||||
/// The tile server rejects requests without a mapy.com Referer.
|
||||
static const mapyOutdoor = MapLayer(
|
||||
type: MapLayerType.mapyOutdoor,
|
||||
name: 'Mapy.cz Outdoor (OSM + LIDAR)',
|
||||
urlTemplate: 'https://mapserver.mapy.cz/turist-en/retina/{z}-{x}-{y}',
|
||||
attribution: '© Seznam.cz, a.s., © OpenStreetMap contributors',
|
||||
maxZoom: 19,
|
||||
headers: {'Referer': 'https://mapy.com/'},
|
||||
);
|
||||
|
||||
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,
|
||||
mapyOutdoor,
|
||||
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);
|
||||
}
|
||||
}
|
||||
83
lib/models/message.dart
Normal file
83
lib/models/message.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
export 'package:meshcore_client/meshcore_client.dart'
|
||||
show
|
||||
Message,
|
||||
MessageType,
|
||||
MessageTextType,
|
||||
MessageDeliveryStatus,
|
||||
MessageRecipient;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
import 'sar_marker.dart';
|
||||
import 'map_coordinate_space.dart';
|
||||
import '../utils/voice_message_parser.dart';
|
||||
|
||||
extension MessageVoiceExtension on Message {
|
||||
/// True when this message is a voice recording (`V:` text or binary voice).
|
||||
bool get isVoiceMessage => isVoice;
|
||||
|
||||
/// Parses the voice packet mode from the stored [voiceId] session info.
|
||||
/// Returns null for non-voice messages.
|
||||
VoicePacketMode? get voicePacketMode {
|
||||
if (!isVoice || text.isEmpty) return null;
|
||||
final envelope = VoiceEnvelope.tryParseText(text);
|
||||
if (envelope != null) return envelope.mode;
|
||||
final pkt = VoicePacket.tryParseText(text);
|
||||
return pkt?.mode;
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageSarExtension on Message {
|
||||
/// Infer the [SarMarkerType] from stored SAR fields.
|
||||
/// Returns null if this is not a SAR marker message.
|
||||
SarMarkerType? get sarMarkerType {
|
||||
if (!isSarMarker) return null;
|
||||
|
||||
if (sarCustomEmoji != null && sarCustomEmoji!.isNotEmpty) {
|
||||
return SarMarkerType.fromEmoji(sarCustomEmoji!);
|
||||
}
|
||||
|
||||
final trimmed = text.trim();
|
||||
if (!trimmed.startsWith('S:')) return null;
|
||||
|
||||
final parts = trimmed.split(':');
|
||||
if (parts.length < 3) return null;
|
||||
|
||||
return SarMarkerType.fromEmoji(parts[1]);
|
||||
}
|
||||
|
||||
/// Convert to a [SarMarker] if this message contains SAR data.
|
||||
SarMarker? toSarMarker() {
|
||||
if (!isSarMarker || sarMarkerType == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
debugPrint('📍 [Message.toSarMarker] Converting to marker:');
|
||||
debugPrint(' message.text: "$text"');
|
||||
debugPrint(' message.sarNotes: "$sarNotes"');
|
||||
debugPrint(' message.sarMarkerType: $sarMarkerType');
|
||||
debugPrint(' message.sarCustomEmoji: "$sarCustomEmoji"');
|
||||
|
||||
final coordinateSpace = sarCustomMapPoint != null && sarCustomMapId != null
|
||||
? MapCoordinateSpace.customMap
|
||||
: MapCoordinateSpace.geo;
|
||||
final location = sarCustomMapPoint ?? sarGpsCoordinates;
|
||||
if (location == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SarMarker(
|
||||
id: id,
|
||||
type: sarMarkerType!,
|
||||
location: location,
|
||||
timestamp: sentAt,
|
||||
senderPublicKey: senderPublicKeyPrefix,
|
||||
senderName: senderName,
|
||||
notes: sarNotes,
|
||||
customEmoji: sarCustomEmoji,
|
||||
colorIndex: sarColorIndex,
|
||||
coordinateSpace: coordinateSpace,
|
||||
mapId: sarCustomMapId,
|
||||
);
|
||||
}
|
||||
}
|
||||
67
lib/models/message_contact_location.dart
Normal file
67
lib/models/message_contact_location.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class MessageContactLocation {
|
||||
final LatLng location;
|
||||
final String source;
|
||||
final DateTime capturedAt;
|
||||
final DateTime? sourceTimestamp;
|
||||
|
||||
const MessageContactLocation({
|
||||
required this.location,
|
||||
required this.source,
|
||||
required this.capturedAt,
|
||||
this.sourceTimestamp,
|
||||
});
|
||||
|
||||
String get technicalSourceLabel {
|
||||
switch (source) {
|
||||
case 'shared':
|
||||
return 'shared location';
|
||||
case 'gps':
|
||||
return 'shared location';
|
||||
case 'telemetry':
|
||||
return 'live telemetry';
|
||||
case 'advert':
|
||||
return 'shared advert';
|
||||
default:
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
String get formattedCoordinates =>
|
||||
'${location.latitude.toStringAsFixed(6)}, ${location.longitude.toStringAsFixed(6)}';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'latitude': location.latitude,
|
||||
'longitude': location.longitude,
|
||||
'source': source,
|
||||
'capturedAtMillis': capturedAt.millisecondsSinceEpoch,
|
||||
'sourceTimestampMillis': sourceTimestamp?.millisecondsSinceEpoch,
|
||||
};
|
||||
}
|
||||
|
||||
static MessageContactLocation? fromJson(Map<String, dynamic> json) {
|
||||
final latitude = json['latitude'];
|
||||
final longitude = json['longitude'];
|
||||
final source = json['source'];
|
||||
final capturedAtMillis = json['capturedAtMillis'];
|
||||
if (latitude is! num ||
|
||||
longitude is! num ||
|
||||
source is! String ||
|
||||
capturedAtMillis is! int) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return MessageContactLocation(
|
||||
location: LatLng(latitude.toDouble(), longitude.toDouble()),
|
||||
source: source,
|
||||
capturedAt: DateTime.fromMillisecondsSinceEpoch(capturedAtMillis),
|
||||
sourceTimestamp: json['sourceTimestampMillis'] is int
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
json['sourceTimestampMillis'] as int,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
140
lib/models/message_reception_details.dart
Normal file
140
lib/models/message_reception_details.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
const int _transmitEstimateToleranceMs = 1500;
|
||||
|
||||
int? sanitizeEstimatedTransmitMs({
|
||||
required int? estimatedTransmitMs,
|
||||
required int? senderToReceiptMs,
|
||||
}) {
|
||||
if (estimatedTransmitMs == null || estimatedTransmitMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (senderToReceiptMs == null || senderToReceiptMs <= 0) {
|
||||
return estimatedTransmitMs;
|
||||
}
|
||||
|
||||
// Sender timestamps are second-granularity, so allow a small cushion before
|
||||
// treating the estimate as impossible for the observed delivery time.
|
||||
if (estimatedTransmitMs > senderToReceiptMs + _transmitEstimateToleranceMs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return estimatedTransmitMs;
|
||||
}
|
||||
|
||||
class MessageReceptionDetails {
|
||||
final DateTime capturedAt;
|
||||
final DateTime? packetLoggedAt;
|
||||
final int? rssiDbm;
|
||||
final double? snrDb;
|
||||
final List<int>? pathBytes;
|
||||
final int? senderToReceiptMs;
|
||||
final int? estimatedTransmitMs;
|
||||
final int? postTransmitDelayMs;
|
||||
final int receivedCopies;
|
||||
|
||||
const MessageReceptionDetails({
|
||||
required this.capturedAt,
|
||||
this.packetLoggedAt,
|
||||
this.rssiDbm,
|
||||
this.snrDb,
|
||||
this.pathBytes,
|
||||
this.senderToReceiptMs,
|
||||
this.estimatedTransmitMs,
|
||||
this.postTransmitDelayMs,
|
||||
this.receivedCopies = 1,
|
||||
});
|
||||
|
||||
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
|
||||
? null
|
||||
: pathBytes!.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'capturedAtMillis': capturedAt.millisecondsSinceEpoch,
|
||||
'packetLoggedAtMillis': packetLoggedAt?.millisecondsSinceEpoch,
|
||||
'rssiDbm': rssiDbm,
|
||||
'snrDb': snrDb,
|
||||
'pathBytes': pathBytes,
|
||||
'senderToReceiptMs': senderToReceiptMs,
|
||||
'estimatedTransmitMs': estimatedTransmitMs,
|
||||
'postTransmitDelayMs': postTransmitDelayMs,
|
||||
'receivedCopies': receivedCopies,
|
||||
};
|
||||
}
|
||||
|
||||
MessageReceptionDetails copyWith({
|
||||
DateTime? capturedAt,
|
||||
DateTime? packetLoggedAt,
|
||||
int? rssiDbm,
|
||||
double? snrDb,
|
||||
List<int>? pathBytes,
|
||||
int? senderToReceiptMs,
|
||||
int? estimatedTransmitMs,
|
||||
int? postTransmitDelayMs,
|
||||
int? receivedCopies,
|
||||
}) {
|
||||
return MessageReceptionDetails(
|
||||
capturedAt: capturedAt ?? this.capturedAt,
|
||||
packetLoggedAt: packetLoggedAt ?? this.packetLoggedAt,
|
||||
rssiDbm: rssiDbm ?? this.rssiDbm,
|
||||
snrDb: snrDb ?? this.snrDb,
|
||||
pathBytes: pathBytes ?? this.pathBytes,
|
||||
senderToReceiptMs: senderToReceiptMs ?? this.senderToReceiptMs,
|
||||
estimatedTransmitMs: estimatedTransmitMs ?? this.estimatedTransmitMs,
|
||||
postTransmitDelayMs: postTransmitDelayMs ?? this.postTransmitDelayMs,
|
||||
receivedCopies: receivedCopies ?? this.receivedCopies,
|
||||
);
|
||||
}
|
||||
|
||||
static MessageReceptionDetails mergeDuplicate({
|
||||
MessageReceptionDetails? existing,
|
||||
MessageReceptionDetails? incoming,
|
||||
}) {
|
||||
final base =
|
||||
existing ??
|
||||
incoming ??
|
||||
MessageReceptionDetails(capturedAt: DateTime.now());
|
||||
|
||||
return base.copyWith(
|
||||
capturedAt: incoming?.capturedAt ?? existing?.capturedAt,
|
||||
packetLoggedAt: incoming?.packetLoggedAt ?? existing?.packetLoggedAt,
|
||||
rssiDbm: incoming?.rssiDbm ?? existing?.rssiDbm,
|
||||
snrDb: incoming?.snrDb ?? existing?.snrDb,
|
||||
pathBytes: incoming?.pathBytes ?? existing?.pathBytes,
|
||||
senderToReceiptMs:
|
||||
incoming?.senderToReceiptMs ?? existing?.senderToReceiptMs,
|
||||
estimatedTransmitMs:
|
||||
incoming?.estimatedTransmitMs ?? existing?.estimatedTransmitMs,
|
||||
postTransmitDelayMs:
|
||||
incoming?.postTransmitDelayMs ?? existing?.postTransmitDelayMs,
|
||||
receivedCopies:
|
||||
(existing?.receivedCopies ?? 1) + (incoming?.receivedCopies ?? 1),
|
||||
);
|
||||
}
|
||||
|
||||
static MessageReceptionDetails? fromJson(Map<String, dynamic> json) {
|
||||
final capturedAtMillis = json['capturedAtMillis'];
|
||||
if (capturedAtMillis is! int) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final pathBytes = json['pathBytes'];
|
||||
return MessageReceptionDetails(
|
||||
capturedAt: DateTime.fromMillisecondsSinceEpoch(capturedAtMillis),
|
||||
packetLoggedAt: json['packetLoggedAtMillis'] is int
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
json['packetLoggedAtMillis'] as int,
|
||||
)
|
||||
: null,
|
||||
rssiDbm: json['rssiDbm'] as int?,
|
||||
snrDb: (json['snrDb'] as num?)?.toDouble(),
|
||||
pathBytes: pathBytes is List
|
||||
? pathBytes.whereType<num>().map((b) => b.toInt()).toList()
|
||||
: null,
|
||||
senderToReceiptMs: json['senderToReceiptMs'] as int?,
|
||||
estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
|
||||
postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
|
||||
receivedCopies: json['receivedCopies'] as int? ?? 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
79
lib/models/message_route_metadata.dart
Normal file
79
lib/models/message_route_metadata.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
import 'path_selection.dart';
|
||||
|
||||
class MessageRouteMetadata {
|
||||
final PathSelectionMode mode;
|
||||
final bool routerFallbackAttempted;
|
||||
final String? relayName;
|
||||
final String? relayKey6;
|
||||
final String? canonicalPath;
|
||||
final int? hopCount;
|
||||
|
||||
const MessageRouteMetadata({
|
||||
required this.mode,
|
||||
required this.routerFallbackAttempted,
|
||||
this.relayName,
|
||||
this.relayKey6,
|
||||
this.canonicalPath,
|
||||
this.hopCount,
|
||||
});
|
||||
|
||||
factory MessageRouteMetadata.fromSelection(
|
||||
PathSelection selection, {
|
||||
required bool routerFallbackAttempted,
|
||||
}) {
|
||||
return MessageRouteMetadata(
|
||||
mode: selection.mode,
|
||||
routerFallbackAttempted: routerFallbackAttempted,
|
||||
relayName: selection.relayName,
|
||||
relayKey6: selection.relayKey6,
|
||||
canonicalPath: selection.canonicalPath.isEmpty
|
||||
? null
|
||||
: selection.canonicalPath,
|
||||
hopCount: selection.hopCount > 0 ? selection.hopCount : null,
|
||||
);
|
||||
}
|
||||
|
||||
String get modeLabel {
|
||||
switch (mode) {
|
||||
case PathSelectionMode.directCurrent:
|
||||
return 'Current direct path';
|
||||
case PathSelectionMode.directHistorical:
|
||||
return 'Rotated direct path';
|
||||
case PathSelectionMode.flood:
|
||||
return 'Flood route';
|
||||
case PathSelectionMode.nearestRouter:
|
||||
final suffix = relayName?.trim().isNotEmpty == true
|
||||
? ' via $relayName'
|
||||
: relayKey6?.trim().isNotEmpty == true
|
||||
? ' via $relayKey6'
|
||||
: '';
|
||||
return 'Nearest router$suffix';
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'mode': mode.name,
|
||||
'router_fallback_attempted': routerFallbackAttempted,
|
||||
'relay_name': relayName,
|
||||
'relay_key6': relayKey6,
|
||||
'canonical_path': canonicalPath,
|
||||
'hop_count': hopCount,
|
||||
};
|
||||
}
|
||||
|
||||
factory MessageRouteMetadata.fromJson(Map<String, dynamic> json) {
|
||||
return MessageRouteMetadata(
|
||||
mode: PathSelectionMode.values.firstWhere(
|
||||
(value) => value.name == json['mode'],
|
||||
orElse: () => PathSelectionMode.directCurrent,
|
||||
),
|
||||
routerFallbackAttempted:
|
||||
json['router_fallback_attempted'] as bool? ?? false,
|
||||
relayName: json['relay_name'] as String?,
|
||||
relayKey6: json['relay_key6'] as String?,
|
||||
canonicalPath: json['canonical_path'] as String?,
|
||||
hopCount: json['hop_count'] as int?,
|
||||
);
|
||||
}
|
||||
}
|
||||
142
lib/models/message_transfer_details.dart
Normal file
142
lib/models/message_transfer_details.dart
Normal file
@@ -0,0 +1,142 @@
|
||||
class MessageTransferDownloader {
|
||||
final String requesterKey6;
|
||||
final String? requesterName;
|
||||
final int transferCount;
|
||||
final DateTime lastTransferredAt;
|
||||
|
||||
const MessageTransferDownloader({
|
||||
required this.requesterKey6,
|
||||
this.requesterName,
|
||||
required this.transferCount,
|
||||
required this.lastTransferredAt,
|
||||
});
|
||||
|
||||
MessageTransferDownloader copyWith({
|
||||
String? requesterKey6,
|
||||
String? requesterName,
|
||||
int? transferCount,
|
||||
DateTime? lastTransferredAt,
|
||||
}) {
|
||||
return MessageTransferDownloader(
|
||||
requesterKey6: requesterKey6 ?? this.requesterKey6,
|
||||
requesterName: requesterName ?? this.requesterName,
|
||||
transferCount: transferCount ?? this.transferCount,
|
||||
lastTransferredAt: lastTransferredAt ?? this.lastTransferredAt,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'requesterKey6': requesterKey6,
|
||||
'requesterName': requesterName,
|
||||
'transferCount': transferCount,
|
||||
'lastTransferredAtMillis': lastTransferredAt.millisecondsSinceEpoch,
|
||||
};
|
||||
}
|
||||
|
||||
static MessageTransferDownloader? fromJson(Map<String, dynamic> json) {
|
||||
final requesterKey6 = json['requesterKey6'];
|
||||
final transferCount = json['transferCount'];
|
||||
final lastTransferredAtMillis = json['lastTransferredAtMillis'];
|
||||
if (requesterKey6 is! String ||
|
||||
transferCount is! int ||
|
||||
lastTransferredAtMillis is! int) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return MessageTransferDownloader(
|
||||
requesterKey6: requesterKey6,
|
||||
requesterName: json['requesterName'] as String?,
|
||||
transferCount: transferCount,
|
||||
lastTransferredAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
lastTransferredAtMillis,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MessageTransferDetails {
|
||||
final int totalTransfers;
|
||||
final List<MessageTransferDownloader> downloaders;
|
||||
|
||||
const MessageTransferDetails({
|
||||
required this.totalTransfers,
|
||||
required this.downloaders,
|
||||
});
|
||||
|
||||
const MessageTransferDetails.empty()
|
||||
: totalTransfers = 0,
|
||||
downloaders = const [];
|
||||
|
||||
MessageTransferDetails registerTransfer({
|
||||
required String requesterKey6,
|
||||
String? requesterName,
|
||||
DateTime? transferredAt,
|
||||
}) {
|
||||
final eventAt = transferredAt ?? DateTime.now();
|
||||
final normalizedName = requesterName?.trim();
|
||||
final updatedDownloaders = List<MessageTransferDownloader>.from(
|
||||
downloaders,
|
||||
);
|
||||
final index = updatedDownloaders.indexWhere(
|
||||
(entry) => entry.requesterKey6 == requesterKey6,
|
||||
);
|
||||
|
||||
if (index == -1) {
|
||||
updatedDownloaders.add(
|
||||
MessageTransferDownloader(
|
||||
requesterKey6: requesterKey6,
|
||||
requesterName: normalizedName?.isEmpty ?? true
|
||||
? null
|
||||
: normalizedName,
|
||||
transferCount: 1,
|
||||
lastTransferredAt: eventAt,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final existing = updatedDownloaders[index];
|
||||
updatedDownloaders[index] = existing.copyWith(
|
||||
requesterName: normalizedName?.isEmpty ?? true
|
||||
? existing.requesterName
|
||||
: normalizedName,
|
||||
transferCount: existing.transferCount + 1,
|
||||
lastTransferredAt: eventAt,
|
||||
);
|
||||
}
|
||||
|
||||
updatedDownloaders.sort(
|
||||
(a, b) => b.lastTransferredAt.compareTo(a.lastTransferredAt),
|
||||
);
|
||||
|
||||
return MessageTransferDetails(
|
||||
totalTransfers: totalTransfers + 1,
|
||||
downloaders: updatedDownloaders,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'totalTransfers': totalTransfers,
|
||||
'downloaders': downloaders.map((entry) => entry.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
static MessageTransferDetails? fromJson(Map<String, dynamic> json) {
|
||||
final totalTransfers = json['totalTransfers'];
|
||||
if (totalTransfers is! int) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final rawDownloaders = json['downloaders'] as List<dynamic>? ?? const [];
|
||||
final downloaders = rawDownloaders
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(MessageTransferDownloader.fromJson)
|
||||
.whereType<MessageTransferDownloader>()
|
||||
.toList();
|
||||
|
||||
return MessageTransferDetails(
|
||||
totalTransfers: totalTransfers,
|
||||
downloaders: downloaders,
|
||||
);
|
||||
}
|
||||
}
|
||||
65
lib/models/path_selection.dart
Normal file
65
lib/models/path_selection.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
enum PathSelectionMode { directCurrent, directHistorical, flood, nearestRouter }
|
||||
|
||||
class PathSelection {
|
||||
final PathSelectionMode mode;
|
||||
final Uint8List pathBytes;
|
||||
final int hopCount;
|
||||
final int hashSize;
|
||||
final String? relayName;
|
||||
final String? relayKey6;
|
||||
|
||||
const PathSelection({
|
||||
required this.mode,
|
||||
required this.pathBytes,
|
||||
required this.hopCount,
|
||||
required this.hashSize,
|
||||
this.relayName,
|
||||
this.relayKey6,
|
||||
});
|
||||
|
||||
PathSelection.flood()
|
||||
: mode = PathSelectionMode.flood,
|
||||
pathBytes = Uint8List(0),
|
||||
hopCount = -1,
|
||||
hashSize = 1,
|
||||
relayName = null,
|
||||
relayKey6 = null;
|
||||
|
||||
bool get usesFlood => mode == PathSelectionMode.flood;
|
||||
bool get hasDirectPath => !usesFlood && pathBytes.isNotEmpty && hopCount > 0;
|
||||
|
||||
String get canonicalPath {
|
||||
if (!hasDirectPath) return '';
|
||||
final hops = <String>[];
|
||||
for (var index = 0; index < pathBytes.length; index += hashSize) {
|
||||
hops.add(
|
||||
pathBytes
|
||||
.sublist(index, index + hashSize)
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toUpperCase(),
|
||||
);
|
||||
}
|
||||
return hops.join(',');
|
||||
}
|
||||
|
||||
PathSelection copyWith({
|
||||
PathSelectionMode? mode,
|
||||
Uint8List? pathBytes,
|
||||
int? hopCount,
|
||||
int? hashSize,
|
||||
String? relayName,
|
||||
String? relayKey6,
|
||||
}) {
|
||||
return PathSelection(
|
||||
mode: mode ?? this.mode,
|
||||
pathBytes: pathBytes ?? this.pathBytes,
|
||||
hopCount: hopCount ?? this.hopCount,
|
||||
hashSize: hashSize ?? this.hashSize,
|
||||
relayName: relayName ?? this.relayName,
|
||||
relayKey6: relayKey6 ?? this.relayKey6,
|
||||
);
|
||||
}
|
||||
}
|
||||
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)';
|
||||
}
|
||||
}
|
||||
209
lib/models/sar_marker.dart
Normal file
209
lib/models/sar_marker.dart
Normal file
@@ -0,0 +1,209 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'map_coordinate_space.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
|
||||
final MapCoordinateSpace coordinateSpace;
|
||||
final String? mapId;
|
||||
|
||||
SarMarker({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.location,
|
||||
required this.timestamp,
|
||||
this.senderPublicKey,
|
||||
this.senderName,
|
||||
this.notes,
|
||||
this.customEmoji,
|
||||
this.colorIndex,
|
||||
this.coordinateSpace = MapCoordinateSpace.geo,
|
||||
this.mapId,
|
||||
});
|
||||
|
||||
bool get isCustomMapMarker =>
|
||||
coordinateSpace == MapCoordinateSpace.customMap && mapId != null;
|
||||
|
||||
/// 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,
|
||||
MapCoordinateSpace? coordinateSpace,
|
||||
String? mapId,
|
||||
}) {
|
||||
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,
|
||||
coordinateSpace: coordinateSpace ?? this.coordinateSpace,
|
||||
mapId: mapId ?? this.mapId,
|
||||
);
|
||||
}
|
||||
|
||||
@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,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
1
lib/models/sent_message_tracker.dart
Normal file
1
lib/models/sent_message_tracker.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'package:meshcore_client/meshcore_client.dart' show SentMessageTracker;
|
||||
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)';
|
||||
}
|
||||
}
|
||||
4474
lib/providers/app_provider.dart
Normal file
4474
lib/providers/app_provider.dart
Normal file
File diff suppressed because it is too large
Load Diff
128
lib/providers/channels_provider.dart
Normal file
128
lib/providers/channels_provider.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
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';
|
||||
}
|
||||
|
||||
List<Channel> getChannelsByHashByte(int hashByte) {
|
||||
return _channels.values.where((channel) => channel.hashByte == hashByte).toList()
|
||||
..sort((a, b) => a.index.compareTo(b.index));
|
||||
}
|
||||
|
||||
String? getUniqueChannelDisplayNameByHashByte(int hashByte) {
|
||||
final matches = getChannelsByHashByte(hashByte);
|
||||
return matches.length == 1 ? matches.single.displayName : null;
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
|
||||
/// Clear runtime channel state before a live device sync begins.
|
||||
void prepareForDeviceSync() {
|
||||
_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();
|
||||
}
|
||||
}
|
||||
3068
lib/providers/connection_provider.dart
Normal file
3068
lib/providers/connection_provider.dart
Normal file
File diff suppressed because it is too large
Load Diff
1914
lib/providers/contacts_provider.dart
Normal file
1914
lib/providers/contacts_provider.dart
Normal file
File diff suppressed because it is too large
Load Diff
628
lib/providers/drawing_provider.dart
Normal file
628
lib/providers/drawing_provider.dart
Normal file
@@ -0,0 +1,628 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/map_drawing.dart';
|
||||
import '../models/map_coordinate_space.dart';
|
||||
import '../services/profiles_feature_service.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';
|
||||
static const String _showReceivedDrawingsKey = 'map_show_received_drawings';
|
||||
static const String _showSarMarkersKey = 'map_show_sar_markers';
|
||||
|
||||
// Drawing state
|
||||
DrawingMode _drawingMode = DrawingMode.none;
|
||||
Color _selectedColor = DrawingColors.palette[0];
|
||||
bool _showReceivedDrawings = true;
|
||||
bool _showSarMarkers = true;
|
||||
|
||||
// Completed drawings
|
||||
final List<MapDrawing> _drawings = [];
|
||||
bool _isInitialized = false;
|
||||
|
||||
// In-progress drawing
|
||||
MapDrawing? _currentDrawing;
|
||||
List<LatLng> _currentLinePoints = [];
|
||||
LatLng? _rectangleStartPoint;
|
||||
|
||||
// Distance measurement state
|
||||
LatLng? _measurementPoint1;
|
||||
LatLng? _measurementPoint2;
|
||||
double? _measuredDistance; // in meters
|
||||
MapCoordinateSpace _activeCoordinateSpace = MapCoordinateSpace.geo;
|
||||
String? _activeMapId;
|
||||
double? _activeMetersPerPixel;
|
||||
|
||||
// Getters
|
||||
DrawingMode get drawingMode => _drawingMode;
|
||||
Color get selectedColor => _selectedColor;
|
||||
bool get showReceivedDrawings => _showReceivedDrawings;
|
||||
bool get showSarMarkers => _showSarMarkers;
|
||||
MapCoordinateSpace get activeCoordinateSpace => _activeCoordinateSpace;
|
||||
String? get activeMapId => _activeMapId;
|
||||
double? get activeMetersPerPixel => _activeMetersPerPixel;
|
||||
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);
|
||||
}
|
||||
|
||||
visibleDrawings = visibleDrawings.where((drawing) {
|
||||
if (drawing.coordinateSpace != _activeCoordinateSpace) {
|
||||
return false;
|
||||
}
|
||||
if (drawing.coordinateSpace == MapCoordinateSpace.customMap) {
|
||||
return drawing.mapId != null && drawing.mapId == _activeMapId;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
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;
|
||||
bool get isInitialized => _isInitialized;
|
||||
LatLng? get measurementPoint1 => _measurementPoint1;
|
||||
LatLng? get measurementPoint2 => _measurementPoint2;
|
||||
double? get measuredDistance => _measuredDistance;
|
||||
|
||||
/// Initialize and load saved drawings
|
||||
Future<void> initialize() async {
|
||||
await _loadPreferences();
|
||||
await _loadDrawings();
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
Future<void> reloadProfileScopedState() async {
|
||||
await _loadPreferences();
|
||||
await _loadDrawings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setMapContext({
|
||||
required MapCoordinateSpace coordinateSpace,
|
||||
String? mapId,
|
||||
double? metersPerPixel,
|
||||
}) {
|
||||
final changed =
|
||||
coordinateSpace != _activeCoordinateSpace ||
|
||||
mapId != _activeMapId ||
|
||||
metersPerPixel != _activeMetersPerPixel;
|
||||
_activeCoordinateSpace = coordinateSpace;
|
||||
_activeMapId = mapId;
|
||||
_activeMetersPerPixel = metersPerPixel;
|
||||
if (_measurementPoint1 != null && _measurementPoint2 != null) {
|
||||
_measuredDistance = _calculateDistance(
|
||||
_measurementPoint1!,
|
||||
_measurementPoint2!,
|
||||
);
|
||||
}
|
||||
if (changed) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
Future<void> toggleReceivedDrawings() async {
|
||||
_showReceivedDrawings = !_showReceivedDrawings;
|
||||
notifyListeners();
|
||||
await _savePreferences();
|
||||
}
|
||||
|
||||
/// Toggle visibility of SAR markers
|
||||
Future<void> toggleSarMarkers() async {
|
||||
_showSarMarkers = !_showSarMarkers;
|
||||
notifyListeners();
|
||||
await _savePreferences();
|
||||
}
|
||||
|
||||
Future<void> _loadPreferences() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_showReceivedDrawings =
|
||||
prefs.getBool(_scopedKey(_showReceivedDrawingsKey)) ?? true;
|
||||
_showSarMarkers = prefs.getBool(_scopedKey(_showSarMarkersKey)) ?? true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _savePreferences() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(
|
||||
_scopedKey(_showReceivedDrawingsKey),
|
||||
_showReceivedDrawings,
|
||||
);
|
||||
await prefs.setBool(_scopedKey(_showSarMarkersKey), _showSarMarkers);
|
||||
}
|
||||
|
||||
/// 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),
|
||||
coordinateSpace: _activeCoordinateSpace,
|
||||
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||
? _activeMapId
|
||||
: null,
|
||||
);
|
||||
|
||||
_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,
|
||||
),
|
||||
coordinateSpace: _activeCoordinateSpace,
|
||||
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||
? _activeMapId
|
||||
: null,
|
||||
);
|
||||
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,
|
||||
coordinateSpace: _activeCoordinateSpace,
|
||||
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||
? _activeMapId
|
||||
: null,
|
||||
);
|
||||
|
||||
_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) {
|
||||
if (_activeCoordinateSpace == MapCoordinateSpace.customMap &&
|
||||
_activeMetersPerPixel != null) {
|
||||
final dy = point2.latitude - point1.latitude;
|
||||
final dx = point2.longitude - point1.longitude;
|
||||
final pixelDistance = math.sqrt((dx * dx) + (dy * dy));
|
||||
return pixelDistance * _activeMetersPerPixel!;
|
||||
}
|
||||
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(_scopedKey(_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(_scopedKey(_storageKey));
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
_drawings.clear();
|
||||
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,
|
||||
coordinateSpace: _activeCoordinateSpace,
|
||||
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||
? _activeMapId
|
||||
: null,
|
||||
);
|
||||
} 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,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
} 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,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
}
|
||||
return drawing;
|
||||
}
|
||||
|
||||
/// Get a drawing by its ID
|
||||
MapDrawing? getDrawingById(String id) {
|
||||
try {
|
||||
return _drawings.firstWhere((d) => d.id == id);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> exportDrawingsJson() {
|
||||
return _drawings.map((drawing) => drawing.toJson()).toList();
|
||||
}
|
||||
|
||||
Future<void> replaceDrawingsFromJson(
|
||||
List<Map<String, dynamic>> jsonList,
|
||||
) async {
|
||||
_drawings
|
||||
..clear()
|
||||
..addAll(jsonList.map(MapDrawing.fromJson).whereType<MapDrawing>());
|
||||
await _saveDrawings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String _scopedKey(String baseKey) {
|
||||
return ProfileStorageScope.scopedKey(baseKey);
|
||||
}
|
||||
|
||||
/// 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,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
} 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,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
}
|
||||
|
||||
_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,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
} 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,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
43
lib/providers/helpers/fragment_ack_wait_registry.dart
Normal file
43
lib/providers/helpers/fragment_ack_wait_registry.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Tracks one or more in-flight waiters for the same fragment ACK key.
|
||||
///
|
||||
/// Duplicate fetch requests can race and wait on the same fragment ACK at once.
|
||||
/// Completing all registered waiters avoids losing the earlier completer when a
|
||||
/// later request registers for the same key.
|
||||
class FragmentAckWaitRegistry {
|
||||
final Map<String, List<Completer<void>>> _waiters = {};
|
||||
|
||||
Future<bool> waitFor(
|
||||
String key, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
final completer = Completer<void>();
|
||||
final waiters = _waiters.putIfAbsent(key, () => <Completer<void>>[]);
|
||||
waiters.add(completer);
|
||||
try {
|
||||
await completer.future.timeout(timeout);
|
||||
return true;
|
||||
} catch (_) {
|
||||
final pending = _waiters[key];
|
||||
pending?.remove(completer);
|
||||
if (pending != null && pending.isEmpty) {
|
||||
_waiters.remove(key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
int complete(String key) {
|
||||
final waiters = _waiters.remove(key);
|
||||
if (waiters == null || waiters.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
for (final completer in waiters) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
return waiters.length;
|
||||
}
|
||||
}
|
||||
234
lib/providers/helpers/message_delivery_tracker.dart
Normal file
234
lib/providers/helpers/message_delivery_tracker.dart
Normal file
@@ -0,0 +1,234 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// 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 = [];
|
||||
|
||||
/// Contact-scoped FIFOs for matching direct-message SENT responses.
|
||||
final Map<String, List<String>> _pendingMessageIdsByContact = {};
|
||||
|
||||
/// 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 = {};
|
||||
|
||||
/// Completer signalled when a pending ACK slot is freed (delivery or removal).
|
||||
Completer<void>? _slotFreedCompleter;
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// Track a pending direct message ID for a specific contact.
|
||||
void trackPendingDirectMessage(String messageId, Uint8List contactPublicKey) {
|
||||
trackPendingMessage(messageId);
|
||||
final contactKey = _contactKey(contactPublicKey);
|
||||
_pendingMessageIdsByContact
|
||||
.putIfAbsent(contactKey, () => [])
|
||||
.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);
|
||||
}
|
||||
|
||||
/// Pop the next pending direct message ID for a specific contact.
|
||||
///
|
||||
/// Falls back to the legacy global FIFO if the contact queue is empty.
|
||||
String? popPendingDirectMessageId(Uint8List contactPublicKey) {
|
||||
final contactKey = _contactKey(contactPublicKey);
|
||||
final queue = _pendingMessageIdsByContact[contactKey];
|
||||
if (queue == null || queue.isEmpty) {
|
||||
return popPendingMessageId();
|
||||
}
|
||||
|
||||
final messageId = queue.removeAt(0);
|
||||
if (queue.isEmpty) {
|
||||
_pendingMessageIdsByContact.remove(contactKey);
|
||||
}
|
||||
_pendingMessageIds.remove(messageId);
|
||||
return messageId;
|
||||
}
|
||||
|
||||
/// 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];
|
||||
}
|
||||
|
||||
/// Returns true once a message has been matched to a concrete ACK tag.
|
||||
bool hasAckForMessage(String messageId) {
|
||||
return _messageIdToAckTag.containsKey(messageId);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
_notifySlotFreed();
|
||||
}
|
||||
|
||||
/// 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);
|
||||
_notifySlotFreed();
|
||||
}
|
||||
_pendingMessageIds.remove(messageId);
|
||||
final emptyKeys = <String>[];
|
||||
for (final entry in _pendingMessageIdsByContact.entries) {
|
||||
entry.value.remove(messageId);
|
||||
if (entry.value.isEmpty) {
|
||||
emptyKeys.add(entry.key);
|
||||
}
|
||||
}
|
||||
for (final key in emptyKeys) {
|
||||
_pendingMessageIdsByContact.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
_pendingMessageIdsByContact.clear();
|
||||
_ackTagToMessageId.clear();
|
||||
_messageIdToAckTag.clear();
|
||||
_ackTagTimestamps.clear();
|
||||
_notifySlotFreed();
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Wait until a pending ACK slot is freed, or [timeout] elapses.
|
||||
///
|
||||
/// Returns immediately if not at the rate limit.
|
||||
Future<void> waitForSlot({
|
||||
Duration timeout = const Duration(milliseconds: 500),
|
||||
}) async {
|
||||
if (!shouldRateLimit) return;
|
||||
_slotFreedCompleter ??= Completer<void>();
|
||||
await _slotFreedCompleter!.future.timeout(
|
||||
timeout,
|
||||
onTimeout: () {},
|
||||
);
|
||||
}
|
||||
|
||||
void _notifySlotFreed() {
|
||||
if (_slotFreedCompleter != null && !_slotFreedCompleter!.isCompleted) {
|
||||
_slotFreedCompleter!.complete();
|
||||
}
|
||||
_slotFreedCompleter = null;
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
'pendingByContact': _pendingMessageIdsByContact.map(
|
||||
(key, value) => MapEntry(key, value.length),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
String _contactKey(Uint8List contactPublicKey) {
|
||||
return contactPublicKey
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
}
|
||||
}
|
||||
178
lib/providers/helpers/message_retry_manager.dart
Normal file
178
lib/providers/helpers/message_retry_manager.dart
Normal file
@@ -0,0 +1,178 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import '../../models/message.dart';
|
||||
import '../../models/contact.dart';
|
||||
|
||||
/// Manages message retry state and logic
|
||||
///
|
||||
/// This helper class centralizes retry logic for direct messages.
|
||||
///
|
||||
/// 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 retry delays (1s, 2s, 4s, 8s) are app-level backoff timers
|
||||
/// - 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 = {};
|
||||
final Map<String, int> _pathFailureStreaks = {};
|
||||
|
||||
/// Max retry attempts when the contact has a known path.
|
||||
/// Sequence: 2 direct attempts, flood, then last successful route.
|
||||
static const int maxRetryAttemptsWithPath = 3;
|
||||
|
||||
/// No retries for flood-only contacts (no known path).
|
||||
/// Value 0 means: don't retry at all, go straight to fallback/fail.
|
||||
static const int maxRetryAttemptsFloodOnly = 0;
|
||||
|
||||
@Deprecated('Use maxRetryAttemptsForContact instead')
|
||||
static const int maxRetryAttempts = maxRetryAttemptsWithPath;
|
||||
|
||||
// Retry backoff values in milliseconds.
|
||||
static const List<int> _retryDelays = [1000, 2000, 4000, 8000, 8000];
|
||||
static const int _defaultLoRaSf = 10;
|
||||
static const int _defaultLoRaCr = 5;
|
||||
static const int _defaultLoRaBwHz = 250000;
|
||||
static const int _defaultLoRaPreambleSymbols = 8;
|
||||
static const int _defaultLoRaCrcEnabled = 1;
|
||||
static const int _defaultLoRaExplicitHeader = 1;
|
||||
|
||||
/// Get backoff delay for the next retry attempt.
|
||||
int getDelayForAttempt(int attempt) {
|
||||
if (attempt < 0 || attempt >= _retryDelays.length) {
|
||||
return _retryDelays.last;
|
||||
}
|
||||
return _retryDelays[attempt];
|
||||
}
|
||||
|
||||
static final math.Random _rng = math.Random();
|
||||
|
||||
/// Calculate a delivery-ACK timeout with random jitter.
|
||||
///
|
||||
/// Matches the official MeshCore app: `suggestedTimeout + random(1-8s)`.
|
||||
/// The jitter prevents collision when multiple messages are in flight.
|
||||
int calculateAckTimeoutMs({
|
||||
required String text,
|
||||
required Contact? contact,
|
||||
int? suggestedTimeoutMs,
|
||||
}) {
|
||||
int baseTimeout;
|
||||
if (suggestedTimeoutMs != null && suggestedTimeoutMs > 0) {
|
||||
baseTimeout = suggestedTimeoutMs;
|
||||
} else {
|
||||
final payloadBytes = utf8.encode(text).length;
|
||||
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
|
||||
final hopCount = contact?.routeHasPath == true
|
||||
? contact!.routeHopCount
|
||||
: -1;
|
||||
|
||||
if (hopCount < 0) {
|
||||
baseTimeout = ((airtimeMs * 10) + 4000).clamp(10000, 30000);
|
||||
} else {
|
||||
baseTimeout = ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
|
||||
}
|
||||
}
|
||||
|
||||
// Add random jitter: 500-2000ms to avoid collision
|
||||
final jitterMs = 500 + _rng.nextInt(1501);
|
||||
return baseTimeout + jitterMs;
|
||||
}
|
||||
|
||||
/// Max attempts for a given contact based on whether it has a known path.
|
||||
static int maxRetryAttemptsForContact(Contact? contact) {
|
||||
final hasPath = contact?.routeHasPath ?? false;
|
||||
return hasPath ? maxRetryAttemptsWithPath : maxRetryAttemptsFloodOnly;
|
||||
}
|
||||
|
||||
bool canRetry(Message message, Contact contact) {
|
||||
return message.retryAttempt < maxRetryAttemptsForContact(contact);
|
||||
}
|
||||
|
||||
/// Whether this is the last retry attempt.
|
||||
/// When true, the caller should reset the path to force flood mode.
|
||||
///
|
||||
/// Note: called AFTER retryAttempt has been incremented, so we compare
|
||||
/// directly against maxAttempts (not +1).
|
||||
bool isLastAttempt(Message message, Contact contact) {
|
||||
final maxAttempts = maxRetryAttemptsForContact(contact);
|
||||
return maxAttempts > 1 && message.retryAttempt >= maxAttempts;
|
||||
}
|
||||
|
||||
/// 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();
|
||||
_pathFailureStreaks.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];
|
||||
}
|
||||
|
||||
/// Record a successful delivery for a contact and clear any accumulated
|
||||
/// route failure streak for future sends.
|
||||
void recordDeliverySuccess(Contact contact) {
|
||||
_pathFailureStreaks.remove(contact.publicKeyHex);
|
||||
}
|
||||
|
||||
/// Record a permanent route failure for a contact.
|
||||
///
|
||||
/// Returns the updated failure streak so callers can decide when to reset
|
||||
/// the learned path on the radio and in local state.
|
||||
int recordPathFailure(Contact contact) {
|
||||
final contactKey = contact.publicKeyHex;
|
||||
final next = (_pathFailureStreaks[contactKey] ?? 0) + 1;
|
||||
_pathFailureStreaks[contactKey] = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
int? getPathFailureStreak(Contact contact) {
|
||||
return _pathFailureStreaks[contact.publicKeyHex];
|
||||
}
|
||||
|
||||
int _estimateLoRaAirtimeMs(int payloadLenBytes) {
|
||||
final sf = _defaultLoRaSf;
|
||||
final bw = _defaultLoRaBwHz.toDouble();
|
||||
final cr = (_defaultLoRaCr - 4).clamp(1, 4);
|
||||
final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1;
|
||||
final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0;
|
||||
|
||||
final symbolMs = ((1 << sf) / bw) * 1000.0;
|
||||
final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs;
|
||||
|
||||
final num =
|
||||
(8 * payloadLenBytes) -
|
||||
(4 * sf) +
|
||||
28 +
|
||||
(16 * _defaultLoRaCrcEnabled) -
|
||||
(20 * ih);
|
||||
final den = 4 * (sf - (2 * de));
|
||||
final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil();
|
||||
final payloadSymbols =
|
||||
8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4);
|
||||
final payloadMs = payloadSymbols * symbolMs;
|
||||
|
||||
return (preambleMs + payloadMs).ceil();
|
||||
}
|
||||
}
|
||||
120
lib/providers/helpers/ping_tracker.dart
Normal file
120
lib/providers/helpers/ping_tracker.dart
Normal file
@@ -0,0 +1,120 @@
|
||||
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 requestKey = _findMatchingPendingPingKey(publicKey);
|
||||
final request = requestKey != null ? _pendingPings.remove(requestKey) : null;
|
||||
|
||||
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('');
|
||||
}
|
||||
|
||||
String? _findMatchingPendingPingKey(Uint8List responseKey) {
|
||||
final responseHex = _publicKeyToHex(responseKey);
|
||||
|
||||
if (_pendingPings.containsKey(responseHex)) {
|
||||
return responseHex;
|
||||
}
|
||||
|
||||
for (final entry in _pendingPings.entries) {
|
||||
final pendingHex = entry.key;
|
||||
if (pendingHex.startsWith(responseHex) || responseHex.startsWith(pendingHex)) {
|
||||
return pendingHex;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
90
lib/providers/helpers/raw_session_retransmit.dart
Normal file
90
lib/providers/helpers/raw_session_retransmit.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../models/contact.dart';
|
||||
|
||||
typedef RawPacketSender =
|
||||
Future<void> Function({
|
||||
required Uint8List contactPath,
|
||||
required int contactPathLen,
|
||||
required Uint8List payload,
|
||||
});
|
||||
|
||||
/// Pacing between consecutive raw fragments. Firmware v1.15 rejects bursts of
|
||||
/// raw packets with `ERROR: Table full` when fragments are blasted too quickly,
|
||||
/// so we space them out to keep the radio's raw transmit queue from overflowing.
|
||||
const Duration _interFragmentDelay = Duration(milliseconds: 350);
|
||||
|
||||
Future<bool> serveCachedSessionFragments<T>({
|
||||
required String providerLabel,
|
||||
required String sessionId,
|
||||
required Contact requester,
|
||||
required List<T> fragments,
|
||||
required int maxDirectPayloadHops,
|
||||
required int Function(T fragment) indexOf,
|
||||
required Uint8List Function(T fragment) encodeBinary,
|
||||
required RawPacketSender? sendRawPacket,
|
||||
Set<int>? requestedIndices,
|
||||
Duration interFragmentDelay = _interFragmentDelay,
|
||||
}) async {
|
||||
if (fragments.isEmpty) {
|
||||
debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId');
|
||||
return false;
|
||||
}
|
||||
if (sendRawPacket == null) {
|
||||
debugPrint('⚠️ [$providerLabel] sendRawPacketCallback not set');
|
||||
return false;
|
||||
}
|
||||
if (!requester.routeHasPath) {
|
||||
debugPrint('⚠️ [$providerLabel] ${requester.advName} has no direct path');
|
||||
return false;
|
||||
}
|
||||
if (requester.routeHopCount > maxDirectPayloadHops) {
|
||||
debugPrint(
|
||||
'⚠️ [$providerLabel] ${requester.advName} is too far: ${requester.routeHopCount} hops (max $maxDirectPayloadHops)',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (requester.outPath.isEmpty) {
|
||||
debugPrint(
|
||||
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
var servedCount = 0;
|
||||
for (final fragment in fragments) {
|
||||
final index = indexOf(fragment);
|
||||
if (index < 0) {
|
||||
debugPrint('⚠️ [$providerLabel] Invalid fragment index $index');
|
||||
continue;
|
||||
}
|
||||
if (requestedIndices != null && !requestedIndices.contains(index)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (servedCount > 0 && interFragmentDelay > Duration.zero) {
|
||||
await Future<void>.delayed(interFragmentDelay);
|
||||
}
|
||||
await sendRawPacket(
|
||||
contactPath: requester.outPath,
|
||||
contactPathLen: requester.routeEncodedPathLen,
|
||||
payload: encodeBinary(fragment),
|
||||
);
|
||||
servedCount++;
|
||||
} catch (e, st) {
|
||||
debugPrint(
|
||||
'❌ [$providerLabel] Serve error for $sessionId#$index: $e\n$st',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (servedCount == 0) {
|
||||
debugPrint(
|
||||
'⚠️ [$providerLabel] No fragments matched request for $sessionId',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
debugPrint('✅ [$providerLabel] Served $servedCount fragments for $sessionId');
|
||||
return true;
|
||||
}
|
||||
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(':');
|
||||
}
|
||||
}
|
||||
57
lib/providers/helpers/session_metadata_restore.dart
Normal file
57
lib/providers/helpers/session_metadata_restore.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import '../../models/message.dart';
|
||||
import '../../utils/image_message_parser.dart';
|
||||
import '../../utils/voice_message_parser.dart';
|
||||
|
||||
class RestoredSessionMetadata {
|
||||
final Map<String, String> voiceSenderKeyBySession;
|
||||
final Map<String, String> imageSenderKeyBySession;
|
||||
final Map<String, ImageEnvelope> imageEnvelopeBySession;
|
||||
|
||||
const RestoredSessionMetadata({
|
||||
required this.voiceSenderKeyBySession,
|
||||
required this.imageSenderKeyBySession,
|
||||
required this.imageEnvelopeBySession,
|
||||
});
|
||||
}
|
||||
|
||||
RestoredSessionMetadata restoreSessionMetadataFromMessages(
|
||||
Iterable<Message> messages,
|
||||
) {
|
||||
final voiceSenderKeyBySession = <String, String>{};
|
||||
final imageSenderKeyBySession = <String, String>{};
|
||||
final imageEnvelopeBySession = <String, ImageEnvelope>{};
|
||||
|
||||
for (final message in messages) {
|
||||
final text = message.text;
|
||||
final voiceEnvelope = VoiceEnvelope.tryParseText(text);
|
||||
if (voiceEnvelope != null) {
|
||||
final senderPrefix = message.senderPublicKeyPrefix;
|
||||
if (senderPrefix != null && senderPrefix.length >= 6) {
|
||||
voiceSenderKeyBySession[voiceEnvelope.sessionId] = senderPrefix
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
final imageEnvelope = ImageEnvelope.tryParse(text);
|
||||
if (imageEnvelope != null) {
|
||||
imageEnvelopeBySession[imageEnvelope.sessionId] = imageEnvelope;
|
||||
final senderPrefix = message.senderPublicKeyPrefix;
|
||||
if (senderPrefix != null && senderPrefix.length >= 6) {
|
||||
imageSenderKeyBySession[imageEnvelope.sessionId] = senderPrefix
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return RestoredSessionMetadata(
|
||||
voiceSenderKeyBySession: voiceSenderKeyBySession,
|
||||
imageSenderKeyBySession: imageSenderKeyBySession,
|
||||
imageEnvelopeBySession: imageEnvelopeBySession,
|
||||
);
|
||||
}
|
||||
458
lib/providers/image_provider.dart
Normal file
458
lib/providers/image_provider.dart
Normal file
@@ -0,0 +1,458 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../services/profiles_feature_service.dart';
|
||||
import 'helpers/raw_session_retransmit.dart';
|
||||
import '../utils/image_message_parser.dart';
|
||||
|
||||
/// Reassembly state for one incoming image session.
|
||||
class ImageSession {
|
||||
final String sessionId;
|
||||
final ImageFormat format;
|
||||
final int total;
|
||||
final int width;
|
||||
final int height;
|
||||
final List<ImagePacket?> fragments; // indexed by fragment.index
|
||||
DateTime? firstFragmentAt;
|
||||
DateTime? lastFragmentAt;
|
||||
|
||||
ImageSession({
|
||||
required this.sessionId,
|
||||
required this.format,
|
||||
required this.total,
|
||||
required this.width,
|
||||
required this.height,
|
||||
}) : fragments = List.filled(total, null);
|
||||
|
||||
int get receivedCount => fragments.where((f) => f != null).length;
|
||||
bool get isComplete => receivedCount == total;
|
||||
|
||||
Duration? estimateRemaining() {
|
||||
if (isComplete) return Duration.zero;
|
||||
if (firstFragmentAt == null || lastFragmentAt == null) return null;
|
||||
if (receivedCount < 2) return null;
|
||||
|
||||
final elapsedMs = lastFragmentAt!
|
||||
.difference(firstFragmentAt!)
|
||||
.inMilliseconds;
|
||||
if (elapsedMs <= 0) return null;
|
||||
final avgMsPerFragment = elapsedMs / (receivedCount - 1);
|
||||
final remaining = total - receivedCount;
|
||||
if (remaining <= 0) return Duration.zero;
|
||||
return Duration(milliseconds: (avgMsPerFragment * remaining).round());
|
||||
}
|
||||
|
||||
/// Reassemble the complete image bytes, or null if any fragment is missing.
|
||||
Uint8List? get imageBytes => reassembleImage(fragments);
|
||||
}
|
||||
|
||||
/// Manages incoming image sessions and outgoing image caches.
|
||||
///
|
||||
/// Mirrors [VoiceProvider] in architecture: on-demand fetch, deferred serving,
|
||||
/// persistent storage of both incoming and outgoing session data.
|
||||
class ImageProvider with ChangeNotifier {
|
||||
static const String _storageKey = 'stored_image_sessions_v1';
|
||||
static const Duration _outgoingTtl = Duration(minutes: 15);
|
||||
static const int maxDirectPayloadHops = 3;
|
||||
|
||||
/// Incoming sessions keyed by sessionId.
|
||||
final Map<String, ImageSession> _sessions = {};
|
||||
final Set<String> _ignoredIncomingSessions = {};
|
||||
|
||||
/// Outgoing sessions cached for deferred serving.
|
||||
final Map<String, _OutgoingSession> _outgoing = {};
|
||||
|
||||
/// Hook for sending a raw binary payload to a contact.
|
||||
Future<void> Function({
|
||||
required Uint8List contactPath,
|
||||
required int contactPathLen,
|
||||
required Uint8List payload,
|
||||
})?
|
||||
sendRawPacketCallback;
|
||||
|
||||
ImageProvider() {
|
||||
_restore();
|
||||
}
|
||||
|
||||
String _scopedStorageKey() => ProfileStorageScope.scopedKey(_storageKey);
|
||||
|
||||
// ── Accessors ────────────────────────────────────────────────────────────
|
||||
|
||||
ImageSession? session(String sessionId) => _sessions[sessionId];
|
||||
bool isComplete(String sessionId) =>
|
||||
_sessions[sessionId]?.isComplete ?? false;
|
||||
bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId);
|
||||
Duration? estimateRemainingTransferTime(String sessionId) =>
|
||||
_sessions[sessionId]?.estimateRemaining();
|
||||
bool isReceiveCanceled(String sessionId) =>
|
||||
_ignoredIncomingSessions.contains(sessionId);
|
||||
|
||||
List<int> missingFragmentIndices(String sessionId) {
|
||||
final session = _sessions[sessionId];
|
||||
if (session == null) return const [];
|
||||
final missing = <int>[];
|
||||
for (var i = 0; i < session.total; i++) {
|
||||
if (session.fragments[i] == null) missing.add(i);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
List<int> availableFragmentIndices(String sessionId) {
|
||||
final outgoing = _outgoing[sessionId];
|
||||
if (outgoing != null) {
|
||||
return outgoing.fragments.map((fragment) => fragment.index).toList()
|
||||
..sort();
|
||||
}
|
||||
|
||||
final session = _sessions[sessionId];
|
||||
if (session == null) return const [];
|
||||
final indices = <int>[];
|
||||
for (var i = 0; i < session.fragments.length; i++) {
|
||||
if (session.fragments[i] != null) {
|
||||
indices.add(i);
|
||||
}
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
// ── Incoming fragment reception ──────────────────────────────────────────
|
||||
|
||||
/// Add a received [fragment]. New compact fragments rely on prior envelope
|
||||
/// metadata for total/format, while legacy fragments can still self-describe.
|
||||
///
|
||||
/// Returns true when the session just became complete.
|
||||
bool addFragment(ImagePacket fragment, {int width = 0, int height = 0}) {
|
||||
if (_ignoredIncomingSessions.contains(fragment.sessionId)) {
|
||||
debugPrint(
|
||||
'⏹️ [ImageProvider] Ignoring canceled incoming session ${fragment.sessionId}',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
_sessions.putIfAbsent(fragment.sessionId, () {
|
||||
if (fragment.total < 1) {
|
||||
throw StateError(
|
||||
'Image envelope missing for compact fragment ${fragment.sessionId}',
|
||||
);
|
||||
}
|
||||
return ImageSession(
|
||||
sessionId: fragment.sessionId,
|
||||
format: fragment.format,
|
||||
total: fragment.total,
|
||||
width: width,
|
||||
height: height,
|
||||
);
|
||||
});
|
||||
|
||||
final session = _sessions[fragment.sessionId]!;
|
||||
if (fragment.index < session.total) {
|
||||
final wasMissing = session.fragments[fragment.index] == null;
|
||||
session.fragments[fragment.index] = fragment;
|
||||
if (wasMissing) {
|
||||
final now = DateTime.now();
|
||||
session.firstFragmentAt ??= now;
|
||||
session.lastFragmentAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
final justComplete = session.isComplete;
|
||||
unawaited(_persist());
|
||||
notifyListeners();
|
||||
return justComplete;
|
||||
}
|
||||
|
||||
void cancelIncomingSession(String sessionId) {
|
||||
_ignoredIncomingSessions.add(sessionId);
|
||||
_sessions.remove(sessionId);
|
||||
unawaited(_persist());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void resumeIncomingSession(String sessionId) {
|
||||
if (_ignoredIncomingSessions.remove(sessionId)) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Register envelope metadata for a session (called when IE1 is received
|
||||
/// before any binary fragments arrive).
|
||||
void registerEnvelope(ImageEnvelope envelope) {
|
||||
if (_ignoredIncomingSessions.contains(envelope.sessionId)) {
|
||||
return;
|
||||
}
|
||||
final existing = _sessions[envelope.sessionId];
|
||||
if (existing == null) {
|
||||
_sessions[envelope.sessionId] = ImageSession(
|
||||
sessionId: envelope.sessionId,
|
||||
format: envelope.format,
|
||||
total: envelope.total,
|
||||
width: envelope.width,
|
||||
height: envelope.height,
|
||||
);
|
||||
unawaited(_persist());
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final needsMerge =
|
||||
existing.width == 0 ||
|
||||
existing.height == 0 ||
|
||||
existing.total != envelope.total ||
|
||||
existing.format != envelope.format;
|
||||
if (!needsMerge) {
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final merged = ImageSession(
|
||||
sessionId: envelope.sessionId,
|
||||
format: envelope.format,
|
||||
total: envelope.total,
|
||||
width: envelope.width,
|
||||
height: envelope.height,
|
||||
);
|
||||
merged.firstFragmentAt = existing.firstFragmentAt;
|
||||
merged.lastFragmentAt = existing.lastFragmentAt;
|
||||
for (final fragment in existing.fragments) {
|
||||
if (fragment == null) continue;
|
||||
if (fragment.index < merged.total) {
|
||||
merged.fragments[fragment.index] = fragment;
|
||||
}
|
||||
}
|
||||
_sessions[envelope.sessionId] = merged;
|
||||
unawaited(_persist());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ── Outgoing session management ──────────────────────────────────────────
|
||||
|
||||
/// Cache encoded fragments for deferred serving.
|
||||
///
|
||||
/// Also registers the session as complete in [_sessions] so the local
|
||||
/// bubble can display the sent image immediately without a fetch round-trip.
|
||||
void cacheOutgoingSession(
|
||||
String sessionId,
|
||||
List<ImagePacket> fragments,
|
||||
ImageEnvelope envelope,
|
||||
) {
|
||||
if (fragments.isEmpty) return;
|
||||
_evictExpiredOutgoing();
|
||||
_outgoing[sessionId] = _OutgoingSession(
|
||||
sessionId: sessionId,
|
||||
fragments: List<ImagePacket>.from(fragments),
|
||||
envelope: envelope,
|
||||
cachedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
// Populate incoming session so the bubble shows the image right away.
|
||||
final session = ImageSession(
|
||||
sessionId: sessionId,
|
||||
format: envelope.format,
|
||||
total: fragments.length,
|
||||
width: envelope.width,
|
||||
height: envelope.height,
|
||||
);
|
||||
for (final f in fragments) {
|
||||
if (f.index < session.total) session.fragments[f.index] = f;
|
||||
}
|
||||
_sessions[sessionId] = session;
|
||||
|
||||
unawaited(_persist());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Stream cached image fragments to [requester] via raw binary packets.
|
||||
Future<bool> serveSessionTo({
|
||||
required String sessionId,
|
||||
required Contact requester,
|
||||
Set<int>? requestedIndices,
|
||||
}) async {
|
||||
final outgoing = _outgoing[sessionId];
|
||||
final fragments = outgoing != null
|
||||
? List<ImagePacket>.from(outgoing.fragments)
|
||||
: _sessions[sessionId]?.fragments.whereType<ImagePacket>().toList() ??
|
||||
const <ImagePacket>[];
|
||||
if (fragments.isEmpty) {
|
||||
debugPrint(
|
||||
'⚠️ [ImageProvider] No cached or received session for $sessionId',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return serveCachedSessionFragments<ImagePacket>(
|
||||
providerLabel: 'ImageProvider',
|
||||
sessionId: sessionId,
|
||||
requester: requester,
|
||||
fragments: fragments,
|
||||
maxDirectPayloadHops: maxDirectPayloadHops,
|
||||
indexOf: (fragment) => fragment.index,
|
||||
encodeBinary: (fragment) => fragment.encodeBinary(),
|
||||
sendRawPacket: sendRawPacketCallback,
|
||||
requestedIndices: requestedIndices,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Persistence ──────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> clearAll() async {
|
||||
_resetInMemoryState();
|
||||
notifyListeners();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_scopedStorageKey());
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ImageProvider] Failed to clear storage: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reloadProfileScopedState() async {
|
||||
_resetInMemoryState();
|
||||
await _restore();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _evictExpiredOutgoing() {
|
||||
final now = DateTime.now();
|
||||
_outgoing.removeWhere((_, s) => now.difference(s.cachedAt) > _outgoingTtl);
|
||||
}
|
||||
|
||||
Future<void> _persist() async {
|
||||
try {
|
||||
_evictExpiredOutgoing();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final payload = <String, dynamic>{
|
||||
'incoming': _sessions.values
|
||||
.map(
|
||||
(s) => {
|
||||
'sessionId': s.sessionId,
|
||||
'fmtId': s.format.id,
|
||||
'total': s.total,
|
||||
'width': s.width,
|
||||
'height': s.height,
|
||||
'fragments': s.fragments
|
||||
.map(
|
||||
(f) => f == null ? null : base64.encode(f.encodeBinary()),
|
||||
)
|
||||
.toList(),
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'outgoing': _outgoing.values
|
||||
.map(
|
||||
(s) => {
|
||||
'sessionId': s.sessionId,
|
||||
'cachedAt': s.cachedAt.millisecondsSinceEpoch,
|
||||
'envelope': s.envelope.encode(),
|
||||
'fragments': s.fragments
|
||||
.map((f) => base64.encode(f.encodeBinary()))
|
||||
.toList(),
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
};
|
||||
await prefs.setString(_scopedStorageKey(), jsonEncode(payload));
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ImageProvider] Failed to persist: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _restore() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_scopedStorageKey());
|
||||
if (raw == null || raw.isEmpty) return;
|
||||
|
||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
|
||||
for (final item in (parsed['incoming'] as List<dynamic>? ?? [])) {
|
||||
final map = item as Map<String, dynamic>;
|
||||
final sessionId = map['sessionId'] as String?;
|
||||
final fmtId = map['fmtId'] as int?;
|
||||
final total = map['total'] as int?;
|
||||
final width = map['width'] as int? ?? 256;
|
||||
final height = map['height'] as int? ?? 256;
|
||||
if (sessionId == null || fmtId == null || total == null || total <= 0) {
|
||||
continue;
|
||||
}
|
||||
final session = ImageSession(
|
||||
sessionId: sessionId,
|
||||
format: ImageFormat.fromId(fmtId),
|
||||
total: total,
|
||||
width: width,
|
||||
height: height,
|
||||
);
|
||||
final frags = map['fragments'] as List<dynamic>? ?? [];
|
||||
for (var i = 0; i < frags.length && i < total; i++) {
|
||||
final enc = frags[i] as String?;
|
||||
if (enc == null || enc.isEmpty) continue;
|
||||
final pkt = ImagePacket.tryParseBinary(base64.decode(enc));
|
||||
if (pkt != null && pkt.index < total) {
|
||||
session.fragments[pkt.index] = pkt;
|
||||
}
|
||||
}
|
||||
_sessions[sessionId] = session;
|
||||
}
|
||||
|
||||
for (final item in (parsed['outgoing'] as List<dynamic>? ?? [])) {
|
||||
final map = item as Map<String, dynamic>;
|
||||
final sessionId = map['sessionId'] as String?;
|
||||
final cachedMs = map['cachedAt'] as int?;
|
||||
final envelopeText = map['envelope'] as String?;
|
||||
if (sessionId == null || cachedMs == null || envelopeText == null) {
|
||||
continue;
|
||||
}
|
||||
final envelope = ImageEnvelope.tryParse(envelopeText);
|
||||
if (envelope == null) continue;
|
||||
final cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedMs);
|
||||
if (DateTime.now().difference(cachedAt) > _outgoingTtl) continue;
|
||||
|
||||
final fragsRaw = map['fragments'] as List<dynamic>? ?? [];
|
||||
final fragments = <ImagePacket>[];
|
||||
for (final enc in fragsRaw) {
|
||||
final pkt = ImagePacket.tryParseBinary(
|
||||
base64.decode((enc ?? '') as String),
|
||||
);
|
||||
if (pkt != null) fragments.add(pkt);
|
||||
}
|
||||
if (fragments.isNotEmpty) {
|
||||
_outgoing[sessionId] = _OutgoingSession(
|
||||
sessionId: sessionId,
|
||||
fragments: fragments,
|
||||
envelope: envelope,
|
||||
cachedAt: cachedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (_sessions.isNotEmpty || _outgoing.isNotEmpty) {
|
||||
debugPrint(
|
||||
'📷 [ImageProvider] Restored ${_sessions.length} incoming, '
|
||||
'${_outgoing.length} outgoing sessions',
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ImageProvider] Failed to restore: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _resetInMemoryState() {
|
||||
_sessions.clear();
|
||||
_outgoing.clear();
|
||||
_ignoredIncomingSessions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class _OutgoingSession {
|
||||
final String sessionId;
|
||||
final List<ImagePacket> fragments;
|
||||
final ImageEnvelope envelope;
|
||||
final DateTime cachedAt;
|
||||
|
||||
const _OutgoingSession({
|
||||
required this.sessionId,
|
||||
required this.fragments,
|
||||
required this.envelope,
|
||||
required this.cachedAt,
|
||||
});
|
||||
}
|
||||
782
lib/providers/map_provider.dart
Normal file
782
lib/providers/map_provider.dart
Normal file
@@ -0,0 +1,782 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/custom_map_config.dart';
|
||||
import '../models/location_trail.dart';
|
||||
import '../models/map_coordinate_space.dart';
|
||||
import '../models/map_drawing.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
import '../services/profiles_feature_service.dart';
|
||||
import '../utils/custom_map_id.dart';
|
||||
|
||||
class MapProvider with ChangeNotifier {
|
||||
static const String _customMapConfigKey = 'custom_map_config_v1';
|
||||
static const String _customMapModeKey = 'custom_map_mode_v1';
|
||||
static const String _customMapsDirName = 'custom_maps';
|
||||
|
||||
MapProvider() {
|
||||
unawaited(_loadInitialState());
|
||||
}
|
||||
|
||||
final ImagePicker _imagePicker = ImagePicker();
|
||||
|
||||
LatLng? _targetLocation;
|
||||
LatLngBounds? _targetBounds;
|
||||
double? _targetZoom;
|
||||
bool _shouldAnimate = false;
|
||||
MapCoordinateSpace _targetCoordinateSpace = MapCoordinateSpace.geo;
|
||||
String? _targetMapId;
|
||||
|
||||
LocationTrail? _currentTrail;
|
||||
bool _isTrailVisible = true;
|
||||
bool _isTrailRecordingEnabled = true;
|
||||
final List<LocationTrail> _trailHistory = [];
|
||||
|
||||
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;
|
||||
|
||||
bool _hideRepeatersOnMap = false;
|
||||
|
||||
LocationTrail? _importedTrail;
|
||||
|
||||
bool _isSelectingDownloadArea = false;
|
||||
LatLngBounds? _downloadAreaBounds;
|
||||
|
||||
CustomMapConfig? _customMapConfig;
|
||||
bool _isUsingCustomMap = false;
|
||||
|
||||
LatLng? get targetLocation => _targetLocation;
|
||||
LatLngBounds? get targetBounds => _targetBounds;
|
||||
double? get targetZoom => _targetZoom;
|
||||
bool get shouldAnimate => _shouldAnimate;
|
||||
MapCoordinateSpace get targetCoordinateSpace => _targetCoordinateSpace;
|
||||
String? get targetMapId => _targetMapId;
|
||||
LocationTrail? get currentTrail => _currentTrail;
|
||||
bool get isTrailVisible => _isTrailVisible;
|
||||
bool get isTrailRecordingEnabled => _isTrailRecordingEnabled;
|
||||
List<LocationTrail> get trailHistory => List.unmodifiable(_trailHistory);
|
||||
bool get isTrailActive => _currentTrail?.isActive ?? false;
|
||||
|
||||
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;
|
||||
|
||||
bool get hideRepeatersOnMap => _hideRepeatersOnMap;
|
||||
|
||||
LocationTrail? get importedTrail => _importedTrail;
|
||||
|
||||
bool get isSelectingDownloadArea => _isSelectingDownloadArea;
|
||||
LatLngBounds? get downloadAreaBounds => _downloadAreaBounds;
|
||||
|
||||
CustomMapConfig? get customMapConfig => _customMapConfig;
|
||||
bool get hasCustomMap => _customMapConfig != null;
|
||||
bool get isUsingCustomMap => _isUsingCustomMap && _customMapConfig != null;
|
||||
bool get shouldHideGpsData => isUsingCustomMap;
|
||||
|
||||
LatLngBounds? get customMapBounds => _customMapConfig?.bounds;
|
||||
|
||||
Future<void> reloadProfileScopedState() async {
|
||||
await _loadInitialState();
|
||||
}
|
||||
|
||||
bool matchesActiveCustomMap(String? mapId) {
|
||||
return hasCustomMap &&
|
||||
normalizeCustomMapId(_customMapConfig!.mapId) ==
|
||||
normalizeCustomMapId(mapId);
|
||||
}
|
||||
|
||||
void navigateToLocation({
|
||||
required LatLng location,
|
||||
double zoom = 15.0,
|
||||
bool animate = true,
|
||||
}) {
|
||||
if (_isUsingCustomMap) {
|
||||
_isUsingCustomMap = false;
|
||||
unawaited(_saveCustomMapState());
|
||||
}
|
||||
_targetLocation = location;
|
||||
_targetBounds = null;
|
||||
_targetZoom = zoom;
|
||||
_shouldAnimate = animate;
|
||||
_targetCoordinateSpace = MapCoordinateSpace.geo;
|
||||
_targetMapId = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String? navigateToMapPoint({
|
||||
required LatLng point,
|
||||
required MapCoordinateSpace coordinateSpace,
|
||||
String? mapId,
|
||||
double zoom = 15.0,
|
||||
bool animate = true,
|
||||
}) {
|
||||
if (coordinateSpace == MapCoordinateSpace.customMap) {
|
||||
if (!matchesActiveCustomMap(mapId)) {
|
||||
return 'Load the matching custom map to view this item.';
|
||||
}
|
||||
if (!_isUsingCustomMap) {
|
||||
_isUsingCustomMap = true;
|
||||
unawaited(_saveCustomMapState());
|
||||
}
|
||||
} else if (_isUsingCustomMap) {
|
||||
_isUsingCustomMap = false;
|
||||
unawaited(_saveCustomMapState());
|
||||
}
|
||||
|
||||
_targetLocation = point;
|
||||
_targetBounds = null;
|
||||
_targetZoom = zoom;
|
||||
_shouldAnimate = animate;
|
||||
_targetCoordinateSpace = coordinateSpace;
|
||||
_targetMapId = mapId;
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
|
||||
String? navigateToBounds({
|
||||
required LatLngBounds bounds,
|
||||
required MapCoordinateSpace coordinateSpace,
|
||||
String? mapId,
|
||||
bool animate = true,
|
||||
}) {
|
||||
if (coordinateSpace == MapCoordinateSpace.customMap) {
|
||||
if (!matchesActiveCustomMap(mapId)) {
|
||||
return 'Load the matching custom map to view this item.';
|
||||
}
|
||||
if (!_isUsingCustomMap) {
|
||||
_isUsingCustomMap = true;
|
||||
unawaited(_saveCustomMapState());
|
||||
}
|
||||
} else if (_isUsingCustomMap) {
|
||||
_isUsingCustomMap = false;
|
||||
unawaited(_saveCustomMapState());
|
||||
}
|
||||
|
||||
_targetBounds = bounds;
|
||||
_targetLocation = bounds.center;
|
||||
_targetZoom = null;
|
||||
_shouldAnimate = animate;
|
||||
_targetCoordinateSpace = coordinateSpace;
|
||||
_targetMapId = mapId;
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
|
||||
void clearNavigation() {
|
||||
_targetLocation = null;
|
||||
_targetBounds = null;
|
||||
_targetZoom = null;
|
||||
_shouldAnimate = false;
|
||||
_targetCoordinateSpace = MapCoordinateSpace.geo;
|
||||
_targetMapId = null;
|
||||
}
|
||||
|
||||
String? navigateToDrawing(String drawingId, dynamic drawingProvider) {
|
||||
final drawing = drawingProvider.getDrawingById(drawingId) as MapDrawing?;
|
||||
if (drawing == null) {
|
||||
return 'Drawing not found.';
|
||||
}
|
||||
|
||||
if (drawing.coordinateSpace == MapCoordinateSpace.customMap) {
|
||||
if (!matchesActiveCustomMap(drawing.mapId)) {
|
||||
return 'Load the matching custom map to view this drawing.';
|
||||
}
|
||||
return navigateToBounds(
|
||||
bounds: drawing.getBounds(),
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
}
|
||||
|
||||
final bounds = drawing.getBounds();
|
||||
final latDiff = (bounds.north - bounds.south).abs();
|
||||
final lonDiff = (bounds.east - bounds.west).abs();
|
||||
final maxDiff = latDiff > lonDiff ? latDiff : lonDiff;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
navigateToLocation(
|
||||
location: drawing.getCenter(),
|
||||
zoom: zoom,
|
||||
animate: true,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
String? navigateToSarMarker(SarMarker marker) {
|
||||
if (marker.coordinateSpace == MapCoordinateSpace.customMap) {
|
||||
return navigateToMapPoint(
|
||||
point: marker.location,
|
||||
coordinateSpace: MapCoordinateSpace.customMap,
|
||||
mapId: marker.mapId,
|
||||
);
|
||||
}
|
||||
navigateToLocation(location: marker.location, zoom: 15.0, animate: true);
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<bool> loadCustomMapFromGallery() async {
|
||||
final picked = await _imagePicker.pickImage(source: ImageSource.gallery);
|
||||
if (picked == null) {
|
||||
return false;
|
||||
}
|
||||
final bytes = await picked.readAsBytes();
|
||||
await setCustomMapImage(bytes: bytes, displayName: picked.name);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> replaceCustomMap() async {
|
||||
await loadCustomMapFromGallery();
|
||||
}
|
||||
|
||||
Future<void> setCustomMapImage({
|
||||
required Uint8List bytes,
|
||||
required String displayName,
|
||||
}) async {
|
||||
final dimensions = await _decodeImageSize(bytes);
|
||||
final mapId = normalizeCustomMapId(sha256.convert(bytes).toString())!;
|
||||
final documentsDir = await getApplicationDocumentsDirectory();
|
||||
final dir = Directory('${documentsDir.path}/$_customMapsDirName');
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
|
||||
final extension = _normalizedExtension(displayName);
|
||||
final nextPath = '${dir.path}/custom_map_$mapId.$extension';
|
||||
final nextFile = File(nextPath);
|
||||
await nextFile.writeAsBytes(bytes, flush: true);
|
||||
|
||||
final previousPath = _customMapConfig?.filePath;
|
||||
_customMapConfig = CustomMapConfig(
|
||||
filePath: nextPath,
|
||||
displayName: displayName,
|
||||
mapId: mapId,
|
||||
imageWidth: dimensions.$1,
|
||||
imageHeight: dimensions.$2,
|
||||
);
|
||||
_isUsingCustomMap = true;
|
||||
await _saveCustomMapState();
|
||||
|
||||
if (previousPath != null && previousPath != nextPath) {
|
||||
unawaited(_deleteFileIfExists(previousPath));
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setCustomMapCalibration({
|
||||
required LatLng pointA,
|
||||
required LatLng pointB,
|
||||
required double metersPerPixel,
|
||||
}) async {
|
||||
if (_customMapConfig == null) return;
|
||||
_customMapConfig = _customMapConfig!.copyWith(
|
||||
calibrationPointA: pointA,
|
||||
calibrationPointB: pointB,
|
||||
metersPerPixel: metersPerPixel,
|
||||
);
|
||||
await _saveCustomMapState();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> clearCustomMapCalibration() async {
|
||||
if (_customMapConfig == null) return;
|
||||
_customMapConfig = _customMapConfig!.copyWith(
|
||||
clearMetersPerPixel: true,
|
||||
clearCalibrationPointA: true,
|
||||
clearCalibrationPointB: true,
|
||||
);
|
||||
await _saveCustomMapState();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> removeCustomMap() async {
|
||||
final filePath = _customMapConfig?.filePath;
|
||||
_customMapConfig = null;
|
||||
_isUsingCustomMap = false;
|
||||
clearNavigation();
|
||||
await _saveCustomMapState();
|
||||
if (filePath != null) {
|
||||
await _deleteFileIfExists(filePath);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> enterCustomMapMode() async {
|
||||
if (!hasCustomMap || _isUsingCustomMap) return;
|
||||
_isUsingCustomMap = true;
|
||||
await _saveCustomMapState();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> exitCustomMapMode() async {
|
||||
if (!_isUsingCustomMap) return;
|
||||
_isUsingCustomMap = false;
|
||||
await _saveCustomMapState();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateZoom(double zoom) {
|
||||
_targetZoom = zoom;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void startTrail() {
|
||||
if (_currentTrail != null && _currentTrail!.isActive) {
|
||||
endTrail();
|
||||
}
|
||||
|
||||
_currentTrail = LocationTrail(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
startTime: DateTime.now(),
|
||||
);
|
||||
_isTrailVisible = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void addTrailPoint(LatLng position, {double? accuracy, double? speed}) {
|
||||
if (!_isTrailRecordingEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentTrail == null || !_currentTrail!.isActive) {
|
||||
startTrail();
|
||||
}
|
||||
|
||||
_currentTrail!.addPoint(
|
||||
TrailPoint(
|
||||
position: position,
|
||||
timestamp: DateTime.now(),
|
||||
accuracy: accuracy,
|
||||
speed: speed,
|
||||
),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void endTrail() {
|
||||
if (_currentTrail != null) {
|
||||
_currentTrail!.isActive = false;
|
||||
_currentTrail!.endTime = DateTime.now();
|
||||
if (_currentTrail!.points.isNotEmpty) {
|
||||
_trailHistory.add(_currentTrail!);
|
||||
}
|
||||
_currentTrail = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void toggleTrailVisibility() {
|
||||
_isTrailVisible = !_isTrailVisible;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setTrailRecordingEnabled(bool enabled) {
|
||||
if (_isTrailRecordingEnabled == enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isTrailRecordingEnabled = enabled;
|
||||
if (!enabled) {
|
||||
if (_currentTrail != null) {
|
||||
endTrail();
|
||||
} else {
|
||||
notifyListeners();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearCurrentTrail() {
|
||||
if (_currentTrail != null) {
|
||||
_currentTrail = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void clearAllTrails() {
|
||||
_currentTrail = null;
|
||||
_trailHistory.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
double get totalTrailDistance {
|
||||
if (_currentTrail == null) return 0;
|
||||
return _currentTrail!.totalDistance;
|
||||
}
|
||||
|
||||
Duration get trailDuration {
|
||||
if (_currentTrail == null) return Duration.zero;
|
||||
return _currentTrail!.duration;
|
||||
}
|
||||
|
||||
Future<void> toggleCadastralOverlay() async {
|
||||
_showCadastralOverlay = !_showCadastralOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
Future<void> toggleForestRoadsOverlay() async {
|
||||
_showForestRoadsOverlay = !_showForestRoadsOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
Future<void> toggleHikingTrailsOverlay() async {
|
||||
_showHikingTrailsOverlay = !_showHikingTrailsOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
Future<void> toggleMainRoadsOverlay() async {
|
||||
_showMainRoadsOverlay = !_showMainRoadsOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
Future<void> toggleHouseNumbersOverlay() async {
|
||||
_showHouseNumbersOverlay = !_showHouseNumbersOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
Future<void> toggleFireHazardZonesOverlay() async {
|
||||
_showFireHazardZonesOverlay = !_showFireHazardZonesOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
Future<void> toggleHistoricalFiresOverlay() async {
|
||||
_showHistoricalFiresOverlay = !_showHistoricalFiresOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
Future<void> toggleFirebreaksOverlay() async {
|
||||
_showFirebreaksOverlay = !_showFirebreaksOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
Future<void> toggleKrasFireZonesOverlay() async {
|
||||
_showKrasFireZonesOverlay = !_showKrasFireZonesOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
Future<void> togglePlaceNamesOverlay() async {
|
||||
_showPlaceNamesOverlay = !_showPlaceNamesOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
Future<void> toggleMunicipalityBordersOverlay() async {
|
||||
_showMunicipalityBordersOverlay = !_showMunicipalityBordersOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
Future<void> loadOverlayState() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_showCadastralOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_cadastral_overlay')) ?? false;
|
||||
_showForestRoadsOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_forest_roads_overlay')) ?? false;
|
||||
_showHikingTrailsOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_hiking_trails_overlay')) ?? false;
|
||||
_showMainRoadsOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_main_roads_overlay')) ?? false;
|
||||
_showHouseNumbersOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_house_numbers_overlay')) ?? false;
|
||||
_showFireHazardZonesOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_fire_hazard_zones_overlay')) ??
|
||||
false;
|
||||
_showHistoricalFiresOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_historical_fires_overlay')) ?? false;
|
||||
_showFirebreaksOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_firebreaks_overlay')) ?? false;
|
||||
_showKrasFireZonesOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_kras_fire_zones_overlay')) ?? false;
|
||||
_showPlaceNamesOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_place_names_overlay')) ?? false;
|
||||
_showMunicipalityBordersOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_municipality_borders_overlay')) ??
|
||||
false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _loadInitialState() async {
|
||||
await Future.wait([
|
||||
loadOverlayState(),
|
||||
loadRepeaterVisibilitySettings(),
|
||||
_loadCustomMapState(),
|
||||
_removeDeprecatedSettings(),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> _removeDeprecatedSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_scopedKey('map_show_all_contact_trails'));
|
||||
}
|
||||
|
||||
Future<void> _saveOverlayState() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_cadastral_overlay'),
|
||||
_showCadastralOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_forest_roads_overlay'),
|
||||
_showForestRoadsOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_hiking_trails_overlay'),
|
||||
_showHikingTrailsOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_main_roads_overlay'),
|
||||
_showMainRoadsOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_house_numbers_overlay'),
|
||||
_showHouseNumbersOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_fire_hazard_zones_overlay'),
|
||||
_showFireHazardZonesOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_historical_fires_overlay'),
|
||||
_showHistoricalFiresOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_firebreaks_overlay'),
|
||||
_showFirebreaksOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_kras_fire_zones_overlay'),
|
||||
_showKrasFireZonesOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_place_names_overlay'),
|
||||
_showPlaceNamesOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_municipality_borders_overlay'),
|
||||
_showMunicipalityBordersOverlay,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setHideRepeatersOnMap(bool hide) async {
|
||||
if (_hideRepeatersOnMap == hide) return;
|
||||
_hideRepeatersOnMap = hide;
|
||||
notifyListeners();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_scopedKey('map_hide_repeaters'), _hideRepeatersOnMap);
|
||||
}
|
||||
|
||||
Future<void> loadRepeaterVisibilitySettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_hideRepeatersOnMap =
|
||||
prefs.getBool(_scopedKey('map_hide_repeaters')) ?? false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setImportedTrail(LocationTrail trail) {
|
||||
_importedTrail = trail;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearImportedTrail() {
|
||||
_importedTrail = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void replaceCurrentTrailWithImport(LocationTrail importedTrail) {
|
||||
if (_currentTrail != null && _currentTrail!.isActive) {
|
||||
endTrail();
|
||||
}
|
||||
_currentTrail = importedTrail;
|
||||
_isTrailVisible = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void enterDownloadAreaMode(LatLngBounds initialBounds) {
|
||||
_isSelectingDownloadArea = true;
|
||||
_downloadAreaBounds = initialBounds;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void exitDownloadAreaMode() {
|
||||
_isSelectingDownloadArea = false;
|
||||
_downloadAreaBounds = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateDownloadAreaBounds(LatLngBounds bounds) {
|
||||
_downloadAreaBounds = bounds;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _loadCustomMapState() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final configJson = prefs.getString(_scopedKey(_customMapConfigKey));
|
||||
if (configJson != null && configJson.isNotEmpty) {
|
||||
final decoded = jsonDecode(configJson);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final config = CustomMapConfig.fromJson(decoded);
|
||||
if (config != null && await File(config.filePath).exists()) {
|
||||
_customMapConfig = config;
|
||||
} else {
|
||||
_customMapConfig = null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_customMapConfig = null;
|
||||
}
|
||||
_isUsingCustomMap =
|
||||
(prefs.getBool(_scopedKey(_customMapModeKey)) ?? false) &&
|
||||
_customMapConfig != null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _saveCustomMapState() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (_customMapConfig == null) {
|
||||
await prefs.remove(_scopedKey(_customMapConfigKey));
|
||||
} else {
|
||||
await prefs.setString(
|
||||
_scopedKey(_customMapConfigKey),
|
||||
jsonEncode(_customMapConfig!.toJson()),
|
||||
);
|
||||
}
|
||||
await prefs.setBool(_scopedKey(_customMapModeKey), _isUsingCustomMap);
|
||||
}
|
||||
|
||||
Future<(int, int)> _decodeImageSize(Uint8List bytes) async {
|
||||
final codec = await ui.instantiateImageCodec(bytes);
|
||||
final frame = await codec.getNextFrame();
|
||||
return (frame.image.width, frame.image.height);
|
||||
}
|
||||
|
||||
String _normalizedExtension(String displayName) {
|
||||
final dotIndex = displayName.lastIndexOf('.');
|
||||
if (dotIndex == -1 || dotIndex == displayName.length - 1) {
|
||||
return 'png';
|
||||
}
|
||||
return displayName.substring(dotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
Future<void> _deleteFileIfExists(String path) async {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> exportWorkspaceJson() {
|
||||
return {
|
||||
'currentTrail': _currentTrail?.toJson(),
|
||||
'trailHistory': _trailHistory.map((trail) => trail.toJson()).toList(),
|
||||
'importedTrail': _importedTrail?.toJson(),
|
||||
'isTrailVisible': _isTrailVisible,
|
||||
'isTrailRecordingEnabled': _isTrailRecordingEnabled,
|
||||
'showCadastralOverlay': _showCadastralOverlay,
|
||||
'showForestRoadsOverlay': _showForestRoadsOverlay,
|
||||
'showHikingTrailsOverlay': _showHikingTrailsOverlay,
|
||||
'showMainRoadsOverlay': _showMainRoadsOverlay,
|
||||
'showHouseNumbersOverlay': _showHouseNumbersOverlay,
|
||||
'showFireHazardZonesOverlay': _showFireHazardZonesOverlay,
|
||||
'showHistoricalFiresOverlay': _showHistoricalFiresOverlay,
|
||||
'showFirebreaksOverlay': _showFirebreaksOverlay,
|
||||
'showKrasFireZonesOverlay': _showKrasFireZonesOverlay,
|
||||
'showPlaceNamesOverlay': _showPlaceNamesOverlay,
|
||||
'showMunicipalityBordersOverlay': _showMunicipalityBordersOverlay,
|
||||
'hideRepeatersOnMap': _hideRepeatersOnMap,
|
||||
};
|
||||
}
|
||||
|
||||
void applyWorkspaceJson(Map<String, dynamic> json) {
|
||||
_currentTrail = json['currentTrail'] is Map<String, dynamic>
|
||||
? LocationTrail.fromJson(json['currentTrail'] as Map<String, dynamic>)
|
||||
: null;
|
||||
_trailHistory
|
||||
..clear()
|
||||
..addAll(
|
||||
(json['trailHistory'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(LocationTrail.fromJson),
|
||||
);
|
||||
_importedTrail = json['importedTrail'] is Map<String, dynamic>
|
||||
? LocationTrail.fromJson(json['importedTrail'] as Map<String, dynamic>)
|
||||
: null;
|
||||
_isTrailVisible = json['isTrailVisible'] as bool? ?? true;
|
||||
_isTrailRecordingEnabled =
|
||||
json['isTrailRecordingEnabled'] as bool? ?? true;
|
||||
_showCadastralOverlay = json['showCadastralOverlay'] as bool? ?? false;
|
||||
_showForestRoadsOverlay = json['showForestRoadsOverlay'] as bool? ?? false;
|
||||
_showHikingTrailsOverlay =
|
||||
json['showHikingTrailsOverlay'] as bool? ?? false;
|
||||
_showMainRoadsOverlay = json['showMainRoadsOverlay'] as bool? ?? false;
|
||||
_showHouseNumbersOverlay =
|
||||
json['showHouseNumbersOverlay'] as bool? ?? false;
|
||||
_showFireHazardZonesOverlay =
|
||||
json['showFireHazardZonesOverlay'] as bool? ?? false;
|
||||
_showHistoricalFiresOverlay =
|
||||
json['showHistoricalFiresOverlay'] as bool? ?? false;
|
||||
_showFirebreaksOverlay = json['showFirebreaksOverlay'] as bool? ?? false;
|
||||
_showKrasFireZonesOverlay =
|
||||
json['showKrasFireZonesOverlay'] as bool? ?? false;
|
||||
_showPlaceNamesOverlay = json['showPlaceNamesOverlay'] as bool? ?? false;
|
||||
_showMunicipalityBordersOverlay =
|
||||
json['showMunicipalityBordersOverlay'] as bool? ?? false;
|
||||
_hideRepeatersOnMap = json['hideRepeatersOnMap'] as bool? ?? false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String _scopedKey(String baseKey) {
|
||||
return ProfileStorageScope.scopedKey(baseKey);
|
||||
}
|
||||
}
|
||||
3082
lib/providers/messages_provider.dart
Normal file
3082
lib/providers/messages_provider.dart
Normal file
File diff suppressed because it is too large
Load Diff
582
lib/providers/offline_tiles_provider.dart
Normal file
582
lib/providers/offline_tiles_provider.dart
Normal file
@@ -0,0 +1,582 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/map_layer.dart';
|
||||
import '../services/offline_tile_cache_service.dart';
|
||||
import '../services/tile_download_service.dart';
|
||||
import '../services/tile_math_service.dart';
|
||||
import '../services/tile_sharing_service.dart';
|
||||
|
||||
export '../services/tile_sharing_service.dart' show TilePeer, PeerCatalog;
|
||||
export '../services/offline_tile_cache_service.dart'
|
||||
show StyleInfo, DownloadRegion;
|
||||
|
||||
/// Download progress state.
|
||||
class DownloadProgress {
|
||||
final int downloaded;
|
||||
final int skipped;
|
||||
final int failed;
|
||||
final int total;
|
||||
|
||||
const DownloadProgress({
|
||||
this.downloaded = 0,
|
||||
this.skipped = 0,
|
||||
this.failed = 0,
|
||||
this.total = 0,
|
||||
});
|
||||
|
||||
int get processed => downloaded + skipped + failed;
|
||||
double get percent => total == 0 ? 0 : processed / total;
|
||||
bool get isComplete => total > 0 && processed >= total;
|
||||
}
|
||||
|
||||
/// A downloaded/skipped tile rectangle for map overlay.
|
||||
class TileOverlay {
|
||||
final double north, south, east, west;
|
||||
final bool isSkipped;
|
||||
|
||||
const TileOverlay({
|
||||
required this.north,
|
||||
required this.south,
|
||||
required this.east,
|
||||
required this.west,
|
||||
this.isSkipped = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Drawing mode for polygon selection.
|
||||
enum DrawingMode { none, polygon, rectangle }
|
||||
|
||||
/// State management for offline tile downloading.
|
||||
class OfflineTilesProvider extends ChangeNotifier {
|
||||
final OfflineTileCacheService _cache = OfflineTileCacheService.instance;
|
||||
final TileSharingService _sharing = TileSharingService.instance;
|
||||
TileDownloadService? _downloadService;
|
||||
StreamSubscription<TileDownloadEvent>? _downloadSubscription;
|
||||
StreamSubscription<Set<TilePeer>>? _peersSubscription;
|
||||
bool _isDisposed = false;
|
||||
|
||||
// Drawing state
|
||||
DrawingMode _drawingMode = DrawingMode.none;
|
||||
DrawingMode _downloadSelectionMode = DrawingMode.none;
|
||||
final List<List<LatLng>> _polygons = [];
|
||||
List<LatLng> _currentVertices = [];
|
||||
LatLng? _rectangleFirstCorner;
|
||||
|
||||
// Download settings
|
||||
int _minZoom = 8;
|
||||
int _maxZoom = 14;
|
||||
MapLayer _selectedLayer = MapLayer.openStreetMap;
|
||||
|
||||
// Download progress
|
||||
bool _isDownloading = false;
|
||||
DownloadProgress _progress = const DownloadProgress();
|
||||
final List<TileOverlay> _tileOverlays = [];
|
||||
|
||||
// Cache info
|
||||
int _cacheSizeBytes = 0;
|
||||
|
||||
// Sharing state
|
||||
bool _isServerRunning = false;
|
||||
Set<TilePeer> _discoveredPeers = {};
|
||||
List<PeerCatalog> _peerCatalogs = [];
|
||||
bool _isFetchingCatalogs = false;
|
||||
bool _isSyncing = false;
|
||||
String _syncStatus = '';
|
||||
double _syncProgress = 0;
|
||||
|
||||
// Local style info
|
||||
List<StyleInfo> _localStyles = [];
|
||||
|
||||
// Coverage overlay — which cached style's tiles to show on the map
|
||||
StyleInfo? _coverageStyle;
|
||||
List<TileOverlay> _coverageOverlays = [];
|
||||
|
||||
// Getters
|
||||
DrawingMode get drawingMode => _drawingMode;
|
||||
DrawingMode get downloadSelectionMode => _downloadSelectionMode;
|
||||
List<List<LatLng>> get polygons => List.unmodifiable(_polygons);
|
||||
List<LatLng> get currentVertices => List.unmodifiable(_currentVertices);
|
||||
LatLng? get rectangleFirstCorner => _rectangleFirstCorner;
|
||||
int get minZoom => _minZoom;
|
||||
int get maxZoom => _maxZoom;
|
||||
MapLayer get selectedLayer => _selectedLayer;
|
||||
bool get isDownloading => _isDownloading;
|
||||
DownloadProgress get progress => _progress;
|
||||
List<TileOverlay> get tileOverlays => _tileOverlays;
|
||||
int get cacheSizeBytes => _cacheSizeBytes;
|
||||
bool get hasPolygons => _polygons.isNotEmpty;
|
||||
bool get isServerRunning => _isServerRunning;
|
||||
Set<TilePeer> get discoveredPeers => _discoveredPeers;
|
||||
List<PeerCatalog> get peerCatalogs => _peerCatalogs;
|
||||
bool get isFetchingCatalogs => _isFetchingCatalogs;
|
||||
bool get isSyncing => _isSyncing;
|
||||
String get syncStatus => _syncStatus;
|
||||
double get syncProgress => _syncProgress;
|
||||
List<StyleInfo> get localStyles => _localStyles;
|
||||
StyleInfo? get coverageStyle => _coverageStyle;
|
||||
List<TileOverlay> get coverageOverlays => _coverageOverlays;
|
||||
|
||||
/// Estimated tile count for the current selection.
|
||||
int get estimatedTileCount {
|
||||
if (_polygons.isEmpty) return 0;
|
||||
return TileMathService.estimateTileCount(_polygons, _minZoom, _maxZoom);
|
||||
}
|
||||
|
||||
// Drawing methods
|
||||
|
||||
void setDrawingMode(DrawingMode mode) {
|
||||
_drawingMode = mode;
|
||||
_currentVertices = [];
|
||||
_rectangleFirstCorner = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void startDownloadSelectionMode(DrawingMode mode) {
|
||||
_polygons.clear();
|
||||
_currentVertices = [];
|
||||
_rectangleFirstCorner = null;
|
||||
_downloadSelectionMode = mode;
|
||||
_drawingMode = mode;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void addVertex(LatLng point) {
|
||||
if (_drawingMode == DrawingMode.polygon) {
|
||||
_currentVertices = [..._currentVertices, point];
|
||||
notifyListeners();
|
||||
} else if (_drawingMode == DrawingMode.rectangle) {
|
||||
if (_rectangleFirstCorner == null) {
|
||||
_rectangleFirstCorner = point;
|
||||
notifyListeners();
|
||||
} else {
|
||||
// Complete rectangle
|
||||
final corner1 = _rectangleFirstCorner!;
|
||||
final corner2 = point;
|
||||
final rect = [
|
||||
LatLng(corner1.latitude, corner1.longitude),
|
||||
LatLng(corner1.latitude, corner2.longitude),
|
||||
LatLng(corner2.latitude, corner2.longitude),
|
||||
LatLng(corner2.latitude, corner1.longitude),
|
||||
];
|
||||
_polygons.add(rect);
|
||||
_rectangleFirstCorner = null;
|
||||
_drawingMode = DrawingMode.none;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void finishPolygon() {
|
||||
if (_drawingMode == DrawingMode.polygon && _currentVertices.length >= 3) {
|
||||
_polygons.add(List.from(_currentVertices));
|
||||
_currentVertices = [];
|
||||
_drawingMode = DrawingMode.none;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void removePolygon(int index) {
|
||||
if (index >= 0 && index < _polygons.length) {
|
||||
_polygons.removeAt(index);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void clearPolygons() {
|
||||
_polygons.clear();
|
||||
_currentVertices = [];
|
||||
_rectangleFirstCorner = null;
|
||||
_drawingMode = DrawingMode.none;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setCurrentViewBounds({
|
||||
required double north,
|
||||
required double south,
|
||||
required double east,
|
||||
required double west,
|
||||
}) {
|
||||
_polygons
|
||||
..clear()
|
||||
..add([
|
||||
LatLng(north, west),
|
||||
LatLng(north, east),
|
||||
LatLng(south, east),
|
||||
LatLng(south, west),
|
||||
]);
|
||||
_currentVertices = [];
|
||||
_rectangleFirstCorner = null;
|
||||
_downloadSelectionMode = DrawingMode.none;
|
||||
_drawingMode = DrawingMode.none;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void undoLastVertex() {
|
||||
if (_currentVertices.isNotEmpty) {
|
||||
_currentVertices = _currentVertices.sublist(0, _currentVertices.length - 1);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Download settings
|
||||
|
||||
void setMinZoom(int zoom) {
|
||||
_minZoom = zoom.clamp(0, 19);
|
||||
if (_maxZoom < _minZoom) _maxZoom = _minZoom;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setMaxZoom(int zoom) {
|
||||
_maxZoom = zoom.clamp(0, 19);
|
||||
if (_minZoom > _maxZoom) _minZoom = _maxZoom;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setSelectedLayer(MapLayer layer) {
|
||||
_selectedLayer = layer;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setSelectedLayerIfDifferent(MapLayer layer) {
|
||||
if (_selectedLayer.type == layer.type &&
|
||||
_selectedLayer.urlTemplate == layer.urlTemplate) {
|
||||
return;
|
||||
}
|
||||
_selectedLayer = layer;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Download control
|
||||
|
||||
Future<void> startDownload() async {
|
||||
if (_isDownloading || _polygons.isEmpty) return;
|
||||
|
||||
_isDownloading = true;
|
||||
_progress = const DownloadProgress();
|
||||
_tileOverlays.clear();
|
||||
notifyListeners();
|
||||
|
||||
_downloadService = TileDownloadService();
|
||||
final stream = _downloadService!.downloadTiles(
|
||||
polygons: _polygons,
|
||||
minZoom: _minZoom,
|
||||
maxZoom: _maxZoom,
|
||||
urlTemplate: _selectedLayer.urlTemplate,
|
||||
displayName: _selectedLayer.name,
|
||||
headers: _selectedLayer.headers,
|
||||
);
|
||||
|
||||
await for (final event in stream) {
|
||||
switch (event) {
|
||||
case TileDownloadStarted(:final totalTiles):
|
||||
_progress = DownloadProgress(total: totalTiles);
|
||||
notifyListeners();
|
||||
|
||||
case TileDownloaded(:final north, :final south, :final east, :final west):
|
||||
_progress = DownloadProgress(
|
||||
downloaded: _progress.downloaded + 1,
|
||||
skipped: _progress.skipped,
|
||||
failed: _progress.failed,
|
||||
total: _progress.total,
|
||||
);
|
||||
_addOverlay(TileOverlay(
|
||||
north: north, south: south, east: east, west: west,
|
||||
));
|
||||
notifyListeners();
|
||||
|
||||
case TileSkipped():
|
||||
_progress = DownloadProgress(
|
||||
downloaded: _progress.downloaded,
|
||||
skipped: _progress.skipped + 1,
|
||||
failed: _progress.failed,
|
||||
total: _progress.total,
|
||||
);
|
||||
notifyListeners();
|
||||
|
||||
case TileBatchSkipped(:final count):
|
||||
_progress = DownloadProgress(
|
||||
downloaded: _progress.downloaded,
|
||||
skipped: _progress.skipped + count,
|
||||
failed: _progress.failed,
|
||||
total: _progress.total,
|
||||
);
|
||||
notifyListeners();
|
||||
|
||||
case TileFailed():
|
||||
_progress = DownloadProgress(
|
||||
downloaded: _progress.downloaded,
|
||||
skipped: _progress.skipped,
|
||||
failed: _progress.failed + 1,
|
||||
total: _progress.total,
|
||||
);
|
||||
notifyListeners();
|
||||
|
||||
case TileDownloadComplete():
|
||||
_isDownloading = false;
|
||||
_tileOverlays.clear();
|
||||
notifyListeners();
|
||||
|
||||
case TileDownloadCancelled():
|
||||
_isDownloading = false;
|
||||
_tileOverlays.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
_isDownloading = false;
|
||||
_downloadService?.dispose();
|
||||
_downloadService = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void cancelDownload() {
|
||||
_downloadService?.cancel();
|
||||
}
|
||||
|
||||
void clearOverlays() {
|
||||
_tileOverlays.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _addOverlay(TileOverlay overlay) {
|
||||
_tileOverlays.add(overlay);
|
||||
// Limit overlays to prevent OOM
|
||||
if (_tileOverlays.length > 500) {
|
||||
_tileOverlays.removeRange(0, _tileOverlays.length - 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache management
|
||||
|
||||
Future<void> refreshCacheSize() async {
|
||||
_cacheSizeBytes = await _cache.getCacheSize();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> deleteStyle(StyleInfo style) async {
|
||||
if (_coverageStyle?.hash == style.hash) hideCoverage();
|
||||
await _cache.deleteStyle(style.hash);
|
||||
await refreshCacheSize();
|
||||
await refreshLocalStyles();
|
||||
}
|
||||
|
||||
Future<void> clearCache() async {
|
||||
hideCoverage();
|
||||
await _cache.clearCache();
|
||||
_cacheSizeBytes = 0;
|
||||
_localStyles = [];
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Coverage overlay — show cached tile bounds on the map
|
||||
|
||||
/// Show the coverage of a cached style on the map.
|
||||
/// Loads tile coordinates from the manifest and converts to bounds.
|
||||
Future<void> showCoverage(StyleInfo style) async {
|
||||
if (_coverageStyle?.hash == style.hash) {
|
||||
// Toggle off if same style tapped again
|
||||
hideCoverage();
|
||||
return;
|
||||
}
|
||||
|
||||
_coverageStyle = style;
|
||||
_coverageOverlays = [];
|
||||
notifyListeners();
|
||||
|
||||
final tiles = await _cache.listTilesForStyle(style.hash);
|
||||
|
||||
// Convert cached tile coordinates to bound overlays
|
||||
final overlays = <TileOverlay>[];
|
||||
for (final tile in tiles) {
|
||||
final bounds = TileMathService.tileBounds(tile.x, tile.y, tile.z);
|
||||
overlays.add(TileOverlay(
|
||||
north: bounds.north,
|
||||
south: bounds.south,
|
||||
east: bounds.east,
|
||||
west: bounds.west,
|
||||
isSkipped: true, // green color
|
||||
));
|
||||
}
|
||||
|
||||
_coverageOverlays = overlays;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void hideCoverage() {
|
||||
_coverageStyle = null;
|
||||
_coverageOverlays = [];
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Sharing controls
|
||||
|
||||
Future<void> toggleServer() async {
|
||||
if (_isServerRunning) {
|
||||
await _sharing.stopServer();
|
||||
_isServerRunning = false;
|
||||
} else {
|
||||
await _sharing.startServer();
|
||||
_isServerRunning = _sharing.isRunning;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> startPeerDiscovery() async {
|
||||
_peersSubscription?.cancel();
|
||||
_peersSubscription = _sharing.peersStream.listen((peers) {
|
||||
_discoveredPeers = peers;
|
||||
notifyListeners();
|
||||
});
|
||||
await _sharing.startDiscovery();
|
||||
}
|
||||
|
||||
Future<void> stopPeerDiscovery() async {
|
||||
_peersSubscription?.cancel();
|
||||
_peersSubscription = null;
|
||||
await _sharing.stopPeerDiscovery();
|
||||
_discoveredPeers = {};
|
||||
if (!_isDisposed) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void addManualPeer(String ipAddress) {
|
||||
_sharing.addManualPeer(ipAddress);
|
||||
_discoveredPeers = _sharing.discoveredPeers;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void removePeer(TilePeer peer) {
|
||||
_sharing.removePeer(peer);
|
||||
_discoveredPeers = _sharing.discoveredPeers;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Peer catalog & P2P sync
|
||||
|
||||
/// Refresh local style info.
|
||||
Future<void> refreshLocalStyles() async {
|
||||
_localStyles = await _cache.listStylesDetailed();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Fetch catalogs from all discovered peers to see what they have.
|
||||
Future<void> refreshPeerCatalogs() async {
|
||||
_isFetchingCatalogs = true;
|
||||
notifyListeners();
|
||||
|
||||
_peerCatalogs = await _sharing.fetchAllPeerCatalogs();
|
||||
_isFetchingCatalogs = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Sync a style from one or more peers that have it.
|
||||
/// Finds all peers offering [styleHash] and pulls missing tiles.
|
||||
Future<void> syncStyleFromPeers(StyleInfo style) async {
|
||||
if (_isSyncing) return;
|
||||
|
||||
// Find all peers that have this style
|
||||
final peersWithStyle = <TilePeer>[];
|
||||
for (final catalog in _peerCatalogs) {
|
||||
if (catalog.styles.any((s) => s.hash == style.hash)) {
|
||||
peersWithStyle.add(catalog.peer);
|
||||
}
|
||||
}
|
||||
if (peersWithStyle.isEmpty) return;
|
||||
|
||||
_isSyncing = true;
|
||||
_syncStatus = 'Starting sync of ${style.displayName}...';
|
||||
_syncProgress = 0;
|
||||
notifyListeners();
|
||||
|
||||
final stream = _sharing.syncStyleFromPeers(
|
||||
peers: peersWithStyle,
|
||||
styleHash: style.hash,
|
||||
styleMeta: style,
|
||||
);
|
||||
|
||||
await for (final event in stream) {
|
||||
switch (event) {
|
||||
case PeerSyncStarted(:final totalTiles):
|
||||
_syncStatus = 'Syncing ${style.displayName}: 0/$totalTiles tiles';
|
||||
_syncProgress = 0;
|
||||
notifyListeners();
|
||||
|
||||
case PeerSyncTileDownloaded(:final downloaded, :final total):
|
||||
_syncStatus =
|
||||
'Syncing ${style.displayName}: $downloaded/$total tiles';
|
||||
_syncProgress = total > 0 ? downloaded / total : 0;
|
||||
notifyListeners();
|
||||
|
||||
case PeerSyncTileSkipped(:final skipped, :final total):
|
||||
_syncProgress = total > 0 ? skipped / total : 0;
|
||||
notifyListeners();
|
||||
|
||||
case PeerSyncComplete(:final downloaded, :final skipped, :final failed):
|
||||
_syncStatus =
|
||||
'Done! $downloaded new, $skipped cached, $failed failed';
|
||||
_isSyncing = false;
|
||||
notifyListeners();
|
||||
await refreshCacheSize();
|
||||
await refreshLocalStyles();
|
||||
|
||||
case PeerSyncCancelled():
|
||||
_syncStatus = 'Sync cancelled';
|
||||
_isSyncing = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void cancelSync() {
|
||||
_sharing.cancelSync();
|
||||
}
|
||||
|
||||
// Presets — load a previously downloaded region for quick re-download
|
||||
|
||||
/// Load a saved download region as the current selection.
|
||||
/// Restores polygons, zoom range, and map layer.
|
||||
void loadPreset(StyleInfo style) {
|
||||
if (style.region == null) return;
|
||||
|
||||
final region = style.region!;
|
||||
|
||||
// Restore polygons
|
||||
_polygons.clear();
|
||||
for (final polyData in region.polygons) {
|
||||
final poly = polyData.map((v) => LatLng(v[0], v[1])).toList();
|
||||
if (poly.length >= 3) _polygons.add(poly);
|
||||
}
|
||||
|
||||
// Restore zoom range
|
||||
_minZoom = region.minZoom;
|
||||
_maxZoom = region.maxZoom;
|
||||
|
||||
// Try to find the matching map layer
|
||||
if (style.urlTemplate.isNotEmpty) {
|
||||
final matchingLayer = MapLayer.allLayers.where(
|
||||
(l) => l.urlTemplate == style.urlTemplate,
|
||||
);
|
||||
if (matchingLayer.isNotEmpty) {
|
||||
_selectedLayer = matchingLayer.first;
|
||||
}
|
||||
}
|
||||
|
||||
_currentVertices = [];
|
||||
_rectangleFirstCorner = null;
|
||||
_drawingMode = DrawingMode.none;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
_downloadSubscription?.cancel();
|
||||
_downloadService?.dispose();
|
||||
_peersSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
1058
lib/providers/sensors_provider.dart
Normal file
1058
lib/providers/sensors_provider.dart
Normal file
File diff suppressed because it is too large
Load Diff
470
lib/providers/voice_provider.dart
Normal file
470
lib/providers/voice_provider.dart
Normal file
@@ -0,0 +1,470 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../services/profiles_feature_service.dart';
|
||||
import 'helpers/raw_session_retransmit.dart';
|
||||
import '../utils/voice_message_parser.dart';
|
||||
import '../services/voice_codec_service.dart';
|
||||
import '../services/voice_player_service.dart';
|
||||
|
||||
/// Reassembly state for one voice session.
|
||||
class VoiceSession {
|
||||
final String sessionId;
|
||||
final VoicePacketMode mode;
|
||||
final int total;
|
||||
final List<VoicePacket?> packets; // indexed by packet.index
|
||||
DateTime? firstPacketAt;
|
||||
DateTime? lastPacketAt;
|
||||
|
||||
VoiceSession({
|
||||
required this.sessionId,
|
||||
required this.mode,
|
||||
required this.total,
|
||||
}) : packets = List.filled(total, null);
|
||||
|
||||
int get receivedCount => packets.where((p) => p != null).length;
|
||||
bool get isComplete => receivedCount == total;
|
||||
|
||||
Duration? estimateRemaining() {
|
||||
if (isComplete) return Duration.zero;
|
||||
if (firstPacketAt == null || lastPacketAt == null) return null;
|
||||
if (receivedCount < 2) return null;
|
||||
|
||||
final elapsedMs = lastPacketAt!.difference(firstPacketAt!).inMilliseconds;
|
||||
if (elapsedMs <= 0) return null;
|
||||
final avgMsPerPacket = elapsedMs / (receivedCount - 1);
|
||||
final remaining = total - receivedCount;
|
||||
if (remaining <= 0) return Duration.zero;
|
||||
return Duration(milliseconds: (avgMsPerPacket * remaining).round());
|
||||
}
|
||||
|
||||
/// Total estimated audio duration in seconds (sum of all received packets).
|
||||
double get estimatedDurationSeconds {
|
||||
var ms = 0;
|
||||
for (final p in packets) {
|
||||
if (p != null) ms += p.durationMs;
|
||||
}
|
||||
return ms / 1000.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages incoming voice packet sessions and coordinates playback.
|
||||
class VoiceProvider with ChangeNotifier {
|
||||
static const String _voiceSessionsStorageKey = 'stored_voice_sessions_v1';
|
||||
static const int maxDirectPayloadHops = 3;
|
||||
final VoiceCodecService _codec;
|
||||
final VoicePlayerService _player;
|
||||
late final StreamSubscription<void> _playerEventsSub;
|
||||
|
||||
/// Active sessions keyed by sessionId.
|
||||
final Map<String, VoiceSession> _sessions = {};
|
||||
final Set<String> _ignoredIncomingSessions = {};
|
||||
|
||||
/// Currently playing session ID, or null.
|
||||
String? _playingSessionId;
|
||||
|
||||
/// Hook for sending a raw voice payload to a destination contact path.
|
||||
Future<void> Function({
|
||||
required Uint8List contactPath,
|
||||
required int contactPathLen,
|
||||
required Uint8List payload,
|
||||
})?
|
||||
sendRawPacketCallback;
|
||||
|
||||
final Map<String, _OutgoingVoiceSession> _outgoingSessions = {};
|
||||
|
||||
VoiceProvider({
|
||||
required VoiceCodecService codec,
|
||||
required VoicePlayerService player,
|
||||
}) : _codec = codec,
|
||||
_player = player {
|
||||
_playerEventsSub = _player.events.listen((_) {
|
||||
if (_playingSessionId != null &&
|
||||
!_player.isPlaying &&
|
||||
_player.duration.inMilliseconds > 0 &&
|
||||
_player.position >= _player.duration) {
|
||||
_playingSessionId = null;
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
_restorePersistedVoiceData();
|
||||
}
|
||||
|
||||
String _storageKey() =>
|
||||
ProfileStorageScope.scopedKey(_voiceSessionsStorageKey);
|
||||
|
||||
// ── Session accessors ────────────────────────────────────────────────────
|
||||
|
||||
VoiceSession? session(String sessionId) => _sessions[sessionId];
|
||||
bool isComplete(String sessionId) =>
|
||||
_sessions[sessionId]?.isComplete ?? false;
|
||||
bool isPlaying(String sessionId) => _playingSessionId == sessionId;
|
||||
Duration get playbackPosition => _player.position;
|
||||
Duration get playbackDuration => _player.duration;
|
||||
|
||||
double playbackProgress(String sessionId) {
|
||||
if (_playingSessionId != sessionId) return 0.0;
|
||||
final totalMs = _player.duration.inMilliseconds;
|
||||
if (totalMs <= 0) return 0.0;
|
||||
final posMs = _player.position.inMilliseconds.clamp(0, totalMs);
|
||||
return posMs / totalMs;
|
||||
}
|
||||
|
||||
bool hasOutgoingSession(String sessionId) =>
|
||||
_outgoingSessions.containsKey(sessionId);
|
||||
Duration? estimateRemainingTransferTime(String sessionId) =>
|
||||
_sessions[sessionId]?.estimateRemaining();
|
||||
bool isReceiveCanceled(String sessionId) =>
|
||||
_ignoredIncomingSessions.contains(sessionId);
|
||||
|
||||
List<int> missingPacketIndices(String sessionId) {
|
||||
final session = _sessions[sessionId];
|
||||
if (session == null) return const [];
|
||||
final missing = <int>[];
|
||||
for (var i = 0; i < session.total; i++) {
|
||||
if (session.packets[i] == null) missing.add(i);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
List<int> availablePacketIndices(String sessionId) {
|
||||
final outgoing = _outgoingSessions[sessionId];
|
||||
if (outgoing != null) {
|
||||
return outgoing.packets.map((packet) => packet.index).toList()..sort();
|
||||
}
|
||||
|
||||
final session = _sessions[sessionId];
|
||||
if (session == null) return const [];
|
||||
final indices = <int>[];
|
||||
for (var i = 0; i < session.packets.length; i++) {
|
||||
if (session.packets[i] != null) {
|
||||
indices.add(i);
|
||||
}
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
// ── Packet reception ─────────────────────────────────────────────────────
|
||||
|
||||
/// Add an incoming [packet] to its session. Creates the session on first packet.
|
||||
/// Returns true if the session just became complete.
|
||||
bool addPacket(VoicePacket packet) {
|
||||
if (_ignoredIncomingSessions.contains(packet.sessionId)) {
|
||||
debugPrint(
|
||||
'⏹️ [VoiceProvider] Ignoring canceled incoming session ${packet.sessionId}',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
_sessions.putIfAbsent(packet.sessionId, () {
|
||||
if (packet.total < 1) {
|
||||
throw StateError(
|
||||
'Voice envelope missing for compact packet ${packet.sessionId}',
|
||||
);
|
||||
}
|
||||
return VoiceSession(
|
||||
sessionId: packet.sessionId,
|
||||
mode: packet.mode,
|
||||
total: packet.total,
|
||||
);
|
||||
});
|
||||
|
||||
final session = _sessions[packet.sessionId]!;
|
||||
if (packet.index < session.total) {
|
||||
final wasMissing = session.packets[packet.index] == null;
|
||||
session.packets[packet.index] = packet;
|
||||
if (wasMissing) {
|
||||
final now = DateTime.now();
|
||||
session.firstPacketAt ??= now;
|
||||
session.lastPacketAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
final justComplete = session.isComplete;
|
||||
_persistVoiceData();
|
||||
notifyListeners();
|
||||
return justComplete;
|
||||
}
|
||||
|
||||
void cancelIncomingSession(String sessionId) {
|
||||
_ignoredIncomingSessions.add(sessionId);
|
||||
_sessions.remove(sessionId);
|
||||
if (_playingSessionId == sessionId) {
|
||||
unawaited(_player.stop());
|
||||
_playingSessionId = null;
|
||||
}
|
||||
_persistVoiceData();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void resumeIncomingSession(String sessionId) {
|
||||
if (_ignoredIncomingSessions.remove(sessionId)) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void registerEnvelope(VoiceEnvelope envelope) {
|
||||
if (_ignoredIncomingSessions.contains(envelope.sessionId)) {
|
||||
return;
|
||||
}
|
||||
final existing = _sessions[envelope.sessionId];
|
||||
if (existing == null) {
|
||||
_sessions[envelope.sessionId] = VoiceSession(
|
||||
sessionId: envelope.sessionId,
|
||||
mode: envelope.mode,
|
||||
total: envelope.total,
|
||||
);
|
||||
_persistVoiceData();
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final needsMerge =
|
||||
existing.total != envelope.total || existing.mode != envelope.mode;
|
||||
if (!needsMerge) {
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final merged = VoiceSession(
|
||||
sessionId: envelope.sessionId,
|
||||
mode: envelope.mode,
|
||||
total: envelope.total,
|
||||
);
|
||||
merged.firstPacketAt = existing.firstPacketAt;
|
||||
merged.lastPacketAt = existing.lastPacketAt;
|
||||
for (final packet in existing.packets) {
|
||||
if (packet == null) continue;
|
||||
if (packet.index < merged.total) {
|
||||
merged.packets[packet.index] = packet;
|
||||
}
|
||||
}
|
||||
_sessions[envelope.sessionId] = merged;
|
||||
_persistVoiceData();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Cache encoded packets for deferred voice serving.
|
||||
void cacheOutgoingSession(String sessionId, List<VoicePacket> packets) {
|
||||
if (packets.isEmpty) return;
|
||||
_outgoingSessions[sessionId] = _OutgoingVoiceSession(
|
||||
sessionId: sessionId,
|
||||
packets: List<VoicePacket>.from(packets),
|
||||
);
|
||||
_persistVoiceData();
|
||||
}
|
||||
|
||||
/// Stream a cached voice session to a requester over raw direct packets.
|
||||
Future<bool> serveSessionTo({
|
||||
required String sessionId,
|
||||
required Contact requester,
|
||||
Set<int>? requestedIndices,
|
||||
}) async {
|
||||
final outgoing = _outgoingSessions[sessionId];
|
||||
final packets = outgoing != null
|
||||
? List<VoicePacket>.from(outgoing.packets)
|
||||
: _sessions[sessionId]?.packets.whereType<VoicePacket>().toList() ??
|
||||
const <VoicePacket>[];
|
||||
if (packets.isEmpty) {
|
||||
debugPrint(
|
||||
'⚠️ [VoiceProvider] No cached or received session for $sessionId',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return serveCachedSessionFragments<VoicePacket>(
|
||||
providerLabel: 'VoiceProvider',
|
||||
sessionId: sessionId,
|
||||
requester: requester,
|
||||
fragments: packets,
|
||||
maxDirectPayloadHops: maxDirectPayloadHops,
|
||||
indexOf: (packet) => packet.index,
|
||||
encodeBinary: (packet) => packet.encodeBinary(),
|
||||
sendRawPacket: sendRawPacketCallback,
|
||||
requestedIndices: requestedIndices,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Playback ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Decode and play the voice session with [sessionId].
|
||||
/// Plays whatever packets are available (handles partial reception gracefully).
|
||||
Future<void> play(String sessionId) async {
|
||||
final session = _sessions[sessionId];
|
||||
if (session == null) {
|
||||
debugPrint(
|
||||
'❌ [VoiceProvider] play($sessionId) — session not found, known: ${_sessions.keys.toList()}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'🎙️ [VoiceProvider] play($sessionId): ${session.receivedCount}/${session.total} packets, mode=${session.mode.label}',
|
||||
);
|
||||
|
||||
try {
|
||||
final decodedPcm = await _codec.decodePackets(
|
||||
session.packets,
|
||||
session.mode,
|
||||
);
|
||||
final pcm = _preparePlaybackPcm(decodedPcm, session.mode);
|
||||
debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples');
|
||||
_playingSessionId = sessionId;
|
||||
notifyListeners();
|
||||
await _player.play(pcm, sampleRateHz: session.mode.sampleRateHz);
|
||||
} catch (e, st) {
|
||||
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
|
||||
if (_playingSessionId == sessionId) {
|
||||
_playingSessionId = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
await _player.stop();
|
||||
_playingSessionId = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Int16List _preparePlaybackPcm(Int16List pcm, VoicePacketMode mode) {
|
||||
return pcm;
|
||||
}
|
||||
|
||||
Future<void> clearStoredVoiceData() async {
|
||||
await _resetInMemoryState();
|
||||
notifyListeners();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_storageKey());
|
||||
} catch (e) {
|
||||
debugPrint('❌ [VoiceProvider] Failed to clear stored voice data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reloadProfileScopedState() async {
|
||||
await _resetInMemoryState();
|
||||
await _restorePersistedVoiceData();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _persistVoiceData() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final payload = <String, dynamic>{
|
||||
'incoming': _sessions.values
|
||||
.map(
|
||||
(session) => {
|
||||
'sessionId': session.sessionId,
|
||||
'modeId': session.mode.id,
|
||||
'total': session.total,
|
||||
'packets': session.packets.map((p) => p?.encodeText()).toList(),
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'outgoing': _outgoingSessions.values
|
||||
.map(
|
||||
(session) => {
|
||||
'sessionId': session.sessionId,
|
||||
'packets': session.packets.map((p) => p.encodeText()).toList(),
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
};
|
||||
await prefs.setString(_storageKey(), jsonEncode(payload));
|
||||
} catch (e) {
|
||||
debugPrint('❌ [VoiceProvider] Failed to persist voice data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _restorePersistedVoiceData() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_storageKey());
|
||||
if (raw == null || raw.isEmpty) return;
|
||||
|
||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
|
||||
final incoming = parsed['incoming'] as List<dynamic>? ?? const [];
|
||||
for (final item in incoming) {
|
||||
final map = item as Map<String, dynamic>;
|
||||
final sessionId = map['sessionId'] as String?;
|
||||
final modeId = map['modeId'] as int?;
|
||||
final total = map['total'] as int?;
|
||||
if (sessionId == null ||
|
||||
modeId == null ||
|
||||
total == null ||
|
||||
total <= 0) {
|
||||
continue;
|
||||
}
|
||||
final mode = VoicePacketMode.fromId(modeId);
|
||||
final session = VoiceSession(
|
||||
sessionId: sessionId,
|
||||
mode: mode,
|
||||
total: total,
|
||||
);
|
||||
final packets = map['packets'] as List<dynamic>? ?? const [];
|
||||
for (var i = 0; i < packets.length && i < session.total; i++) {
|
||||
final encoded = packets[i] as String?;
|
||||
if (encoded == null || encoded.isEmpty) continue;
|
||||
final packet = VoicePacket.tryParseText(encoded);
|
||||
if (packet != null && packet.index < session.total) {
|
||||
session.packets[packet.index] = packet;
|
||||
}
|
||||
}
|
||||
_sessions[sessionId] = session;
|
||||
}
|
||||
|
||||
final outgoing = parsed['outgoing'] as List<dynamic>? ?? const [];
|
||||
for (final item in outgoing) {
|
||||
final map = item as Map<String, dynamic>;
|
||||
final sessionId = map['sessionId'] as String?;
|
||||
if (sessionId == null || sessionId.isEmpty) continue;
|
||||
final packetsRaw = map['packets'] as List<dynamic>? ?? const [];
|
||||
final packets = <VoicePacket>[];
|
||||
for (final encoded in packetsRaw) {
|
||||
final packet = VoicePacket.tryParseText((encoded ?? '') as String);
|
||||
if (packet != null) packets.add(packet);
|
||||
}
|
||||
if (packets.isNotEmpty) {
|
||||
_outgoingSessions[sessionId] = _OutgoingVoiceSession(
|
||||
sessionId: sessionId,
|
||||
packets: packets,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
debugPrint(
|
||||
'🎙️ [VoiceProvider] Restored ${_sessions.length} incoming and ${_outgoingSessions.length} outgoing voice sessions',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [VoiceProvider] Failed to restore voice data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resetInMemoryState() async {
|
||||
if (_playingSessionId != null || _player.isPlaying) {
|
||||
await _player.stop();
|
||||
}
|
||||
_sessions.clear();
|
||||
_outgoingSessions.clear();
|
||||
_ignoredIncomingSessions.clear();
|
||||
_playingSessionId = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_playerEventsSub.cancel();
|
||||
_player.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _OutgoingVoiceSession {
|
||||
final String sessionId;
|
||||
final List<VoicePacket> packets;
|
||||
|
||||
const _OutgoingVoiceSession({required this.sessionId, required this.packets});
|
||||
}
|
||||
394
lib/screens/add_contact_screen.dart
Normal file
394
lib/screens/add_contact_screen.dart
Normal file
@@ -0,0 +1,394 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class AddContactScreen extends StatefulWidget {
|
||||
final String? initialAdvert;
|
||||
|
||||
const AddContactScreen({super.key, this.initialAdvert});
|
||||
|
||||
@override
|
||||
State<AddContactScreen> createState() => _AddContactScreenState();
|
||||
}
|
||||
|
||||
class _AddContactScreenState extends State<AddContactScreen> {
|
||||
final TextEditingController _advertController = TextEditingController();
|
||||
bool _isImporting = false;
|
||||
bool _importSucceeded = false;
|
||||
String? _validationError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.initialAdvert != null &&
|
||||
widget.initialAdvert!.trim().isNotEmpty) {
|
||||
_advertController.text = widget.initialAdvert!.trim();
|
||||
} else {
|
||||
_loadClipboardIfPresent();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_advertController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadClipboardIfPresent() async {
|
||||
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
final text = clipboardData?.text?.trim();
|
||||
if (!mounted || text == null || text.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (_normalizeAdvertText(text) == null) {
|
||||
return;
|
||||
}
|
||||
_advertController.text = text;
|
||||
}
|
||||
|
||||
String? _normalizeAdvertText(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = trimmed;
|
||||
if (normalized.startsWith('meshcore://')) {
|
||||
normalized = normalized.substring('meshcore://'.length);
|
||||
}
|
||||
|
||||
normalized = normalized.replaceAll(RegExp(r'\s+'), '');
|
||||
if (normalized.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final isHex = RegExp(r'^[0-9a-fA-F]+$').hasMatch(normalized);
|
||||
if (!isHex || normalized.length.isOdd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
|
||||
Uint8List _hexToBytes(String hex) {
|
||||
final bytes = <int>[];
|
||||
for (var i = 0; i < hex.length; i += 2) {
|
||||
bytes.add(int.parse(hex.substring(i, i + 2), radix: 16));
|
||||
}
|
||||
return Uint8List.fromList(bytes);
|
||||
}
|
||||
|
||||
Future<void> _pasteFromClipboard() async {
|
||||
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
final text = clipboardData?.text;
|
||||
if (text == null || text.trim().isEmpty) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context)!.clipboardIsEmpty)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_advertController.text = text.trim();
|
||||
_importSucceeded = false;
|
||||
_validationError = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _importContact() async {
|
||||
final normalized = _normalizeAdvertText(_advertController.text);
|
||||
if (normalized == null) {
|
||||
setState(() {
|
||||
_validationError =
|
||||
'Enter a valid meshcore:// advert or raw hexadecimal contact advert.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final advertBytes = _hexToBytes(normalized);
|
||||
if (advertBytes.length < 98) {
|
||||
setState(() {
|
||||
_validationError =
|
||||
'Advert is too short. Expected exported contact data.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isImporting = true;
|
||||
_importSucceeded = false;
|
||||
_validationError = null;
|
||||
});
|
||||
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
await connectionProvider.importContactAdvert(advertBytes);
|
||||
final importError = connectionProvider.error;
|
||||
|
||||
if (importError == null) {
|
||||
await connectionProvider.getContacts();
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isImporting = false;
|
||||
});
|
||||
|
||||
if (importError != null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(importError)));
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context)!.contactImported)),
|
||||
);
|
||||
setState(() {
|
||||
_importSucceeded = true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final normalized = _normalizeAdvertText(_advertController.text);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(AppLocalizations.of(context)!.addContact)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.person_add_alt_1_outlined,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Import a shared contact advert',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'Paste a `meshcore://...` link or raw hexadecimal advert. The app will validate it and import the contact into the connected device.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_ImportHintChip(
|
||||
icon: Icons.link_outlined,
|
||||
label: AppLocalizations.of(context)!.acceptsShareLinks,
|
||||
),
|
||||
_ImportHintChip(
|
||||
icon: Icons.code_outlined,
|
||||
label: AppLocalizations.of(context)!.supportsRawHex,
|
||||
),
|
||||
_ImportHintChip(
|
||||
icon: Icons.content_paste_go_outlined,
|
||||
label: AppLocalizations.of(context)!.clipboardfriendly,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Advert payload',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'You can paste the full share link or only the hex payload.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _advertController,
|
||||
minLines: 5,
|
||||
maxLines: 9,
|
||||
onChanged: (_) {
|
||||
if (_validationError != null || _importSucceeded) {
|
||||
setState(() {
|
||||
_importSucceeded = false;
|
||||
_validationError = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Contact advert',
|
||||
hintText: AppLocalizations.of(context)!.pasteShareLinkOrHexAdvert,
|
||||
alignLabelWithHint: true,
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: _validationError,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
normalized == null
|
||||
? 'No valid advert detected yet'
|
||||
: 'Advert size: ${normalized.length ~/ 2} bytes',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _isImporting ? null : _pasteFromClipboard,
|
||||
icon: Icon(Icons.content_paste_go_outlined),
|
||||
label: Text(AppLocalizations.of(context)!.paste),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: (_isImporting || _importSucceeded)
|
||||
? null
|
||||
: _importContact,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
icon: _isImporting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: _importSucceeded
|
||||
? const Icon(Icons.check_circle_outline)
|
||||
: const Icon(Icons.person_add_alt_1_outlined),
|
||||
label: Text(
|
||||
_isImporting
|
||||
? 'Importing...'
|
||||
: _importSucceeded
|
||||
? 'Imported'
|
||||
: 'Add Contact',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_importSucceeded)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: colorScheme.primary.withValues(alpha: 0.18),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
size: 18,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Contact imported. Paste another advert or edit the current one to import again.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImportHintChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
const _ImportHintChip({required this.icon, required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: theme.textTheme.labelMedium),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
2758
lib/screens/contacts_tab.dart
Normal file
2758
lib/screens/contacts_tab.dart
Normal file
File diff suppressed because it is too large
Load Diff
3235
lib/screens/device_config_screen.dart
Normal file
3235
lib/screens/device_config_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
1077
lib/screens/discovery_screen.dart
Normal file
1077
lib/screens/discovery_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
1173
lib/screens/home_screen.dart
Normal file
1173
lib/screens/home_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
1220
lib/screens/live_traffic_screen.dart
Normal file
1220
lib/screens/live_traffic_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
4202
lib/screens/map_tab.dart
Normal file
4202
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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
3065
lib/screens/messages_tab.dart
Normal file
3065
lib/screens/messages_tab.dart
Normal file
File diff suppressed because it is too large
Load Diff
846
lib/screens/offline_map_screen.dart
Normal file
846
lib/screens/offline_map_screen.dart
Normal file
@@ -0,0 +1,846 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/map_layer.dart';
|
||||
import '../providers/offline_tiles_provider.dart';
|
||||
import '../widgets/map/polygon_draw_handler.dart';
|
||||
|
||||
/// Screen for downloading offline map tiles.
|
||||
///
|
||||
/// Allows drawing polygons/rectangles to select areas, choosing zoom levels,
|
||||
/// and downloading tiles with real-time progress visualization.
|
||||
class OfflineMapScreen extends StatefulWidget {
|
||||
const OfflineMapScreen({super.key});
|
||||
|
||||
@override
|
||||
State<OfflineMapScreen> createState() => _OfflineMapScreenState();
|
||||
}
|
||||
|
||||
class _OfflineMapScreenState extends State<OfflineMapScreen> {
|
||||
final MapController _mapController = MapController();
|
||||
late OfflineTilesProvider _provider;
|
||||
double _currentZoom = 8;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_provider = context.read<OfflineTilesProvider>();
|
||||
_provider.refreshCacheSize();
|
||||
_provider.refreshLocalStyles();
|
||||
_provider.startPeerDiscovery();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_provider.stopPeerDiscovery();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(AppLocalizations.of(context)!.offlineMaps),
|
||||
actions: [
|
||||
Consumer<OfflineTilesProvider>(
|
||||
builder: (context, provider, _) {
|
||||
return IconButton(
|
||||
icon: Badge(
|
||||
isLabelVisible: provider.discoveredPeers.isNotEmpty,
|
||||
label: Text('${provider.discoveredPeers.length}'),
|
||||
child: Icon(
|
||||
provider.isServerRunning
|
||||
? Icons.wifi_tethering
|
||||
: Icons.wifi_tethering_off,
|
||||
),
|
||||
),
|
||||
tooltip: provider.isServerRunning
|
||||
? 'Sharing tiles'
|
||||
: 'Tile sharing off',
|
||||
onPressed: () => _showSharingSheet(context, provider),
|
||||
);
|
||||
},
|
||||
),
|
||||
Consumer<OfflineTilesProvider>(
|
||||
builder: (context, provider, _) {
|
||||
return PopupMenuButton<MapLayer>(
|
||||
icon: const Icon(Icons.layers),
|
||||
tooltip: AppLocalizations.of(context)!.mapStyle,
|
||||
onSelected: (layer) => provider.setSelectedLayer(layer),
|
||||
itemBuilder: (_) => [
|
||||
for (final layer in MapLayer.allLayers)
|
||||
PopupMenuItem(
|
||||
value: layer,
|
||||
child: Row(
|
||||
children: [
|
||||
if (layer.type == provider.selectedLayer.type)
|
||||
const Icon(Icons.check, size: 18)
|
||||
else
|
||||
const SizedBox(width: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(layer.name),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
Consumer<OfflineTilesProvider>(
|
||||
builder: (context, provider, _) {
|
||||
return FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: const LatLng(46.05, 14.5), // Slovenia
|
||||
initialZoom: 8,
|
||||
onPositionChanged: (camera, hasGesture) {
|
||||
if (camera.zoom != _currentZoom) {
|
||||
setState(() => _currentZoom = camera.zoom);
|
||||
}
|
||||
},
|
||||
onTap: (tapPosition, point) {
|
||||
if (provider.drawingMode != DrawingMode.none) {
|
||||
provider.addVertex(point);
|
||||
}
|
||||
},
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: provider.selectedLayer.urlTemplate,
|
||||
tileProvider: NetworkTileProvider(
|
||||
headers: provider.selectedLayer.headers ?? const {},
|
||||
),
|
||||
userAgentPackageName: 'com.meshcore.sar',
|
||||
maxZoom: provider.selectedLayer.maxZoom,
|
||||
),
|
||||
CoverageLayer(currentZoom: _currentZoom),
|
||||
const PolygonDrawLayer(),
|
||||
const DownloadProgressLayer(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const DrawingToolbar(),
|
||||
Positioned(
|
||||
left: 16,
|
||||
top: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.85),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.15),
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
'Z ${_currentZoom.toStringAsFixed(1)}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildBottomPanel(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomPanel() {
|
||||
return Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Consumer<OfflineTilesProvider>(
|
||||
builder: (context, provider, _) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(16)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.15),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Saved region presets dropdown
|
||||
if (!provider.isDownloading &&
|
||||
provider.localStyles
|
||||
.any((s) => s.region != null)) ...[
|
||||
DropdownButtonFormField<StyleInfo>(
|
||||
decoration: const InputDecoration(
|
||||
prefixIcon: Icon(Icons.bookmark, size: 20),
|
||||
labelText: 'Saved regions',
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 8),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
isExpanded: true,
|
||||
hint: Text(AppLocalizations.of(context)!.loadASavedRegion),
|
||||
items: provider.localStyles
|
||||
.where((s) => s.region != null)
|
||||
.map((style) => DropdownMenuItem(
|
||||
value: style,
|
||||
child: Text(
|
||||
'${style.displayName} '
|
||||
'(z${style.region!.minZoom}-${style.region!.maxZoom}, '
|
||||
'${_formatNumber(style.tileCount)} tiles)',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (style) {
|
||||
if (style != null) {
|
||||
provider.loadPreset(style);
|
||||
_fitMapToPolygons(provider);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
// Zoom range
|
||||
if (!provider.isDownloading) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ZoomSelector(
|
||||
label: AppLocalizations.of(context)!.minZoom,
|
||||
value: provider.minZoom,
|
||||
onChanged: provider.setMinZoom,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _ZoomSelector(
|
||||
label: AppLocalizations.of(context)!.maxZoom,
|
||||
value: provider.maxZoom,
|
||||
onChanged: provider.setMaxZoom,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Tile estimate
|
||||
Text(
|
||||
provider.hasPolygons
|
||||
? '~${_formatNumber(provider.estimatedTileCount)} tiles'
|
||||
: 'Draw an area to download',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Cache: ${_formatBytes(provider.cacheSizeBytes)}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
||||
// Progress
|
||||
if (provider.isDownloading) ...[
|
||||
LinearProgressIndicator(
|
||||
value: provider.progress.percent,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${(provider.progress.percent * 100).toStringAsFixed(1)}% — '
|
||||
'Downloaded: ${provider.progress.downloaded}, '
|
||||
'Cached: ${provider.progress.skipped}, '
|
||||
'Failed: ${provider.progress.failed} / '
|
||||
'${provider.progress.total}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
||||
// Download complete summary
|
||||
if (!provider.isDownloading &&
|
||||
provider.progress.isComplete) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Done! ${provider.progress.downloaded} downloaded, '
|
||||
'${provider.progress.skipped} cached, '
|
||||
'${provider.progress.failed} failed',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.green,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
children: [
|
||||
if (!provider.isDownloading) ...[
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: provider.hasPolygons
|
||||
? () => provider.startDownload()
|
||||
: null,
|
||||
icon: const Icon(Icons.download),
|
||||
label: Text(AppLocalizations.of(context)!.download),
|
||||
),
|
||||
),
|
||||
if (provider.tileOverlays.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
onPressed: provider.clearOverlays,
|
||||
icon: const Icon(Icons.layers_clear),
|
||||
tooltip: AppLocalizations.of(context)!.clearOverlay,
|
||||
),
|
||||
],
|
||||
if (provider.cacheSizeBytes > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
onPressed: () => _confirmClearCache(provider),
|
||||
icon: const Icon(Icons.delete_forever),
|
||||
tooltip: AppLocalizations.of(context)!.clearCache,
|
||||
),
|
||||
],
|
||||
],
|
||||
if (provider.isDownloading) ...[
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => provider.cancelDownload(),
|
||||
icon: const Icon(Icons.cancel),
|
||||
label: Text(AppLocalizations.of(context)!.cancel),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSharingSheet(BuildContext context, OfflineTilesProvider provider) {
|
||||
// Refresh catalogs and local styles when opening
|
||||
provider.refreshPeerCatalogs();
|
||||
provider.refreshLocalStyles();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (sheetContext) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.65,
|
||||
minChildSize: 0.3,
|
||||
maxChildSize: 0.9,
|
||||
expand: false,
|
||||
builder: (context, scrollController) {
|
||||
return Consumer<OfflineTilesProvider>(
|
||||
builder: (context, provider, _) {
|
||||
return ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Header
|
||||
Center(
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[400],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Tile Sharing',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Server toggle
|
||||
SwitchListTile(
|
||||
title: Text(AppLocalizations.of(context)!.shareMyTiles),
|
||||
subtitle: Text(
|
||||
provider.isServerRunning
|
||||
? 'Other devices can fetch tiles from this device'
|
||||
: 'Start serving cached tiles to nearby devices',
|
||||
),
|
||||
secondary: Icon(
|
||||
provider.isServerRunning
|
||||
? Icons.wifi_tethering
|
||||
: Icons.wifi_tethering_off,
|
||||
),
|
||||
value: provider.isServerRunning,
|
||||
onChanged: (_) => provider.toggleServer(),
|
||||
),
|
||||
|
||||
// My cached maps
|
||||
if (provider.localStyles.isNotEmpty) ...[
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
'My Cached Maps',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
...provider.localStyles.map((style) {
|
||||
final isShowing =
|
||||
provider.coverageStyle?.hash == style.hash;
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.map, size: 20),
|
||||
title: Text(style.displayName),
|
||||
subtitle: Text(
|
||||
'${_formatNumber(style.tileCount)} tiles, '
|
||||
'${_formatBytes(style.sizeBytes)}',
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isShowing
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
size: 20,
|
||||
color: isShowing
|
||||
? Colors.blue
|
||||
: null,
|
||||
),
|
||||
tooltip: isShowing
|
||||
? 'Hide on map'
|
||||
: 'Show on map',
|
||||
onPressed: () {
|
||||
provider.showCoverage(style);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline,
|
||||
size: 20),
|
||||
tooltip: AppLocalizations.of(context)!.delete,
|
||||
onPressed: () => _confirmDeleteStyle(
|
||||
context, provider, style),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
|
||||
// Peers section
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Nearby Devices',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
if (provider.isFetchingCatalogs)
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
tooltip: AppLocalizations.of(context)!.refresh,
|
||||
onPressed: () =>
|
||||
provider.refreshPeerCatalogs(),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add, size: 20),
|
||||
tooltip: AppLocalizations.of(context)!.addPeerManually,
|
||||
onPressed: () =>
|
||||
_showAddPeerDialog(context, provider),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (provider.discoveredPeers.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
'No peers found on the local network.\n'
|
||||
'Make sure other devices have tile sharing enabled.',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Peer catalogs — show each peer and their available styles
|
||||
...provider.peerCatalogs.map((catalog) => _buildPeerCard(
|
||||
context, provider, catalog)),
|
||||
|
||||
// Peers without catalogs yet (just discovered, not queried)
|
||||
...provider.discoveredPeers
|
||||
.where((peer) => !provider.peerCatalogs
|
||||
.any((c) => c.peer == peer))
|
||||
.map((peer) => ListTile(
|
||||
leading: const Icon(Icons.devices),
|
||||
title: Text(peer.ipAddress),
|
||||
subtitle: Text(AppLocalizations.of(context)!.fetchingCatalog),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.remove_circle_outline,
|
||||
size: 20),
|
||||
onPressed: () =>
|
||||
provider.removePeer(peer),
|
||||
),
|
||||
)),
|
||||
|
||||
// Sync progress
|
||||
if (provider.isSyncing || provider.syncStatus.isNotEmpty) ...[
|
||||
const Divider(),
|
||||
if (provider.isSyncing)
|
||||
LinearProgressIndicator(
|
||||
value: provider.syncProgress > 0
|
||||
? provider.syncProgress
|
||||
: null,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
provider.syncStatus,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
if (provider.isSyncing)
|
||||
TextButton(
|
||||
onPressed: () => provider.cancelSync(),
|
||||
child: Text(AppLocalizations.of(context)!.cancel),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPeerCard(
|
||||
BuildContext context,
|
||||
OfflineTilesProvider provider,
|
||||
PeerCatalog catalog,
|
||||
) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.devices, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
catalog.peer.ipAddress,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline, size: 18),
|
||||
onPressed: () => provider.removePeer(catalog.peer),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (catalog.styles.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'No cached tiles on this device',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
...catalog.styles.map((style) {
|
||||
// Check if we already have this style locally
|
||||
final localMatch = provider.localStyles
|
||||
.where((s) => s.hash == style.hash);
|
||||
final localCount =
|
||||
localMatch.isNotEmpty ? localMatch.first.tileCount : 0;
|
||||
final missingTiles = style.tileCount - localCount;
|
||||
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.map_outlined, size: 20),
|
||||
title: Text(style.displayName),
|
||||
subtitle: Text(
|
||||
'${_formatNumber(style.tileCount)} tiles, '
|
||||
'${_formatBytes(style.sizeBytes)}'
|
||||
'${localCount > 0 ? ' (you have ${_formatNumberInline(localCount)})' : ''}',
|
||||
),
|
||||
trailing: missingTiles > 0
|
||||
? TextButton.icon(
|
||||
onPressed: provider.isSyncing
|
||||
? null
|
||||
: () => provider.syncStyleFromPeers(style),
|
||||
icon: const Icon(Icons.download, size: 16),
|
||||
label: Text(
|
||||
missingTiles == style.tileCount
|
||||
? 'Get all'
|
||||
: '+${_formatNumber(missingTiles)}',
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.check_circle,
|
||||
color: Colors.green, size: 20),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatNumberInline(int n) {
|
||||
if (n < 1000) return '$n';
|
||||
if (n < 1000000) return '${(n / 1000).toStringAsFixed(1)}K';
|
||||
return '${(n / 1000000).toStringAsFixed(1)}M';
|
||||
}
|
||||
|
||||
void _fitMapToPolygons(OfflineTilesProvider provider) {
|
||||
if (provider.polygons.isEmpty) return;
|
||||
|
||||
var minLat = 90.0, maxLat = -90.0;
|
||||
var minLng = 180.0, maxLng = -180.0;
|
||||
for (final poly in provider.polygons) {
|
||||
for (final p in poly) {
|
||||
if (p.latitude < minLat) minLat = p.latitude;
|
||||
if (p.latitude > maxLat) maxLat = p.latitude;
|
||||
if (p.longitude < minLng) minLng = p.longitude;
|
||||
if (p.longitude > maxLng) maxLng = p.longitude;
|
||||
}
|
||||
}
|
||||
|
||||
_mapController.fitCamera(
|
||||
CameraFit.bounds(
|
||||
bounds: LatLngBounds(
|
||||
LatLng(minLat, minLng),
|
||||
LatLng(maxLat, maxLng),
|
||||
),
|
||||
padding: const EdgeInsets.all(50),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddPeerDialog(
|
||||
BuildContext context, OfflineTilesProvider provider) {
|
||||
final controller = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.addPeer),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'IP Address',
|
||||
hintText: '192.168.1.100',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
autofocus: true,
|
||||
onSubmitted: (value) {
|
||||
if (value.trim().isNotEmpty) {
|
||||
provider.addManualPeer(value.trim());
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(AppLocalizations.of(context)!.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final ip = controller.text.trim();
|
||||
if (ip.isNotEmpty) {
|
||||
provider.addManualPeer(ip);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Text(AppLocalizations.of(context)!.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDeleteStyle(
|
||||
BuildContext context,
|
||||
OfflineTilesProvider provider,
|
||||
StyleInfo style,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.deleteStyleConfirm(style.displayName)),
|
||||
content: Text(
|
||||
'${_formatNumber(style.tileCount)} tiles, '
|
||||
'${_formatBytes(style.sizeBytes)} will be deleted.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(AppLocalizations.of(context)!.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
provider.deleteStyle(style);
|
||||
Navigator.pop(dialogContext);
|
||||
},
|
||||
child:
|
||||
Text(AppLocalizations.of(context)!.delete, style: const TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmClearCache(OfflineTilesProvider provider) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.clearOfflineCache),
|
||||
content: Text(
|
||||
'This will delete ${_formatBytes(provider.cacheSizeBytes)} of cached tiles.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(AppLocalizations.of(context)!.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
provider.clearCache();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child:
|
||||
Text(AppLocalizations.of(context)!.delete, style: const TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
}
|
||||
|
||||
String _formatNumber(int n) {
|
||||
if (n < 1000) return '$n';
|
||||
if (n < 1000000) return '${(n / 1000).toStringAsFixed(1)}K';
|
||||
return '${(n / 1000000).toStringAsFixed(1)}M';
|
||||
}
|
||||
}
|
||||
|
||||
class _ZoomSelector extends StatelessWidget {
|
||||
final String label;
|
||||
final int value;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
const _ZoomSelector({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: value.toDouble(),
|
||||
min: 0,
|
||||
max: 19,
|
||||
divisions: 19,
|
||||
label: '$value',
|
||||
onChanged: (v) => onChanged(v.round()),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: Text(
|
||||
'$value',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
1110
lib/screens/packet_log_screen.dart
Normal file
1110
lib/screens/packet_log_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
310
lib/screens/profiles_screen.dart
Normal file
310
lib/screens/profiles_screen.dart
Normal file
@@ -0,0 +1,310 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/config_profile.dart';
|
||||
import '../services/profile_manager.dart';
|
||||
import '../services/profile_workspace_coordinator.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class ProfilesScreen extends StatelessWidget {
|
||||
const ProfilesScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<ProfileManager>(
|
||||
builder: (context, profileManager, child) {
|
||||
final profiles = profileManager.visibleProfiles;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(AppLocalizations.of(context)!.profiles),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => _importProfile(context),
|
||||
icon: const Icon(Icons.file_open),
|
||||
tooltip: AppLocalizations.of(context)!.importProfile,
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _createProfile(context),
|
||||
icon: Icon(Icons.add),
|
||||
label: Text(AppLocalizations.of(context)!.newProfile),
|
||||
),
|
||||
body: profiles.isEmpty
|
||||
? Center(
|
||||
child: Text(AppLocalizations.of(context)!.enableProfilesToStartManagingThem),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: profiles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final profile = profiles[index];
|
||||
final isActive =
|
||||
profileManager.activeProfileId == profile.id;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
profile.name,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (isActive)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(AppLocalizations.of(context)!.active),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(_summary(profile)),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: () async {
|
||||
await context
|
||||
.read<ProfileWorkspaceCoordinator>()
|
||||
.openProfile(profile.id);
|
||||
},
|
||||
child: Text(AppLocalizations.of(context)!.open),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: () async {
|
||||
final resolved =
|
||||
profile.id ==
|
||||
ConfigProfile.defaultProfileId
|
||||
? await context
|
||||
.read<
|
||||
ProfileWorkspaceCoordinator
|
||||
>()
|
||||
.snapshotCurrentProfile(
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
)
|
||||
: profile;
|
||||
if (!context.mounted) return;
|
||||
await context
|
||||
.read<ProfileWorkspaceCoordinator>()
|
||||
.exportProfile(resolved);
|
||||
},
|
||||
child: Text(AppLocalizations.of(context)!.share),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) async {
|
||||
switch (value) {
|
||||
case 'duplicate':
|
||||
await context
|
||||
.read<ProfileWorkspaceCoordinator>()
|
||||
.duplicateProfile(profile);
|
||||
break;
|
||||
case 'rename':
|
||||
await _renameProfile(context, profile);
|
||||
break;
|
||||
case 'delete':
|
||||
await _deleteProfile(
|
||||
context,
|
||||
profile,
|
||||
isActive: isActive,
|
||||
);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'duplicate',
|
||||
child: Text(AppLocalizations.of(context)!.duplicate),
|
||||
),
|
||||
if (!profile.isDefault)
|
||||
PopupMenuItem(
|
||||
value: 'rename',
|
||||
child: Text(AppLocalizations.of(context)!.rename),
|
||||
),
|
||||
if (!profile.isDefault)
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text(AppLocalizations.of(context)!.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _summary(ConfigProfile profile) {
|
||||
if (profile.isDefault) {
|
||||
return 'Current app state and history.';
|
||||
}
|
||||
|
||||
final sections = <String>[];
|
||||
if (profile.sections.deviceConfig?.isEmpty == false) {
|
||||
sections.add('Device');
|
||||
}
|
||||
if (profile.sections.channels.isNotEmpty) {
|
||||
sections.add('${profile.sections.channels.length} channels');
|
||||
}
|
||||
if (profile.sections.appSettings?.isEmpty == false) {
|
||||
sections.add('App settings');
|
||||
}
|
||||
if (profile.sections.mapWorkspace?.isEmpty == false) {
|
||||
sections.add('Map workspace');
|
||||
}
|
||||
return sections.isEmpty ? 'Empty profile' : sections.join(' | ');
|
||||
}
|
||||
|
||||
Future<void> _importProfile(BuildContext context) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final errorColor = Theme.of(context).colorScheme.error;
|
||||
final coordinator = context.read<ProfileWorkspaceCoordinator>();
|
||||
try {
|
||||
final imported = await coordinator.importProfileFromFile();
|
||||
if (imported == null) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Could not import profile — invalid file'),
|
||||
backgroundColor: errorColor,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Imported profile "${imported.name}"')),
|
||||
);
|
||||
} catch (_) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Could not import profile — invalid file'),
|
||||
backgroundColor: errorColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteProfile(
|
||||
BuildContext context,
|
||||
ConfigProfile profile, {
|
||||
required bool isActive,
|
||||
}) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete Profile'),
|
||||
content: Text(
|
||||
'Delete "${profile.name}"? Its messages and contacts will be '
|
||||
'permanently erased.'
|
||||
'${isActive ? '\n\nThis profile is currently active. The app will switch to the Default profile.' : ''}',
|
||||
),
|
||||
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 || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
await context.read<ProfileWorkspaceCoordinator>().deleteProfile(profile);
|
||||
}
|
||||
|
||||
Future<void> _createProfile(BuildContext context) async {
|
||||
final controller = TextEditingController();
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.createProfile),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(labelText: 'Profile name'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(AppLocalizations.of(context)!.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
|
||||
child: Text(AppLocalizations.of(context)!.create),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (name == null || name.isEmpty || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
await context.read<ProfileWorkspaceCoordinator>().createProfileFromCurrent(
|
||||
name: name,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _renameProfile(
|
||||
BuildContext context,
|
||||
ConfigProfile profile,
|
||||
) async {
|
||||
final controller = TextEditingController(text: profile.name);
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.renameProfile),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(labelText: 'Profile name'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(AppLocalizations.of(context)!.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
|
||||
child: Text(AppLocalizations.of(context)!.save),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (name == null || name.isEmpty || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
await context.read<ProfileWorkspaceCoordinator>().renameProfile(
|
||||
profile,
|
||||
name,
|
||||
);
|
||||
}
|
||||
}
|
||||
776
lib/screens/repeaters_map_screen.dart
Normal file
776
lib/screens/repeaters_map_screen.dart
Normal file
@@ -0,0 +1,776 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/contact.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../services/mesh_map_nodes_service.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class RepeatersMapScreen extends StatefulWidget {
|
||||
const RepeatersMapScreen({super.key});
|
||||
|
||||
@override
|
||||
State<RepeatersMapScreen> createState() => _RepeatersMapScreenState();
|
||||
}
|
||||
|
||||
class _RepeatersMapScreenState extends State<RepeatersMapScreen> {
|
||||
static const LatLng _fallbackCenter = LatLng(46.0569, 14.5058);
|
||||
static const double _fallbackZoom = 7;
|
||||
static const double _myLocationZoom = 14;
|
||||
|
||||
final flutter_map.MapController _mapController = flutter_map.MapController();
|
||||
|
||||
List<MeshMapNode> _onlineRepeaters = const [];
|
||||
bool _isLoading = true;
|
||||
bool _didFitCamera = false;
|
||||
String? _error;
|
||||
double _currentZoom = _fallbackZoom;
|
||||
LatLng? _myLocation;
|
||||
final Set<String> _addingRepeaters = <String>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRepeaters();
|
||||
}
|
||||
|
||||
Future<void> _loadRepeaters({bool forceRefresh = false}) async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
if (forceRefresh) {
|
||||
_didFitCamera = false;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
final nodes = await MeshMapNodesService.fetchNodes(
|
||||
forceRefresh: forceRefresh,
|
||||
);
|
||||
final repeaters = nodes.where(MeshMapNodesService.isRepeater).toList();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_onlineRepeaters = repeaters;
|
||||
_isLoading = false;
|
||||
});
|
||||
_refreshMyLocation();
|
||||
} catch (error) {
|
||||
final cached = await MeshMapNodesService.loadCachedNodes();
|
||||
final repeaters = cached.where(MeshMapNodesService.isRepeater).toList();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_onlineRepeaters = repeaters;
|
||||
_isLoading = false;
|
||||
_error = repeaters.isEmpty
|
||||
? 'Unable to load repeaters right now.'
|
||||
: 'Showing cached repeaters. Pull to retry.';
|
||||
});
|
||||
_refreshMyLocation();
|
||||
}
|
||||
}
|
||||
|
||||
List<_MapRepeater> _mergeRepeaters({
|
||||
required List<MeshMapNode> onlineNodes,
|
||||
required List<Contact> contactRepeaters,
|
||||
}) {
|
||||
final merged = <String, _MapRepeater>{};
|
||||
|
||||
for (final node in onlineNodes) {
|
||||
merged[node.publicKey] = _MapRepeater(
|
||||
publicKey: node.publicKey,
|
||||
name: node.name,
|
||||
latitude: node.latitude,
|
||||
longitude: node.longitude,
|
||||
isFromContacts: false,
|
||||
isFromOnline: true,
|
||||
);
|
||||
}
|
||||
|
||||
for (final contact in contactRepeaters) {
|
||||
final location = contact.displayLocation;
|
||||
if (location == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final key = contact.publicKeyHex.toLowerCase();
|
||||
final existing = merged[key];
|
||||
merged[key] = _MapRepeater(
|
||||
publicKey: key,
|
||||
name: contact.displayName.isNotEmpty
|
||||
? contact.displayName
|
||||
: existing?.name ?? 'Unknown repeater',
|
||||
latitude: existing?.latitude ?? location.latitude,
|
||||
longitude: existing?.longitude ?? location.longitude,
|
||||
isFromContacts: true,
|
||||
isFromOnline: existing?.isFromOnline ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
final repeaters = merged.values.toList()
|
||||
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
return repeaters;
|
||||
}
|
||||
|
||||
Future<void> _refreshMyLocation() async {
|
||||
try {
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
distanceFilter: 0,
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_myLocation = LatLng(position.latitude, position.longitude);
|
||||
});
|
||||
} catch (_) {
|
||||
// Best effort only.
|
||||
}
|
||||
}
|
||||
|
||||
void _fitCameraIfNeeded(List<_MapRepeater> repeaters) {
|
||||
if (_didFitCamera || repeaters.isEmpty || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _didFitCamera || repeaters.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (repeaters.length == 1) {
|
||||
final repeater = repeaters.first;
|
||||
_mapController.move(LatLng(repeater.latitude, repeater.longitude), 13);
|
||||
_currentZoom = 13;
|
||||
} else {
|
||||
final bounds = flutter_map.LatLngBounds.fromPoints(
|
||||
repeaters
|
||||
.map((node) => LatLng(node.latitude, node.longitude))
|
||||
.toList(),
|
||||
);
|
||||
_mapController.fitCamera(
|
||||
flutter_map.CameraFit.bounds(
|
||||
bounds: bounds,
|
||||
padding: const EdgeInsets.all(48),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_didFitCamera = true;
|
||||
});
|
||||
}
|
||||
|
||||
void _showRepeaterDetails(_MapRepeater repeater) {
|
||||
final isAdding = _addingRepeaters.contains(repeater.publicKey);
|
||||
final canAdd = !repeater.isFromContacts;
|
||||
final isConnected = context
|
||||
.read<ConnectionProvider>()
|
||||
.deviceInfo
|
||||
.isConnected;
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (context) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
repeater.name,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
_InfoLine(
|
||||
label: AppLocalizations.of(context)!.publicKeyLabel,
|
||||
value: repeater.publicKey,
|
||||
monospace: true,
|
||||
),
|
||||
_InfoLine(
|
||||
label: AppLocalizations.of(context)!.coordinates,
|
||||
value:
|
||||
'${repeater.latitude.toStringAsFixed(5)}, ${repeater.longitude.toStringAsFixed(5)}',
|
||||
),
|
||||
_InfoLine(label: AppLocalizations.of(context)!.source, value: repeater.sourceLabel),
|
||||
const SizedBox(height: 12),
|
||||
if (canAdd)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: !isConnected || isAdding
|
||||
? null
|
||||
: () async {
|
||||
Navigator.of(context).pop();
|
||||
await _addRepeaterToContacts(repeater);
|
||||
},
|
||||
icon: isAdding
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.person_add_alt_1),
|
||||
label: Text(
|
||||
!isConnected
|
||||
? 'Connect device to add'
|
||||
: 'Add to contacts',
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: null,
|
||||
icon: Icon(Icons.check_circle),
|
||||
label: Text(AppLocalizations.of(context)!.alreadyInContacts),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addRepeaterToContacts(_MapRepeater repeater) async {
|
||||
if (_addingRepeaters.contains(repeater.publicKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.connectToADeviceBeforeAddingContacts),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_addingRepeaters.add(repeater.publicKey);
|
||||
});
|
||||
|
||||
try {
|
||||
await connectionProvider.getContact(_hexToBytes(repeater.publicKey));
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context)!.addedToContacts(repeater.name))),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context)!.failedToAddName(repeater.name, error.toString()))),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_addingRepeaters.remove(repeater.publicKey);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List _hexToBytes(String hex) {
|
||||
final normalized = hex.replaceAll(':', '').trim().toLowerCase();
|
||||
return Uint8List.fromList(
|
||||
List<int>.generate(
|
||||
normalized.length ~/ 2,
|
||||
(index) => int.parse(
|
||||
normalized.substring(index * 2, index * 2 + 2),
|
||||
radix: 16,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final contactsProvider = context.watch<ContactsProvider>();
|
||||
final repeaters = _mergeRepeaters(
|
||||
onlineNodes: _onlineRepeaters,
|
||||
contactRepeaters: contactsProvider.repeaters,
|
||||
);
|
||||
_fitCameraIfNeeded(repeaters);
|
||||
final clusters = _buildClusters(repeaters);
|
||||
final markers = clusters.map((cluster) {
|
||||
final markerColor = switch (cluster.status) {
|
||||
_ClusterStatus.contactsOnly => const Color(0xFF7C3AED),
|
||||
_ClusterStatus.onlineOnly => const Color(0xFFE8681D),
|
||||
_ClusterStatus.both => const Color(0xFF1B8F4F),
|
||||
_ClusterStatus.mixed => const Color(0xFF2563EB),
|
||||
};
|
||||
|
||||
if (cluster.members.length == 1) {
|
||||
final repeater = cluster.members.first;
|
||||
return flutter_map.Marker(
|
||||
point: cluster.center,
|
||||
width: 80,
|
||||
height: 100,
|
||||
child: GestureDetector(
|
||||
onTap: () => _showRepeaterDetails(repeater),
|
||||
child: _RepeaterMarker(color: markerColor, label: repeater.name),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return flutter_map.Marker(
|
||||
point: cluster.center,
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: GestureDetector(
|
||||
onTap: () => _zoomToCluster(cluster),
|
||||
child: _ClusterMarker(
|
||||
color: markerColor,
|
||||
count: cluster.members.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
final allMarkers = <flutter_map.Marker>[
|
||||
...markers,
|
||||
if (_myLocation != null)
|
||||
flutter_map.Marker(
|
||||
point: _myLocation!,
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2563EB),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 3),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.18),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(AppLocalizations.of(context)!.repeatersMap),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _isLoading
|
||||
? null
|
||||
: () => _loadRepeaters(forceRefresh: true),
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: AppLocalizations.of(context)!.refresh,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
flutter_map.FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: flutter_map.MapOptions(
|
||||
initialCenter: _fallbackCenter,
|
||||
initialZoom: _fallbackZoom,
|
||||
onPositionChanged: (camera, hasGesture) {
|
||||
final nextZoom = camera.zoom;
|
||||
if (nextZoom != _currentZoom && mounted) {
|
||||
setState(() {
|
||||
_currentZoom = nextZoom;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
children: [
|
||||
flutter_map.TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'app.meshcore.sar',
|
||||
),
|
||||
flutter_map.MarkerLayer(markers: allMarkers),
|
||||
],
|
||||
),
|
||||
if (_error != null)
|
||||
Positioned(
|
||||
top: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Material(
|
||||
elevation: 2,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Text(_error!),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isLoading) const Center(child: CircularProgressIndicator()),
|
||||
if (!_isLoading && repeaters.isEmpty)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
_error ?? 'No repeaters available.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (repeaters.isNotEmpty)
|
||||
Positioned(
|
||||
left: 16,
|
||||
top: _error == null ? 16 : 72,
|
||||
child: Material(
|
||||
elevation: 2,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_LegendRow(
|
||||
color: const Color(0xFF7C3AED),
|
||||
label: AppLocalizations.of(context)!.fromContacts,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_LegendRow(
|
||||
color: const Color(0xFFE8681D),
|
||||
label: AppLocalizations.of(context)!.onlineOnly,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_LegendRow(color: const Color(0xFF1B8F4F), label: AppLocalizations.of(context)!.inBoth),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (repeaters.isNotEmpty)
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: Material(
|
||||
elevation: 3,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Text(
|
||||
'${repeaters.length} repeaters',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _jumpToMyLocation,
|
||||
tooltip: AppLocalizations.of(context)!.myLocation,
|
||||
child: const Icon(Icons.my_location),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _jumpToMyLocation() async {
|
||||
await _refreshMyLocation();
|
||||
final myLocation = _myLocation;
|
||||
if (!mounted || myLocation == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_mapController.move(myLocation, _myLocationZoom);
|
||||
setState(() {
|
||||
_currentZoom = _myLocationZoom;
|
||||
});
|
||||
}
|
||||
|
||||
List<_RepeaterCluster> _buildClusters(List<_MapRepeater> repeaters) {
|
||||
if (repeaters.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
if (_currentZoom >= 11.5) {
|
||||
return repeaters
|
||||
.map(
|
||||
(repeater) => _RepeaterCluster(
|
||||
members: [repeater],
|
||||
center: LatLng(repeater.latitude, repeater.longitude),
|
||||
status: repeater.clusterStatus,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
final cellSize = _gridSizeForZoom(_currentZoom);
|
||||
final buckets = <String, List<_MapRepeater>>{};
|
||||
|
||||
for (final repeater in repeaters) {
|
||||
final latBucket = (repeater.latitude / cellSize).floor();
|
||||
final lonBucket = (repeater.longitude / cellSize).floor();
|
||||
final key = '$latBucket:$lonBucket';
|
||||
buckets.putIfAbsent(key, () => <_MapRepeater>[]).add(repeater);
|
||||
}
|
||||
|
||||
return buckets.values.map((members) {
|
||||
final centerLat =
|
||||
members.map((node) => node.latitude).reduce((a, b) => a + b) /
|
||||
members.length;
|
||||
final centerLon =
|
||||
members.map((node) => node.longitude).reduce((a, b) => a + b) /
|
||||
members.length;
|
||||
final statuses = members.map((node) => node.clusterStatus).toSet();
|
||||
return _RepeaterCluster(
|
||||
members: members,
|
||||
center: LatLng(centerLat, centerLon),
|
||||
status: statuses.length == 1 ? statuses.first : _ClusterStatus.mixed,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
double _gridSizeForZoom(double zoom) {
|
||||
if (zoom < 5) return 3.0;
|
||||
if (zoom < 7) return 1.5;
|
||||
if (zoom < 9) return 0.7;
|
||||
if (zoom < 10.5) return 0.28;
|
||||
return 0.12;
|
||||
}
|
||||
|
||||
void _zoomToCluster(_RepeaterCluster cluster) {
|
||||
if (cluster.members.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final bounds = flutter_map.LatLngBounds.fromPoints(
|
||||
cluster.members
|
||||
.map((node) => LatLng(node.latitude, node.longitude))
|
||||
.toList(),
|
||||
);
|
||||
_mapController.fitCamera(
|
||||
flutter_map.CameraFit.bounds(
|
||||
bounds: bounds,
|
||||
padding: const EdgeInsets.all(64),
|
||||
maxZoom: (_currentZoom + 2).clamp(8, 14),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoLine extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final bool monospace;
|
||||
|
||||
const _InfoLine({
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.monospace = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 92,
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: monospace
|
||||
? const TextStyle(fontFamily: 'monospace')
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LegendRow extends StatelessWidget {
|
||||
final Color color;
|
||||
final String label;
|
||||
|
||||
const _LegendRow({required this.color, required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(label),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RepeaterMarker extends StatelessWidget {
|
||||
final Color color;
|
||||
final String label;
|
||||
|
||||
const _RepeaterMarker({required this.color, required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
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: const Icon(Icons.router, color: Colors.white, size: 18),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
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(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClusterMarker extends StatelessWidget {
|
||||
final Color color;
|
||||
final int count;
|
||||
|
||||
const _ClusterMarker({required this.color, required this.count});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.22),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'$count',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _ClusterStatus { contactsOnly, onlineOnly, both, mixed }
|
||||
|
||||
class _RepeaterCluster {
|
||||
final List<_MapRepeater> members;
|
||||
final LatLng center;
|
||||
final _ClusterStatus status;
|
||||
|
||||
const _RepeaterCluster({
|
||||
required this.members,
|
||||
required this.center,
|
||||
required this.status,
|
||||
});
|
||||
}
|
||||
|
||||
class _MapRepeater {
|
||||
final String publicKey;
|
||||
final String name;
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final bool isFromContacts;
|
||||
final bool isFromOnline;
|
||||
|
||||
const _MapRepeater({
|
||||
required this.publicKey,
|
||||
required this.name,
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
required this.isFromContacts,
|
||||
required this.isFromOnline,
|
||||
});
|
||||
|
||||
String get sourceLabel {
|
||||
if (isFromContacts && isFromOnline) return 'Contacts and online';
|
||||
if (isFromContacts) return 'Contacts';
|
||||
return 'Online';
|
||||
}
|
||||
|
||||
_ClusterStatus get clusterStatus {
|
||||
if (isFromContacts && isFromOnline) return _ClusterStatus.both;
|
||||
if (isFromContacts) return _ClusterStatus.contactsOnly;
|
||||
return _ClusterStatus.onlineOnly;
|
||||
}
|
||||
}
|
||||
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: Icon(Icons.download),
|
||||
title: Text(l10n.importFromClipboard),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'export',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.upload),
|
||||
title: Text(l10n.exportToClipboard),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
value: 'reset',
|
||||
child: ListTile(
|
||||
leading: 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),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.noTemplates,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
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: 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1242
lib/screens/sensors_tab.dart
Normal file
1242
lib/screens/sensors_tab.dart
Normal file
File diff suppressed because it is too large
Load Diff
3051
lib/screens/settings_screen.dart
Normal file
3051
lib/screens/settings_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
1007
lib/screens/welcome_wizard_screen.dart
Normal file
1007
lib/screens/welcome_wizard_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
150
lib/services/app_config_snapshot_service.dart
Normal file
150
lib/services/app_config_snapshot_service.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/config_profile.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import 'image_preferences.dart';
|
||||
import 'profiles_feature_service.dart';
|
||||
import 'route_hash_preferences.dart';
|
||||
import 'voice_bitrate_preferences.dart';
|
||||
|
||||
class AppConfigSnapshotService {
|
||||
Future<AppSettingsProfileSection> capture(AppProvider appProvider) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final locationTracking = appProvider.locationTrackingService;
|
||||
return AppSettingsProfileSection(
|
||||
mapEnabled: appProvider.isMapEnabled,
|
||||
contactsEnabled: appProvider.isContactsEnabled,
|
||||
sensorsEnabled: appProvider.isSensorsEnabled,
|
||||
voiceSilenceTrimmingEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
|
||||
voiceBandPassFilterEnabled: appProvider.isVoiceBandPassFilterEnabled,
|
||||
voiceCompressorEnabled: appProvider.isVoiceCompressorEnabled,
|
||||
voiceLimiterEnabled: appProvider.isVoiceLimiterEnabled,
|
||||
voiceAutoGainEnabled: appProvider.isVoiceAutoGainEnabled,
|
||||
voiceEchoCancellationEnabled: appProvider.isVoiceEchoCancellationEnabled,
|
||||
voiceNoiseSuppressionEnabled: appProvider.isVoiceNoiseSuppressionEnabled,
|
||||
messageFontScale: appProvider.messageFontScale,
|
||||
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
|
||||
nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled,
|
||||
voiceBitrate: await VoiceBitratePreferences.getBitrate(),
|
||||
routeHashSize: await RouteHashPreferences.getHashSize(),
|
||||
imageMaxSize: await ImagePreferences.getMaxSize(),
|
||||
imageCompression: await ImagePreferences.getCompression(),
|
||||
imageGrayscale: await ImagePreferences.getGrayscale(),
|
||||
imageUltraMode: await ImagePreferences.getUltraMode(),
|
||||
showRxTxIndicators:
|
||||
prefs.getBool(
|
||||
ProfileStorageScope.scopedKey('show_rx_tx_indicators'),
|
||||
) ??
|
||||
true,
|
||||
fastLocationUpdatesEnabled: locationTracking.fastLocationUpdatesEnabled,
|
||||
fastLocationMovementThresholdMeters:
|
||||
locationTracking.fastLocationMovementThresholdMeters,
|
||||
fastLocationActiveCadenceSeconds:
|
||||
locationTracking.fastLocationActiveCadenceSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> apply(
|
||||
AppSettingsProfileSection? section,
|
||||
AppProvider appProvider,
|
||||
) async {
|
||||
if (section == null || section.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (section.mapEnabled != null) {
|
||||
await appProvider.toggleMapEnabled(section.mapEnabled!);
|
||||
}
|
||||
if (section.contactsEnabled != null) {
|
||||
await appProvider.toggleContactsEnabled(section.contactsEnabled!);
|
||||
}
|
||||
if (section.sensorsEnabled != null) {
|
||||
await appProvider.toggleSensorsEnabled(section.sensorsEnabled!);
|
||||
}
|
||||
if (section.voiceSilenceTrimmingEnabled != null) {
|
||||
await appProvider.toggleVoiceSilenceTrimmingEnabled(
|
||||
section.voiceSilenceTrimmingEnabled!,
|
||||
);
|
||||
}
|
||||
if (section.voiceBandPassFilterEnabled != null) {
|
||||
await appProvider.toggleVoiceBandPassFilterEnabled(
|
||||
section.voiceBandPassFilterEnabled!,
|
||||
);
|
||||
}
|
||||
if (section.voiceCompressorEnabled != null) {
|
||||
await appProvider.toggleVoiceCompressorEnabled(
|
||||
section.voiceCompressorEnabled!,
|
||||
);
|
||||
}
|
||||
if (section.voiceLimiterEnabled != null) {
|
||||
await appProvider.toggleVoiceLimiterEnabled(section.voiceLimiterEnabled!);
|
||||
}
|
||||
if (section.voiceAutoGainEnabled != null) {
|
||||
await appProvider.toggleVoiceAutoGainEnabled(
|
||||
section.voiceAutoGainEnabled!,
|
||||
);
|
||||
}
|
||||
if (section.voiceEchoCancellationEnabled != null) {
|
||||
await appProvider.toggleVoiceEchoCancellationEnabled(
|
||||
section.voiceEchoCancellationEnabled!,
|
||||
);
|
||||
}
|
||||
if (section.voiceNoiseSuppressionEnabled != null) {
|
||||
await appProvider.toggleVoiceNoiseSuppressionEnabled(
|
||||
section.voiceNoiseSuppressionEnabled!,
|
||||
);
|
||||
}
|
||||
if (section.messageFontScale != null) {
|
||||
await appProvider.setMessageFontScale(section.messageFontScale!);
|
||||
}
|
||||
if (section.clearPathOnMaxRetry != null) {
|
||||
await appProvider.toggleClearPathOnMaxRetry(section.clearPathOnMaxRetry!);
|
||||
}
|
||||
if (section.nearestRelayFallbackEnabled != null) {
|
||||
await appProvider.toggleNearestRelayFallbackEnabled(
|
||||
section.nearestRelayFallbackEnabled!,
|
||||
);
|
||||
}
|
||||
if (section.voiceBitrate != null) {
|
||||
await VoiceBitratePreferences.setBitrate(section.voiceBitrate!);
|
||||
}
|
||||
if (section.routeHashSize != null) {
|
||||
await RouteHashPreferences.setHashSize(section.routeHashSize!);
|
||||
}
|
||||
if (section.imageMaxSize != null) {
|
||||
await ImagePreferences.setMaxSize(section.imageMaxSize!);
|
||||
}
|
||||
if (section.imageCompression != null) {
|
||||
await ImagePreferences.setCompression(section.imageCompression!);
|
||||
}
|
||||
if (section.imageGrayscale != null) {
|
||||
await ImagePreferences.setGrayscale(section.imageGrayscale!);
|
||||
}
|
||||
if (section.imageUltraMode != null) {
|
||||
await ImagePreferences.setUltraMode(section.imageUltraMode!);
|
||||
}
|
||||
if (section.showRxTxIndicators != null) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(
|
||||
ProfileStorageScope.scopedKey('show_rx_tx_indicators'),
|
||||
section.showRxTxIndicators!,
|
||||
);
|
||||
}
|
||||
|
||||
final locationTracking = appProvider.locationTrackingService;
|
||||
if (section.fastLocationUpdatesEnabled != null) {
|
||||
locationTracking.fastLocationUpdatesEnabled =
|
||||
section.fastLocationUpdatesEnabled!;
|
||||
}
|
||||
if (section.fastLocationMovementThresholdMeters != null) {
|
||||
locationTracking.fastLocationMovementThresholdMeters =
|
||||
section.fastLocationMovementThresholdMeters!.clamp(10.0, 1000.0);
|
||||
}
|
||||
if (section.fastLocationActiveCadenceSeconds != null) {
|
||||
locationTracking.fastLocationActiveCadenceSeconds =
|
||||
section.fastLocationActiveCadenceSeconds!.clamp(10, 31);
|
||||
}
|
||||
await locationTracking.saveSettings();
|
||||
await appProvider.reloadProfileScopedSettings();
|
||||
}
|
||||
}
|
||||
334
lib/services/background_location_service.dart
Normal file
334
lib/services/background_location_service.dart
Normal file
@@ -0,0 +1,334 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter_background_service/flutter_background_service.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
import 'profiles_feature_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';
|
||||
static const String _notificationChannelId =
|
||||
'meshcore_sar_background_tracking';
|
||||
static const int _notificationId = 9101;
|
||||
|
||||
MeshCoreBleService? _bleService;
|
||||
final FlutterBackgroundService _service = FlutterBackgroundService();
|
||||
bool _serviceConfigured = false;
|
||||
|
||||
String _scopedKey(String baseKey) {
|
||||
return ProfileStorageScope.scopedKey(baseKey);
|
||||
}
|
||||
|
||||
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
|
||||
///
|
||||
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;
|
||||
}
|
||||
|
||||
if (Platform.isIOS && permission != LocationPermission.always) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission != LocationPermission.always) {
|
||||
debugPrint(
|
||||
'⚠️ [BackgroundLocation] iOS background tracking requires Always permission',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Save settings
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_scopedKey(_prefKeyEnabled), true);
|
||||
await prefs.setDouble(_scopedKey(_prefKeyDistance), distanceThreshold);
|
||||
|
||||
await _startForegroundService(distanceThreshold);
|
||||
|
||||
// Start listening to position updates
|
||||
Position? lastPosition;
|
||||
try {
|
||||
_positionSubscription =
|
||||
Geolocator.getPositionStream(
|
||||
locationSettings: _buildLocationSettings(
|
||||
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(
|
||||
_scopedKey(_prefKeyLastLat),
|
||||
position.latitude,
|
||||
);
|
||||
await prefs.setDouble(
|
||||
_scopedKey(_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;
|
||||
await _stopForegroundService();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_scopedKey(_prefKeyEnabled), false);
|
||||
debugPrint('✅ [BackgroundLocation] Tracking stopped');
|
||||
}
|
||||
|
||||
Future<void> _startForegroundService(double distanceThreshold) async {
|
||||
if (!Platform.isAndroid && !Platform.isIOS) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!_serviceConfigured) {
|
||||
await _configureForegroundService();
|
||||
}
|
||||
|
||||
final running = await _service.isRunning();
|
||||
if (!running) {
|
||||
await _service.startService();
|
||||
}
|
||||
|
||||
_service.invoke('trackingUpdate', {
|
||||
'distanceThreshold': distanceThreshold,
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [BackgroundLocation] Foreground service start failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopForegroundService() async {
|
||||
if (!Platform.isAndroid && !Platform.isIOS) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (await _service.isRunning()) {
|
||||
_service.invoke('stopService');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [BackgroundLocation] Foreground service stop failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _configureForegroundService() async {
|
||||
const channel = AndroidNotificationChannel(
|
||||
_notificationChannelId,
|
||||
'Background tracking',
|
||||
description:
|
||||
'Keeps MeshCore SAR location sharing active while the app is in the background.',
|
||||
importance: Importance.low,
|
||||
);
|
||||
|
||||
final notifications = FlutterLocalNotificationsPlugin();
|
||||
await notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.createNotificationChannel(channel);
|
||||
|
||||
await _service.configure(
|
||||
androidConfiguration: AndroidConfiguration(
|
||||
onStart: meshCoreSarBackgroundServiceStart,
|
||||
autoStart: false,
|
||||
autoStartOnBoot: false,
|
||||
isForegroundMode: true,
|
||||
notificationChannelId: _notificationChannelId,
|
||||
initialNotificationTitle: 'MeshCore SAR',
|
||||
initialNotificationContent: 'Maintaining background tracking',
|
||||
foregroundServiceNotificationId: _notificationId,
|
||||
foregroundServiceTypes: [AndroidForegroundType.location],
|
||||
),
|
||||
iosConfiguration: IosConfiguration(
|
||||
autoStart: false,
|
||||
onForeground: meshCoreSarBackgroundServiceStart,
|
||||
onBackground: meshCoreSarBackgroundServiceIos,
|
||||
),
|
||||
);
|
||||
_serviceConfigured = true;
|
||||
}
|
||||
|
||||
/// 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(_scopedKey(_prefKeyDistance), distance);
|
||||
debugPrint(
|
||||
'📏 [BackgroundLocation] Distance threshold updated to ${distance}m',
|
||||
);
|
||||
|
||||
// Restart tracking if currently enabled
|
||||
final isEnabled = prefs.getBool(_scopedKey(_prefKeyEnabled)) ?? false;
|
||||
if (isEnabled && _bleService != null) {
|
||||
await stopTracking();
|
||||
await startTracking(distanceThreshold: distance);
|
||||
}
|
||||
}
|
||||
|
||||
LocationSettings _buildLocationSettings({int distanceFilter = 10}) {
|
||||
if (Platform.isIOS) {
|
||||
return AppleSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
activityType: ActivityType.fitness,
|
||||
allowBackgroundLocationUpdates: true,
|
||||
distanceFilter: distanceFilter,
|
||||
pauseLocationUpdatesAutomatically: false,
|
||||
showBackgroundLocationIndicator: true,
|
||||
);
|
||||
}
|
||||
|
||||
return LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
distanceFilter: distanceFilter,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<bool> meshCoreSarBackgroundServiceIos(ServiceInstance service) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
return true;
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void meshCoreSarBackgroundServiceStart(ServiceInstance service) {
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
|
||||
if (service is AndroidServiceInstance) {
|
||||
service.setAsForegroundService();
|
||||
service.setForegroundNotificationInfo(
|
||||
title: 'MeshCore SAR',
|
||||
content: 'Maintaining background tracking',
|
||||
);
|
||||
}
|
||||
|
||||
service.on('trackingUpdate').listen((event) {
|
||||
if (service is AndroidServiceInstance) {
|
||||
final threshold = event?['distanceThreshold'];
|
||||
final suffix = threshold is num
|
||||
? ' (${threshold.toStringAsFixed(0)} m updates)'
|
||||
: '';
|
||||
service.setForegroundNotificationInfo(
|
||||
title: 'MeshCore SAR',
|
||||
content: 'Maintaining background tracking$suffix',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
service.on('stopService').listen((event) {
|
||||
service.stopSelf();
|
||||
});
|
||||
}
|
||||
178
lib/services/bthome_met_history.dart
Normal file
178
lib/services/bthome_met_history.dart
Normal file
@@ -0,0 +1,178 @@
|
||||
import '../models/contact.dart';
|
||||
|
||||
enum BTHomeMetMeasurement {
|
||||
temperature(1, 'Temperature', '°C'),
|
||||
humidity(2, 'Humidity', '%'),
|
||||
windSpeed(3, 'Wind speed', 'm/s'),
|
||||
gust(4, 'Wind gust', 'm/s'),
|
||||
rain(5, 'Rain', 'mm');
|
||||
|
||||
const BTHomeMetMeasurement(this.id, this.label, this.unit);
|
||||
|
||||
final int id;
|
||||
final String label;
|
||||
final String unit;
|
||||
|
||||
static BTHomeMetMeasurement? fromId(int id) {
|
||||
for (final value in BTHomeMetMeasurement.values) {
|
||||
if (value.id == id) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class BTHomeMetHistoryPage {
|
||||
const BTHomeMetHistoryPage({
|
||||
required this.measurement,
|
||||
required this.page,
|
||||
required this.values,
|
||||
});
|
||||
|
||||
final BTHomeMetMeasurement measurement;
|
||||
final int page;
|
||||
final List<double> values;
|
||||
|
||||
double? get latest => values.isEmpty ? null : values.last;
|
||||
double? get minimum => values.isEmpty
|
||||
? null
|
||||
: values.reduce((left, right) => left < right ? left : right);
|
||||
double? get maximum => values.isEmpty
|
||||
? null
|
||||
: values.reduce((left, right) => left > right ? left : right);
|
||||
}
|
||||
|
||||
class BTHomeMetHistoryFormatException implements Exception {
|
||||
const BTHomeMetHistoryFormatException(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class BTHomeMetHistoryParser {
|
||||
static BTHomeMetHistoryPage parse(String text) {
|
||||
final parts = text
|
||||
.trim()
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.toList(growable: false);
|
||||
if (parts.length < 3) {
|
||||
throw const BTHomeMetHistoryFormatException(
|
||||
'MET history response is too short.',
|
||||
);
|
||||
}
|
||||
|
||||
final measurementId = int.tryParse(parts[0]);
|
||||
final page = int.tryParse(parts[1]);
|
||||
final count = int.tryParse(parts[2]);
|
||||
if (measurementId == null || page == null || count == null) {
|
||||
throw const BTHomeMetHistoryFormatException(
|
||||
'MET history header is invalid.',
|
||||
);
|
||||
}
|
||||
|
||||
final measurement = BTHomeMetMeasurement.fromId(measurementId);
|
||||
if (measurement == null) {
|
||||
throw BTHomeMetHistoryFormatException(
|
||||
'Unsupported MET history measurement id: $measurementId',
|
||||
);
|
||||
}
|
||||
if (count < 0) {
|
||||
throw const BTHomeMetHistoryFormatException(
|
||||
'MET history sample count is invalid.',
|
||||
);
|
||||
}
|
||||
if (parts.length != count + 3) {
|
||||
throw BTHomeMetHistoryFormatException(
|
||||
'MET history sample count mismatch: expected $count values, got ${parts.length - 3}.',
|
||||
);
|
||||
}
|
||||
|
||||
final values = <double>[];
|
||||
for (final part in parts.skip(3)) {
|
||||
final value = double.tryParse(part);
|
||||
if (value == null) {
|
||||
throw BTHomeMetHistoryFormatException(
|
||||
'Invalid MET history sample value: $part',
|
||||
);
|
||||
}
|
||||
values.add(value);
|
||||
}
|
||||
|
||||
return BTHomeMetHistoryPage(
|
||||
measurement: measurement,
|
||||
page: page,
|
||||
values: List<double>.unmodifiable(values),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isTruthyCapabilityValue(Object? value) {
|
||||
if (value is num) {
|
||||
return value > 0;
|
||||
}
|
||||
if (value is bool) {
|
||||
return value;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _hasBTHomeMetCapability(Contact? contact) {
|
||||
final extraSensorData = contact?.telemetry?.extraSensorData;
|
||||
if (extraSensorData == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// MET history is only enabled when the node advertises an explicit
|
||||
// capability marker on channel 1. Accept a small set of channel-1 marker
|
||||
// keys so the app remains tolerant while firmware-side encoding settles.
|
||||
const capabilityKeys = <String>[
|
||||
'met_capability',
|
||||
'met_capability_1',
|
||||
'generic_sensor_1',
|
||||
'light_level_1',
|
||||
];
|
||||
|
||||
for (final key in capabilityKeys) {
|
||||
if (_isTruthyCapabilityValue(extraSensorData[key])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
List<BTHomeMetMeasurement> bTHomeMetMeasurementsForContact(Contact? contact) {
|
||||
final telemetry = contact?.telemetry;
|
||||
if (telemetry == null || !_hasBTHomeMetCapability(contact)) {
|
||||
return const <BTHomeMetMeasurement>[];
|
||||
}
|
||||
|
||||
final measurements = <BTHomeMetMeasurement>[
|
||||
if (telemetry.temperature != null) BTHomeMetMeasurement.temperature,
|
||||
if (telemetry.humidity != null) BTHomeMetMeasurement.humidity,
|
||||
];
|
||||
|
||||
final extraSensorData = telemetry.extraSensorData;
|
||||
if (extraSensorData != null) {
|
||||
if (extraSensorData.keys.any(
|
||||
(key) => key.startsWith('speed_') || key.startsWith('signed_speed_'),
|
||||
)) {
|
||||
measurements.add(BTHomeMetMeasurement.windSpeed);
|
||||
}
|
||||
if (extraSensorData.keys.any((key) => key.startsWith('gust_'))) {
|
||||
measurements.add(BTHomeMetMeasurement.gust);
|
||||
}
|
||||
if (extraSensorData.keys.any((key) => key.startsWith('rain_'))) {
|
||||
measurements.add(BTHomeMetMeasurement.rain);
|
||||
}
|
||||
}
|
||||
|
||||
return List<BTHomeMetMeasurement>.unmodifiable(measurements);
|
||||
}
|
||||
|
||||
bool supportsBTHomeMetHistory(Contact? contact) =>
|
||||
bTHomeMetMeasurementsForContact(contact).isNotEmpty;
|
||||
53
lib/services/build_info_service.dart
Normal file
53
lib/services/build_info_service.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, debugPrint, kIsWeb;
|
||||
import 'package:flutter/services.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 (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
942
lib/services/cayenne_lpp_parser.dart
Normal file
942
lib/services/cayenne_lpp_parser.dart
Normal file
@@ -0,0 +1,942 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
|
||||
/// Cayenne LPP (Low Power Payload) data parser
|
||||
/// Used for decoding telemetry sensor data from MeshCore devices
|
||||
class CayenneLppParser {
|
||||
static const int _selfTelemetryChannel = 1;
|
||||
static const int _lppGenericSensor = 100;
|
||||
static const int _lppCurrent = 117;
|
||||
static const int _lppFrequency = 118;
|
||||
static const int _lppPercentage = 120;
|
||||
static const int _lppAltitude = 121;
|
||||
static const int _lppConcentration = 125;
|
||||
static const int _lppPower = 128;
|
||||
static const int _lppSpeed = 129;
|
||||
static const int _lppDistance = 130;
|
||||
static const int _lppEnergy = 131;
|
||||
static const int _lppDirection = 132;
|
||||
static const int _lppUnixTime = 133;
|
||||
static const int _lppColour = 135;
|
||||
static const int _lppGust = 137;
|
||||
static const int _lppDewPoint = 138;
|
||||
static const int _lppRain = 139;
|
||||
static const int _lppSwitch = 142;
|
||||
static const int _lppBinaryBool = 143;
|
||||
static const int _lppBinaryPowerSwitch = 144;
|
||||
static const int _lppBinaryOpen = 145;
|
||||
static const int _lppBinaryBatteryLow = 146;
|
||||
static const int _lppBinaryCharging = 147;
|
||||
static const int _lppBinaryCarbonMonoxide = 148;
|
||||
static const int _lppBinaryCold = 149;
|
||||
static const int _lppBinaryConnectivity = 150;
|
||||
static const int _lppBinaryDoor = 151;
|
||||
static const int _lppBinaryGarageDoor = 152;
|
||||
static const int _lppBinaryGas = 153;
|
||||
static const int _lppBinaryHeat = 154;
|
||||
static const int _lppBinaryLight = 155;
|
||||
static const int _lppBinaryLock = 156;
|
||||
static const int _lppBinaryMoisture = 157;
|
||||
static const int _lppBinaryMotion = 158;
|
||||
static const int _lppBinaryMoving = 159;
|
||||
static const int _lppBinaryOccupancy = 160;
|
||||
static const int _lppBinaryPlug = 161;
|
||||
static const int _lppBinaryPresence = 162;
|
||||
static const int _lppBinaryProblem = 163;
|
||||
static const int _lppBinaryRunning = 164;
|
||||
static const int _lppBinarySafety = 165;
|
||||
static const int _lppBinarySmoke = 166;
|
||||
static const int _lppBinarySound = 167;
|
||||
static const int _lppBinaryTamper = 168;
|
||||
static const int _lppBinaryVibration = 169;
|
||||
static const int _lppBinaryWindow = 170;
|
||||
static const int _lppButtonEvent = 171;
|
||||
static const int _lppDimmer = 172;
|
||||
static const int _lppUv = 173;
|
||||
static const int _lppLightLevel = 174;
|
||||
static const int _lppPm25 = 175;
|
||||
static const int _lppPm10 = 176;
|
||||
static const int _lppCo2 = 177;
|
||||
static const int _lppTvoc = 178;
|
||||
static const int _lppRpm = 179;
|
||||
static const int _lppConductivity = 180;
|
||||
static const int _lppRotation = 181;
|
||||
static const int _lppDuration = 182;
|
||||
static const int _lppAcceleration = 183;
|
||||
static const int _lppGyroRate = 184;
|
||||
static const int _lppVolume = 185;
|
||||
static const int _lppFlowRate = 186;
|
||||
static const int _lppVolumeStorage = 187;
|
||||
static const int _lppWater = 188;
|
||||
static const int _lppGasVolume = 189;
|
||||
static const int _lppMass = 190;
|
||||
static const int _lppSignedSpeed = 191;
|
||||
static const int _lppSignedPower = 192;
|
||||
static const int _lppSignedCurrent = 193;
|
||||
|
||||
/// 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? deferredBatteryMilliVolts;
|
||||
double? deferredBatteryPercentage;
|
||||
double? temperature;
|
||||
double? humidity;
|
||||
double? pressure;
|
||||
final extraSensorData = <String, dynamic>{};
|
||||
bool sawNonSelfChannel = false;
|
||||
|
||||
int fieldCount = 0;
|
||||
while (reader.hasRemaining) {
|
||||
if (fieldCount > 0 &&
|
||||
_isZeroPaddedTail(data, reader.remainingBytesCount)) {
|
||||
debugPrint(
|
||||
' Detected zero-padded telemetry tail, stopping parse at position '
|
||||
'${data.length - reader.remainingBytesCount}',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
fieldCount++;
|
||||
debugPrint(
|
||||
' [Field $fieldCount] Position: ${data.length - reader.remainingBytesCount}',
|
||||
);
|
||||
|
||||
final channel = reader.readByte();
|
||||
debugPrint(' Channel: $channel');
|
||||
if (channel != _selfTelemetryChannel) {
|
||||
sawNonSelfChannel = true;
|
||||
}
|
||||
|
||||
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');
|
||||
if (_isDedicatedBatteryChannel(channel)) {
|
||||
batteryMilliVolts = value * 1000;
|
||||
batteryPercentage = _calculateBatteryPercentage(value);
|
||||
extraSensorData[_sourceChannelKey('battery')] = channel;
|
||||
extraSensorData[_sourceChannelKey('voltage')] = channel;
|
||||
debugPrint(
|
||||
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
||||
);
|
||||
} else if (_isDeferredBatteryChannel(channel)) {
|
||||
deferredBatteryMilliVolts = value * 1000;
|
||||
deferredBatteryPercentage = _calculateBatteryPercentage(value);
|
||||
extraSensorData['analog_input_$channel'] = value;
|
||||
} else {
|
||||
extraSensorData['analog_input_$channel'] = value;
|
||||
}
|
||||
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().toDouble();
|
||||
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();
|
||||
final value = rawValue / 10.0;
|
||||
debugPrint(' Temperature (raw): $rawValue');
|
||||
debugPrint(' Temperature: ${value.toStringAsFixed(1)}°C');
|
||||
if (channel == _selfTelemetryChannel) {
|
||||
temperature = value;
|
||||
extraSensorData[_sourceChannelKey('temperature')] = channel;
|
||||
} else {
|
||||
extraSensorData['temperature_$channel'] = value;
|
||||
if (temperature == null) {
|
||||
temperature = value;
|
||||
extraSensorData[_sourceChannelKey('temperature')] = channel;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppHumiditySensor:
|
||||
final rawValue = reader.readByte();
|
||||
final value = rawValue / 2.0;
|
||||
debugPrint(' Humidity (raw): $rawValue');
|
||||
debugPrint(' Humidity: ${value.toStringAsFixed(1)}%');
|
||||
if (channel == _selfTelemetryChannel) {
|
||||
humidity = value;
|
||||
extraSensorData[_sourceChannelKey('humidity')] = channel;
|
||||
} else {
|
||||
extraSensorData['humidity_$channel'] = value;
|
||||
if (humidity == null) {
|
||||
humidity = value;
|
||||
extraSensorData[_sourceChannelKey('humidity')] = channel;
|
||||
}
|
||||
}
|
||||
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();
|
||||
final value = rawValue / 10.0;
|
||||
debugPrint(' Barometer (raw): $rawValue');
|
||||
debugPrint(' Barometer: ${value.toStringAsFixed(1)} hPa');
|
||||
if (channel == _selfTelemetryChannel) {
|
||||
pressure = value;
|
||||
extraSensorData[_sourceChannelKey('pressure')] = channel;
|
||||
} else {
|
||||
extraSensorData['pressure_$channel'] = value;
|
||||
if (pressure == null) {
|
||||
pressure = value;
|
||||
extraSensorData[_sourceChannelKey('pressure')] = channel;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppVoltageSensor:
|
||||
final rawValue = reader.readUInt16BE();
|
||||
final value = rawValue / 100.0;
|
||||
debugPrint(' Voltage (raw): $rawValue');
|
||||
debugPrint(' Voltage: ${value}V');
|
||||
if (_isDedicatedBatteryChannel(channel)) {
|
||||
batteryMilliVolts = value * 1000;
|
||||
batteryPercentage = _calculateBatteryPercentage(value);
|
||||
extraSensorData[_sourceChannelKey('battery')] = channel;
|
||||
extraSensorData[_sourceChannelKey('voltage')] = channel;
|
||||
debugPrint(
|
||||
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
||||
);
|
||||
} else if (_isDeferredBatteryChannel(channel)) {
|
||||
deferredBatteryMilliVolts = value * 1000;
|
||||
deferredBatteryPercentage = _calculateBatteryPercentage(value);
|
||||
extraSensorData['voltage_$channel'] = value;
|
||||
} else {
|
||||
extraSensorData['voltage_$channel'] = value;
|
||||
}
|
||||
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[_sourceChannelKey('gps')] = channel;
|
||||
extraSensorData['altitude_$channel'] = alt;
|
||||
break;
|
||||
|
||||
case _lppGenericSensor:
|
||||
final value = _readUInt32BE(reader).toDouble();
|
||||
debugPrint(' Generic Sensor: $value');
|
||||
extraSensorData['generic_sensor_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppCurrent:
|
||||
final rawValue = reader.readInt16BE();
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Current (raw): $rawValue');
|
||||
debugPrint(' Current: ${value}A');
|
||||
extraSensorData['current_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppFrequency:
|
||||
final value = _readUInt32BE(reader).toDouble();
|
||||
debugPrint(' Frequency: ${value}Hz');
|
||||
extraSensorData['frequency_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppPercentage:
|
||||
final value = reader.readByte().toDouble();
|
||||
debugPrint(' Percentage: $value%');
|
||||
if (_isDedicatedBatteryChannel(channel) || _isDeferredBatteryChannel(channel)) {
|
||||
batteryPercentage = value;
|
||||
extraSensorData[_sourceChannelKey('battery')] = channel;
|
||||
} else {
|
||||
extraSensorData['percentage_$channel'] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case _lppAltitude:
|
||||
final rawValue = reader.readInt16BE();
|
||||
final value = rawValue.toDouble();
|
||||
debugPrint(' Altitude: ${value}m');
|
||||
extraSensorData['altitude_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppConcentration:
|
||||
final value = reader.readUInt16BE().toDouble();
|
||||
debugPrint(' Concentration: ${value}ppm');
|
||||
extraSensorData['concentration_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppPower:
|
||||
final value = reader.readUInt16BE().toDouble();
|
||||
debugPrint(' Power: ${value}W');
|
||||
extraSensorData['power_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppSpeed:
|
||||
final rawValue = reader.readUInt16BE();
|
||||
final value = rawValue / 100.0;
|
||||
debugPrint(' Speed: ${value}m/s');
|
||||
extraSensorData['speed_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppDistance:
|
||||
final rawValue = _readUInt32BE(reader);
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Distance: ${value}m');
|
||||
extraSensorData['distance_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppEnergy:
|
||||
final rawValue = _readUInt32BE(reader);
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Energy: ${value}kWh');
|
||||
extraSensorData['energy_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppDirection:
|
||||
final value = reader.readUInt16BE().toDouble();
|
||||
debugPrint(' Direction: $value°');
|
||||
extraSensorData['direction_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppUnixTime:
|
||||
final value = _readUInt32BE(reader);
|
||||
debugPrint(' Unix time: $value');
|
||||
extraSensorData['unixtime_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppColour:
|
||||
final red = reader.readByte();
|
||||
final green = reader.readByte();
|
||||
final blue = reader.readByte();
|
||||
debugPrint(' Colour: r=$red, g=$green, b=$blue');
|
||||
extraSensorData['colour_$channel'] = {
|
||||
'r': red,
|
||||
'g': green,
|
||||
'b': blue,
|
||||
};
|
||||
break;
|
||||
|
||||
case _lppGust:
|
||||
final rawValue = reader.readUInt16BE();
|
||||
final value = rawValue / 100.0;
|
||||
debugPrint(' Gust: ${value}m/s');
|
||||
extraSensorData['gust_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppDewPoint:
|
||||
final rawValue = reader.readInt16BE();
|
||||
final value = rawValue / 10.0;
|
||||
debugPrint(' Dew point: ${value.toStringAsFixed(1)}°C');
|
||||
extraSensorData['dew_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppRain:
|
||||
final rawValue = reader.readUInt16BE();
|
||||
final value = rawValue / 10.0;
|
||||
debugPrint(' Rain: ${value}mm');
|
||||
extraSensorData['rain_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppBinaryBool:
|
||||
case _lppBinaryPowerSwitch:
|
||||
case _lppBinaryOpen:
|
||||
case _lppBinaryBatteryLow:
|
||||
case _lppBinaryCharging:
|
||||
case _lppBinaryCarbonMonoxide:
|
||||
case _lppBinaryCold:
|
||||
case _lppBinaryConnectivity:
|
||||
case _lppBinaryDoor:
|
||||
case _lppBinaryGarageDoor:
|
||||
case _lppBinaryGas:
|
||||
case _lppBinaryHeat:
|
||||
case _lppBinaryLight:
|
||||
case _lppBinaryLock:
|
||||
case _lppBinaryMoisture:
|
||||
case _lppBinaryMotion:
|
||||
case _lppBinaryMoving:
|
||||
case _lppBinaryOccupancy:
|
||||
case _lppBinaryPlug:
|
||||
case _lppBinaryPresence:
|
||||
case _lppBinaryProblem:
|
||||
case _lppBinaryRunning:
|
||||
case _lppBinarySafety:
|
||||
case _lppBinarySmoke:
|
||||
case _lppBinarySound:
|
||||
case _lppBinaryTamper:
|
||||
case _lppBinaryVibration:
|
||||
case _lppBinaryWindow:
|
||||
final value = reader.readByte();
|
||||
debugPrint(' Binary state: $value');
|
||||
extraSensorData['${_binaryMetricKeyForType(type)}_$channel'] =
|
||||
value;
|
||||
break;
|
||||
|
||||
case _lppButtonEvent:
|
||||
final value = reader.readByte();
|
||||
debugPrint(' Button event: $value');
|
||||
extraSensorData['button_event_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppDimmer:
|
||||
final value = _readInt8(reader);
|
||||
debugPrint(' Dimmer: $value');
|
||||
extraSensorData['dimmer_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppUv:
|
||||
final value = reader.readByte() / 10.0;
|
||||
debugPrint(' UV index: $value');
|
||||
extraSensorData['uv_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppLightLevel:
|
||||
final value = reader.readByte();
|
||||
debugPrint(' Light level: $value');
|
||||
extraSensorData['light_level_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppPm25:
|
||||
final value = reader.readUInt16BE().toDouble();
|
||||
debugPrint(' PM2.5: $value');
|
||||
extraSensorData['pm25_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppPm10:
|
||||
final value = reader.readUInt16BE().toDouble();
|
||||
debugPrint(' PM10: $value');
|
||||
extraSensorData['pm10_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppCo2:
|
||||
final value = reader.readUInt16BE().toDouble();
|
||||
debugPrint(' CO2: $value');
|
||||
extraSensorData['co2_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppTvoc:
|
||||
final value = reader.readUInt16BE().toDouble();
|
||||
debugPrint(' TVOC: $value');
|
||||
extraSensorData['tvoc_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppRpm:
|
||||
final value = reader.readUInt16BE().toDouble();
|
||||
debugPrint(' RPM: $value');
|
||||
extraSensorData['rpm_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppConductivity:
|
||||
final value = reader.readUInt16BE().toDouble();
|
||||
debugPrint(' Conductivity: $value');
|
||||
extraSensorData['conductivity_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppRotation:
|
||||
final rawValue = reader.readInt16BE();
|
||||
final value = rawValue / 10.0;
|
||||
debugPrint(' Rotation: $value');
|
||||
extraSensorData['rotation_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppDuration:
|
||||
final rawValue = _readUInt32BE(reader);
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Duration: $value s');
|
||||
extraSensorData['duration_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppAcceleration:
|
||||
final rawValue = _readInt32BE(reader);
|
||||
final value = rawValue / 1000000.0;
|
||||
debugPrint(' Acceleration: $value');
|
||||
extraSensorData['acceleration_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppGyroRate:
|
||||
final rawValue = _readInt32BE(reader);
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Gyro rate: $value');
|
||||
extraSensorData['gyro_rate_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppVolume:
|
||||
final rawValue = _readUInt32BE(reader);
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Volume: $value');
|
||||
extraSensorData['volume_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppFlowRate:
|
||||
final rawValue = _readUInt32BE(reader);
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Flow rate: $value');
|
||||
extraSensorData['flow_rate_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppVolumeStorage:
|
||||
final rawValue = _readUInt32BE(reader);
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Storage volume: $value');
|
||||
extraSensorData['volume_storage_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppWater:
|
||||
final rawValue = _readUInt32BE(reader);
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Water: $value');
|
||||
extraSensorData['water_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppGasVolume:
|
||||
final rawValue = _readUInt32BE(reader);
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Gas volume: $value');
|
||||
extraSensorData['gas_volume_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppMass:
|
||||
final rawValue = _readUInt32BE(reader);
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Mass: $value');
|
||||
extraSensorData['mass_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppSignedSpeed:
|
||||
final rawValue = _readInt32BE(reader);
|
||||
final value = rawValue / 1000000.0;
|
||||
debugPrint(' Signed speed: $value');
|
||||
extraSensorData['signed_speed_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppSignedPower:
|
||||
final rawValue = _readInt32BE(reader);
|
||||
final value = rawValue / 100.0;
|
||||
debugPrint(' Signed power: $value');
|
||||
extraSensorData['signed_power_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppSignedCurrent:
|
||||
final rawValue = _readInt32BE(reader);
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Signed current: $value');
|
||||
extraSensorData['signed_current_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppSwitch:
|
||||
final value = reader.readByte();
|
||||
debugPrint(' Switch: $value');
|
||||
extraSensorData['switch_$channel'] = value;
|
||||
break;
|
||||
|
||||
default:
|
||||
final size = _payloadSizeForType(type);
|
||||
if (size == null || reader.remainingBytesCount < size) {
|
||||
debugPrint(
|
||||
' ⚠️ Unknown type $type with unsupported size, stopping parse',
|
||||
);
|
||||
reader.skip(reader.remainingBytesCount);
|
||||
break;
|
||||
}
|
||||
debugPrint(' ⚠️ Unknown type $type, skipping $size bytes');
|
||||
reader.skip(size);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint(' ❌ Parsing error: $e');
|
||||
// If we encounter a parsing error, break and return what we have
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (batteryMilliVolts == null &&
|
||||
batteryPercentage == null &&
|
||||
deferredBatteryMilliVolts != null &&
|
||||
deferredBatteryPercentage != null &&
|
||||
!sawNonSelfChannel) {
|
||||
batteryMilliVolts = deferredBatteryMilliVolts;
|
||||
batteryPercentage = deferredBatteryPercentage;
|
||||
extraSensorData[_sourceChannelKey('battery')] = _selfTelemetryChannel;
|
||||
extraSensorData[_sourceChannelKey('voltage')] = _selfTelemetryChannel;
|
||||
debugPrint(
|
||||
' Promoted self-channel voltage to battery: '
|
||||
'${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
static bool _isDedicatedBatteryChannel(int channel) => channel == 0;
|
||||
|
||||
static bool _isDeferredBatteryChannel(int channel) =>
|
||||
channel == _selfTelemetryChannel;
|
||||
|
||||
static int _readUInt32BE(BufferReader reader) {
|
||||
final bytes = reader.readBytes(4);
|
||||
return (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3];
|
||||
}
|
||||
|
||||
static int _readInt32BE(BufferReader reader) {
|
||||
final value = _readUInt32BE(reader);
|
||||
if ((value & 0x80000000) != 0) {
|
||||
return value - 0x100000000;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static int _readInt8(BufferReader reader) {
|
||||
final value = reader.readByte();
|
||||
if ((value & 0x80) != 0) {
|
||||
return value - 0x100;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static String _sourceChannelKey(String fieldKey) =>
|
||||
'__source_channel:$fieldKey';
|
||||
|
||||
static String _binaryMetricKeyForType(int type) {
|
||||
switch (type) {
|
||||
case _lppBinaryBool:
|
||||
return 'binary_bool';
|
||||
case _lppBinaryPowerSwitch:
|
||||
return 'binary_power_switch';
|
||||
case _lppBinaryOpen:
|
||||
return 'binary_open';
|
||||
case _lppBinaryBatteryLow:
|
||||
return 'binary_battery_low';
|
||||
case _lppBinaryCharging:
|
||||
return 'binary_charging';
|
||||
case _lppBinaryCarbonMonoxide:
|
||||
return 'binary_carbon_monoxide';
|
||||
case _lppBinaryCold:
|
||||
return 'binary_cold';
|
||||
case _lppBinaryConnectivity:
|
||||
return 'binary_connectivity';
|
||||
case _lppBinaryDoor:
|
||||
return 'binary_door';
|
||||
case _lppBinaryGarageDoor:
|
||||
return 'binary_garage_door';
|
||||
case _lppBinaryGas:
|
||||
return 'binary_gas';
|
||||
case _lppBinaryHeat:
|
||||
return 'binary_heat';
|
||||
case _lppBinaryLight:
|
||||
return 'binary_light';
|
||||
case _lppBinaryLock:
|
||||
return 'binary_lock';
|
||||
case _lppBinaryMoisture:
|
||||
return 'binary_moisture';
|
||||
case _lppBinaryMotion:
|
||||
return 'binary_motion';
|
||||
case _lppBinaryMoving:
|
||||
return 'binary_moving';
|
||||
case _lppBinaryOccupancy:
|
||||
return 'binary_occupancy';
|
||||
case _lppBinaryPlug:
|
||||
return 'binary_plug';
|
||||
case _lppBinaryPresence:
|
||||
return 'binary_presence';
|
||||
case _lppBinaryProblem:
|
||||
return 'binary_problem';
|
||||
case _lppBinaryRunning:
|
||||
return 'binary_running';
|
||||
case _lppBinarySafety:
|
||||
return 'binary_safety';
|
||||
case _lppBinarySmoke:
|
||||
return 'binary_smoke';
|
||||
case _lppBinarySound:
|
||||
return 'binary_sound';
|
||||
case _lppBinaryTamper:
|
||||
return 'binary_tamper';
|
||||
case _lppBinaryVibration:
|
||||
return 'binary_vibration';
|
||||
case _lppBinaryWindow:
|
||||
return 'binary_window';
|
||||
}
|
||||
return 'binary_state';
|
||||
}
|
||||
|
||||
static int? _payloadSizeForType(int type) {
|
||||
if (type >= _lppBinaryBool && type <= _lppBinaryWindow) {
|
||||
return 1;
|
||||
}
|
||||
switch (type) {
|
||||
case MeshCoreConstants.lppDigitalInput:
|
||||
case MeshCoreConstants.lppDigitalOutput:
|
||||
case MeshCoreConstants.lppPresenceSensor:
|
||||
case MeshCoreConstants.lppHumiditySensor:
|
||||
case _lppPercentage:
|
||||
case _lppSwitch:
|
||||
case _lppButtonEvent:
|
||||
case _lppDimmer:
|
||||
case _lppUv:
|
||||
case _lppLightLevel:
|
||||
return 1;
|
||||
case MeshCoreConstants.lppAnalogInput:
|
||||
case MeshCoreConstants.lppAnalogOutput:
|
||||
case MeshCoreConstants.lppIlluminanceSensor:
|
||||
case MeshCoreConstants.lppTemperatureSensor:
|
||||
case MeshCoreConstants.lppBarometer:
|
||||
case MeshCoreConstants.lppVoltageSensor:
|
||||
case _lppCurrent:
|
||||
case _lppAltitude:
|
||||
case _lppConcentration:
|
||||
case _lppPower:
|
||||
case _lppSpeed:
|
||||
case _lppDirection:
|
||||
case _lppGust:
|
||||
case _lppDewPoint:
|
||||
case _lppRain:
|
||||
case _lppPm25:
|
||||
case _lppPm10:
|
||||
case _lppCo2:
|
||||
case _lppTvoc:
|
||||
case _lppRpm:
|
||||
case _lppConductivity:
|
||||
case _lppRotation:
|
||||
return 2;
|
||||
case MeshCoreConstants.lppAccelerometer:
|
||||
case MeshCoreConstants.lppGyrometer:
|
||||
return 6;
|
||||
case MeshCoreConstants.lppGps:
|
||||
return 9;
|
||||
case _lppGenericSensor:
|
||||
case _lppFrequency:
|
||||
case _lppDistance:
|
||||
case _lppEnergy:
|
||||
case _lppUnixTime:
|
||||
case _lppDuration:
|
||||
case _lppAcceleration:
|
||||
case _lppGyroRate:
|
||||
case _lppVolume:
|
||||
case _lppFlowRate:
|
||||
case _lppVolumeStorage:
|
||||
case _lppWater:
|
||||
case _lppGasVolume:
|
||||
case _lppMass:
|
||||
case _lppSignedSpeed:
|
||||
case _lppSignedPower:
|
||||
case _lppSignedCurrent:
|
||||
return 4;
|
||||
case _lppColour:
|
||||
return 3;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) {
|
||||
final start = data.length - remainingBytes;
|
||||
for (int i = start; i < data.length; i++) {
|
||||
if (data[i] != 0) return false;
|
||||
}
|
||||
return remainingBytes > 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);
|
||||
}
|
||||
}
|
||||
11
lib/services/compass_support.dart
Normal file
11
lib/services/compass_support.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter/foundation.dart'
|
||||
show TargetPlatform, defaultTargetPlatform, kIsWeb;
|
||||
|
||||
class CompassSupport {
|
||||
const CompassSupport._();
|
||||
|
||||
static bool get isAvailable =>
|
||||
!kIsWeb &&
|
||||
(defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS);
|
||||
}
|
||||
336
lib/services/contact_route_resolver.dart
Normal file
336
lib/services/contact_route_resolver.dart
Normal file
@@ -0,0 +1,336 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/contact.dart';
|
||||
|
||||
class ResolvedContactRoutePlan {
|
||||
final List<String> tokens;
|
||||
final List<Contact> selectedContacts;
|
||||
final String summary;
|
||||
|
||||
const ResolvedContactRoutePlan({
|
||||
required this.tokens,
|
||||
required this.selectedContacts,
|
||||
required this.summary,
|
||||
});
|
||||
|
||||
String get canonicalText => tokens.join(',');
|
||||
}
|
||||
|
||||
class ContactRouteResolver {
|
||||
static const Distance _distance = Distance();
|
||||
|
||||
const ContactRouteResolver._();
|
||||
|
||||
static ResolvedContactRoutePlan? resolveAutomaticRoute({
|
||||
required LatLng senderLocation,
|
||||
required Contact recipient,
|
||||
required List<Contact> availableContacts,
|
||||
required int hashSize,
|
||||
}) {
|
||||
final recipientLocation = recipient.displayLocation;
|
||||
if (recipientLocation == null) return null;
|
||||
|
||||
final repeaters = availableContacts
|
||||
.where(
|
||||
(contact) =>
|
||||
contact.isRepeater &&
|
||||
contact.displayLocation != null &&
|
||||
contact.publicKeyHex != recipient.publicKeyHex,
|
||||
)
|
||||
.toList();
|
||||
if (repeaters.isEmpty) return null;
|
||||
|
||||
final recipientLatLng = LatLng(
|
||||
recipientLocation.latitude,
|
||||
recipientLocation.longitude,
|
||||
);
|
||||
|
||||
final routedCandidates =
|
||||
repeaters
|
||||
.where(
|
||||
(contact) =>
|
||||
contact.routeHasPath && contact.routeHashSize == hashSize,
|
||||
)
|
||||
.toList()
|
||||
..sort(
|
||||
(a, b) =>
|
||||
_scoreKnownRouteRepeater(
|
||||
senderLocation: senderLocation,
|
||||
recipientLocation: recipientLatLng,
|
||||
repeater: a,
|
||||
availableContacts: repeaters,
|
||||
hashSize: hashSize,
|
||||
).compareTo(
|
||||
_scoreKnownRouteRepeater(
|
||||
senderLocation: senderLocation,
|
||||
recipientLocation: recipientLatLng,
|
||||
repeater: b,
|
||||
availableContacts: repeaters,
|
||||
hashSize: hashSize,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (routedCandidates.isNotEmpty) {
|
||||
final anchor = routedCandidates.first;
|
||||
final tokens = <String>[
|
||||
...anchor.routeCanonicalText
|
||||
.split(',')
|
||||
.where((token) => token.isNotEmpty)
|
||||
.map((token) => token.toUpperCase()),
|
||||
_tokenFor(anchor, hashSize),
|
||||
];
|
||||
final selectedContacts = _matchContactsForTokens(
|
||||
tokens,
|
||||
availableContacts: repeaters,
|
||||
);
|
||||
return ResolvedContactRoutePlan(
|
||||
tokens: _dedupeTokens(tokens),
|
||||
selectedContacts: selectedContacts,
|
||||
summary: 'Resolved via known repeater route',
|
||||
);
|
||||
}
|
||||
|
||||
final corridorRepeaters = List<Contact>.from(repeaters)
|
||||
..sort((a, b) {
|
||||
final progressA = _progressAlongSegment(
|
||||
point: LatLng(
|
||||
a.displayLocation!.latitude,
|
||||
a.displayLocation!.longitude,
|
||||
),
|
||||
start: senderLocation,
|
||||
end: recipientLatLng,
|
||||
);
|
||||
final progressB = _progressAlongSegment(
|
||||
point: LatLng(
|
||||
b.displayLocation!.latitude,
|
||||
b.displayLocation!.longitude,
|
||||
),
|
||||
start: senderLocation,
|
||||
end: recipientLatLng,
|
||||
);
|
||||
final progressCompare = progressA.compareTo(progressB);
|
||||
if (progressCompare != 0) return progressCompare;
|
||||
return _scoreRepeater(
|
||||
senderLocation: senderLocation,
|
||||
recipientLocation: recipientLatLng,
|
||||
repeater: a,
|
||||
).compareTo(
|
||||
_scoreRepeater(
|
||||
senderLocation: senderLocation,
|
||||
recipientLocation: recipientLatLng,
|
||||
repeater: b,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
final selected = <Contact>[];
|
||||
var currentPoint = senderLocation;
|
||||
var currentDistanceToRecipient = _distance.as(
|
||||
LengthUnit.Meter,
|
||||
senderLocation,
|
||||
recipientLatLng,
|
||||
);
|
||||
final maxCorridorDistance = math.max(
|
||||
1500.0,
|
||||
currentDistanceToRecipient * 0.22,
|
||||
);
|
||||
|
||||
for (final repeater in corridorRepeaters) {
|
||||
if (selected.length >= 4) break;
|
||||
final repeaterPoint = LatLng(
|
||||
repeater.displayLocation!.latitude,
|
||||
repeater.displayLocation!.longitude,
|
||||
);
|
||||
final distanceToSegment = _distanceToSegmentMeters(
|
||||
repeaterPoint,
|
||||
senderLocation,
|
||||
recipientLatLng,
|
||||
);
|
||||
if (distanceToSegment > maxCorridorDistance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final nextDistanceToRecipient = _distance.as(
|
||||
LengthUnit.Meter,
|
||||
repeaterPoint,
|
||||
recipientLatLng,
|
||||
);
|
||||
if (nextDistanceToRecipient >= currentDistanceToRecipient - 300) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final distanceFromCurrent = _distance.as(
|
||||
LengthUnit.Meter,
|
||||
currentPoint,
|
||||
repeaterPoint,
|
||||
);
|
||||
if (distanceFromCurrent < 100) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selected.add(repeater);
|
||||
currentPoint = repeaterPoint;
|
||||
currentDistanceToRecipient = nextDistanceToRecipient;
|
||||
|
||||
if (currentDistanceToRecipient < 2500) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ResolvedContactRoutePlan(
|
||||
tokens: selected.map((contact) => _tokenFor(contact, hashSize)).toList(),
|
||||
selectedContacts: selected,
|
||||
summary: 'Resolved from repeater locations',
|
||||
);
|
||||
}
|
||||
|
||||
static List<Contact> _matchContactsForTokens(
|
||||
List<String> tokens, {
|
||||
required List<Contact> availableContacts,
|
||||
}) {
|
||||
final matches = <Contact>[];
|
||||
final seen = <String>{};
|
||||
for (final token in tokens) {
|
||||
final match = availableContacts
|
||||
.where(
|
||||
(contact) => contact.publicKeyHex.toUpperCase().startsWith(token),
|
||||
)
|
||||
.firstOrNull;
|
||||
if (match != null && seen.add(match.publicKeyHex)) {
|
||||
matches.add(match);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
static List<String> _dedupeTokens(List<String> tokens) {
|
||||
final result = <String>[];
|
||||
for (final token in tokens) {
|
||||
if (result.isEmpty || result.last != token) {
|
||||
result.add(token);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static String _tokenFor(Contact contact, int hashSize) {
|
||||
final hex = contact.publicKeyHex.toUpperCase();
|
||||
final length = hashSize * 2;
|
||||
return hex.length < length ? hex : hex.substring(0, length);
|
||||
}
|
||||
|
||||
static double _scoreRepeater({
|
||||
required LatLng senderLocation,
|
||||
required LatLng recipientLocation,
|
||||
required Contact repeater,
|
||||
}) {
|
||||
final point = LatLng(
|
||||
repeater.displayLocation!.latitude,
|
||||
repeater.displayLocation!.longitude,
|
||||
);
|
||||
final toRecipient = _distance.as(
|
||||
LengthUnit.Meter,
|
||||
point,
|
||||
recipientLocation,
|
||||
);
|
||||
final corridor = _distanceToSegmentMeters(
|
||||
point,
|
||||
senderLocation,
|
||||
recipientLocation,
|
||||
);
|
||||
return (toRecipient * 0.75) + (corridor * 0.25);
|
||||
}
|
||||
|
||||
static double _scoreKnownRouteRepeater({
|
||||
required LatLng senderLocation,
|
||||
required LatLng recipientLocation,
|
||||
required Contact repeater,
|
||||
required List<Contact> availableContacts,
|
||||
required int hashSize,
|
||||
}) {
|
||||
final baseScore = _scoreRepeater(
|
||||
senderLocation: senderLocation,
|
||||
recipientLocation: recipientLocation,
|
||||
repeater: repeater,
|
||||
);
|
||||
final repeaterPoint = LatLng(
|
||||
repeater.displayLocation!.latitude,
|
||||
repeater.displayLocation!.longitude,
|
||||
);
|
||||
|
||||
final chainContacts = repeater.routeCanonicalText
|
||||
.split(',')
|
||||
.where((token) => token.isNotEmpty)
|
||||
.map(
|
||||
(token) => availableContacts
|
||||
.where(
|
||||
(contact) =>
|
||||
contact.displayLocation != null &&
|
||||
contact.publicKeyHex.toUpperCase().startsWith(
|
||||
token.toUpperCase(),
|
||||
),
|
||||
)
|
||||
.firstOrNull,
|
||||
)
|
||||
.whereType<Contact>()
|
||||
.toList();
|
||||
|
||||
final chainEnd = chainContacts.isNotEmpty
|
||||
? LatLng(
|
||||
chainContacts.last.displayLocation!.latitude,
|
||||
chainContacts.last.displayLocation!.longitude,
|
||||
)
|
||||
: senderLocation;
|
||||
final chainGap = _distance.as(LengthUnit.Meter, chainEnd, repeaterPoint);
|
||||
final senderGap = _distance.as(
|
||||
LengthUnit.Meter,
|
||||
senderLocation,
|
||||
repeaterPoint,
|
||||
);
|
||||
|
||||
return (chainGap * 0.55) + (baseScore * 0.35) + (senderGap * 0.10);
|
||||
}
|
||||
|
||||
static double _progressAlongSegment({
|
||||
required LatLng point,
|
||||
required LatLng start,
|
||||
required LatLng end,
|
||||
}) {
|
||||
final dx = end.longitude - start.longitude;
|
||||
final dy = end.latitude - start.latitude;
|
||||
final lengthSquared = (dx * dx) + (dy * dy);
|
||||
if (lengthSquared == 0) return 0;
|
||||
return (((point.longitude - start.longitude) * dx) +
|
||||
((point.latitude - start.latitude) * dy)) /
|
||||
lengthSquared;
|
||||
}
|
||||
|
||||
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
|
||||
final ax = a.longitude;
|
||||
final ay = a.latitude;
|
||||
final bx = b.longitude;
|
||||
final by = b.latitude;
|
||||
final px = p.longitude;
|
||||
final py = p.latitude;
|
||||
|
||||
final abx = bx - ax;
|
||||
final aby = by - ay;
|
||||
final apx = px - ax;
|
||||
final apy = py - ay;
|
||||
final ab2 = abx * abx + aby * aby;
|
||||
if (ab2 == 0) {
|
||||
return _distance.as(LengthUnit.Meter, a, p);
|
||||
}
|
||||
var t = (apx * abx + apy * aby) / ab2;
|
||||
t = t.clamp(0.0, 1.0);
|
||||
final closest = LatLng(ay + aby * t, ax + abx * t);
|
||||
return _distance.as(LengthUnit.Meter, closest, p);
|
||||
}
|
||||
}
|
||||
424
lib/services/contact_storage_service.dart
Normal file
424
lib/services/contact_storage_service.dart
Normal file
@@ -0,0 +1,424 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/contact_group.dart';
|
||||
import 'profiles_feature_service.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 String _contactGroupsKey = 'stored_contact_groups';
|
||||
static const String _pendingAdvertsKey = 'stored_pending_adverts';
|
||||
static const int _maxStoredContacts = 500; // Store up to 500 contacts
|
||||
static const int _maxStoredPendingAdverts = 500;
|
||||
|
||||
String _key(String baseKey, {String? namespace}) {
|
||||
return ProfileStorageScope.scopedKey(baseKey, namespace: namespace);
|
||||
}
|
||||
|
||||
/// Save contacts to persistent storage
|
||||
Future<void> saveContacts(List<Contact> contacts, {String? namespace}) 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(
|
||||
_key(_contactsKey, namespace: namespace),
|
||||
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,
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_contactsKey, namespace: namespace),
|
||||
);
|
||||
|
||||
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({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_key(_contactsKey, namespace: namespace));
|
||||
debugPrint('✅ [ContactStorage] Cleared all stored contacts');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error clearing contacts: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveContactGroups(
|
||||
List<SavedContactGroup> groups, {
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = jsonEncode(
|
||||
groups.map((group) => _contactGroupToJson(group)).toList(),
|
||||
);
|
||||
await prefs.setString(
|
||||
_key(_contactGroupsKey, namespace: namespace),
|
||||
jsonString,
|
||||
);
|
||||
debugPrint(
|
||||
'✅ [ContactStorage] Saved ${groups.length} contact groups to storage',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error saving contact groups: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<SavedContactGroup>> loadContactGroups({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_contactGroupsKey, namespace: namespace),
|
||||
);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||
return jsonList
|
||||
.map((json) => _contactGroupFromJson(json as Map<String, dynamic>))
|
||||
.whereType<SavedContactGroup>()
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error loading contact groups: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearContactGroups({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_key(_contactGroupsKey, namespace: namespace));
|
||||
debugPrint('✅ [ContactStorage] Cleared all contact groups');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error clearing contact groups: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> savePendingAdverts(
|
||||
List<Map<String, dynamic>> adverts, {
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final limitedList = adverts.length > _maxStoredPendingAdverts
|
||||
? adverts.sublist(adverts.length - _maxStoredPendingAdverts)
|
||||
: adverts;
|
||||
await prefs.setString(
|
||||
_key(_pendingAdvertsKey, namespace: namespace),
|
||||
jsonEncode(limitedList),
|
||||
);
|
||||
debugPrint(
|
||||
'✅ [ContactStorage] Saved ${limitedList.length} pending adverts to storage',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error saving pending adverts: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> loadPendingAdverts({
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_pendingAdvertsKey, namespace: namespace),
|
||||
);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||
return jsonList.whereType<Map<String, dynamic>>().toList();
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error loading pending adverts: $e');
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearPendingAdverts({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_key(_pendingAdvertsKey, namespace: namespace));
|
||||
debugPrint('✅ [ContactStorage] Cleared all stored pending adverts');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error clearing pending adverts: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Favorites ───────────────────────────────────────────────────────────────
|
||||
|
||||
static const String _favoritesKey = 'contact_favorites';
|
||||
|
||||
Future<void> saveFavorites(
|
||||
Set<String> publicKeyHexes, {
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_key(_favoritesKey, namespace: namespace),
|
||||
publicKeyHexes.toList(),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error saving favorites: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Set<String>> loadFavorites({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final list = prefs.getStringList(
|
||||
_key(_favoritesKey, namespace: namespace),
|
||||
);
|
||||
return list?.toSet() ?? {};
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error loading favorites: $e');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Get storage statistics
|
||||
Future<Map<String, dynamic>> getStorageStats({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_contactsKey, namespace: namespace),
|
||||
);
|
||||
|
||||
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,
|
||||
'nameOverride': contact.nameOverride,
|
||||
'lastAdvert': contact.lastAdvert,
|
||||
'advLat': contact.advLat,
|
||||
'advLon': contact.advLon,
|
||||
'lastMod': contact.lastMod,
|
||||
'telemetry': contact.telemetry != null
|
||||
? _telemetryToJson(contact.telemetry!)
|
||||
: null,
|
||||
'advertHistory': contact.advertHistory
|
||||
.map(
|
||||
(point) => {
|
||||
'lat': point.location.latitude,
|
||||
'lon': point.location.longitude,
|
||||
'tsMillis': point.timestamp.millisecondsSinceEpoch,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 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,
|
||||
nameOverride: json['nameOverride'] 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,
|
||||
advertHistory: _advertHistoryFromJson(json['advertHistory']),
|
||||
);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
List<AdvertLocation> _advertHistoryFromJson(dynamic json) {
|
||||
if (json is! List) return [];
|
||||
final result = <AdvertLocation>[];
|
||||
for (final item in json) {
|
||||
if (item is! Map<String, dynamic>) continue;
|
||||
final lat = item['lat'];
|
||||
final lon = item['lon'];
|
||||
final tsMillis = item['tsMillis'];
|
||||
if (lat is! num || lon is! num || tsMillis is! int) continue;
|
||||
result.add(
|
||||
AdvertLocation(
|
||||
location: LatLng(lat.toDouble(), lon.toDouble()),
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(tsMillis),
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _contactGroupToJson(SavedContactGroup group) {
|
||||
return {
|
||||
'id': group.id,
|
||||
'sectionKey': group.sectionKey,
|
||||
'label': group.label,
|
||||
'query': group.query,
|
||||
'createdAtMillis': group.createdAt.millisecondsSinceEpoch,
|
||||
'matchPrefixes': group.matchPrefixes,
|
||||
'isAutoGroup': group.isAutoGroup,
|
||||
};
|
||||
}
|
||||
|
||||
SavedContactGroup? _contactGroupFromJson(Map<String, dynamic> json) {
|
||||
try {
|
||||
return SavedContactGroup(
|
||||
id: json['id'] as String,
|
||||
sectionKey: json['sectionKey'] as String,
|
||||
label: json['label'] as String,
|
||||
query: json['query'] as String,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
json['createdAtMillis'] as int,
|
||||
),
|
||||
matchPrefixes: (json['matchPrefixes'] as List<dynamic>?)
|
||||
?.map((value) => value as String)
|
||||
.toList(),
|
||||
isAutoGroup: json['isAutoGroup'] as bool? ?? false,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error parsing contact group: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
lib/services/developer_mode_service.dart
Normal file
15
lib/services/developer_mode_service.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class DeveloperModeService {
|
||||
static const String _developerModeKey = 'developer_mode_enabled';
|
||||
|
||||
static Future<bool> isEnabled() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_developerModeKey) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> setEnabled(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_developerModeKey, enabled);
|
||||
}
|
||||
}
|
||||
140
lib/services/device_config_applicator.dart
Normal file
140
lib/services/device_config_applicator.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../models/channel.dart';
|
||||
import '../models/config_profile.dart';
|
||||
import '../providers/channels_provider.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
|
||||
class DeviceConfigApplicator {
|
||||
ConfigProfileSections capture({
|
||||
required ConnectionProvider connectionProvider,
|
||||
required ChannelsProvider channelsProvider,
|
||||
}) {
|
||||
final deviceInfo = connectionProvider.deviceInfo;
|
||||
return ConfigProfileSections(
|
||||
deviceConfig: DeviceConfigProfileSection(
|
||||
frequencyKhz: deviceInfo.radioFreq,
|
||||
bandwidth: deviceInfo.radioBw,
|
||||
spreadingFactor: deviceInfo.radioSf,
|
||||
codingRate: deviceInfo.radioCr,
|
||||
repeatEnabled: deviceInfo.clientRepeat,
|
||||
txPower: deviceInfo.txPower,
|
||||
telemetryModes: deviceInfo.telemetryModes,
|
||||
advertLocationPolicy: deviceInfo.advertLocPolicy,
|
||||
multiAcks: deviceInfo.multiAcks,
|
||||
manualAddContacts: deviceInfo.manualAddContacts,
|
||||
autoAddUsers: deviceInfo.autoAddUsers,
|
||||
autoAddRepeaters: deviceInfo.autoAddRepeaters,
|
||||
autoAddRoomServers: deviceInfo.autoAddRoomServers,
|
||||
autoAddSensors: deviceInfo.autoAddSensors,
|
||||
autoAddOverwriteOldest: deviceInfo.autoAddOverwriteOldest,
|
||||
publicLatitude: _decodeAdvertCoordinate(deviceInfo.advLat),
|
||||
publicLongitude: _decodeAdvertCoordinate(deviceInfo.advLon),
|
||||
),
|
||||
channels: channelsProvider.channels,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> apply(
|
||||
ConfigProfile profile, {
|
||||
required ConnectionProvider connectionProvider,
|
||||
required ChannelsProvider channelsProvider,
|
||||
}) async {
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
final deviceConfig = profile.sections.deviceConfig;
|
||||
if (deviceConfig != null && !deviceConfig.isEmpty) {
|
||||
if (deviceConfig.txPower != null) {
|
||||
await connectionProvider.setTxPower(deviceConfig.txPower!);
|
||||
}
|
||||
if (deviceConfig.telemetryModes != null ||
|
||||
deviceConfig.advertLocationPolicy != null ||
|
||||
deviceConfig.manualAddContacts != null ||
|
||||
deviceConfig.multiAcks != null) {
|
||||
await connectionProvider.setOtherParams(
|
||||
manualAddContacts: (deviceConfig.manualAddContacts ?? false) ? 1 : 0,
|
||||
telemetryModes: deviceConfig.telemetryModes ?? 0,
|
||||
advertLocationPolicy: deviceConfig.advertLocationPolicy ?? 0,
|
||||
multiAcks: deviceConfig.multiAcks ?? 0,
|
||||
);
|
||||
}
|
||||
if (deviceConfig.autoAddUsers != null &&
|
||||
deviceConfig.autoAddRepeaters != null &&
|
||||
deviceConfig.autoAddRoomServers != null &&
|
||||
deviceConfig.autoAddSensors != null &&
|
||||
deviceConfig.autoAddOverwriteOldest != null) {
|
||||
await connectionProvider.setAutoaddConfig(
|
||||
autoAddUsers: deviceConfig.autoAddUsers!,
|
||||
autoAddRepeaters: deviceConfig.autoAddRepeaters!,
|
||||
autoAddRoomServers: deviceConfig.autoAddRoomServers!,
|
||||
autoAddSensors: deviceConfig.autoAddSensors!,
|
||||
overwriteOldest: deviceConfig.autoAddOverwriteOldest!,
|
||||
);
|
||||
}
|
||||
if (deviceConfig.publicLatitude != null &&
|
||||
deviceConfig.publicLongitude != null) {
|
||||
await connectionProvider.setAdvertLatLon(
|
||||
latitude: deviceConfig.publicLatitude!,
|
||||
longitude: deviceConfig.publicLongitude!,
|
||||
);
|
||||
}
|
||||
if (deviceConfig.frequencyKhz != null &&
|
||||
deviceConfig.bandwidth != null &&
|
||||
deviceConfig.spreadingFactor != null &&
|
||||
deviceConfig.codingRate != null) {
|
||||
await connectionProvider.setRadioParams(
|
||||
frequency: deviceConfig.frequencyKhz!,
|
||||
bandwidth: deviceConfig.bandwidth!,
|
||||
spreadingFactor: deviceConfig.spreadingFactor!,
|
||||
codingRate: deviceConfig.codingRate!,
|
||||
repeat: deviceConfig.repeatEnabled,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await _applyChannels(
|
||||
channels: profile.sections.channels,
|
||||
connectionProvider: connectionProvider,
|
||||
channelsProvider: channelsProvider,
|
||||
);
|
||||
await connectionProvider.refreshDeviceInfo();
|
||||
}
|
||||
|
||||
Future<void> _applyChannels({
|
||||
required List<Channel> channels,
|
||||
required ConnectionProvider connectionProvider,
|
||||
required ChannelsProvider channelsProvider,
|
||||
}) async {
|
||||
if (channels.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final desired = {for (final channel in channels) channel.index: channel};
|
||||
|
||||
for (final channel in channels) {
|
||||
await connectionProvider.setChannelSlot(
|
||||
channelIdx: channel.index,
|
||||
channelName: channel.name,
|
||||
secret: Uint8List.fromList(channel.secret),
|
||||
);
|
||||
channelsProvider.addOrUpdateChannelObject(channel);
|
||||
}
|
||||
|
||||
for (final existing in channelsProvider.channels) {
|
||||
if (existing.index == 0 || desired.containsKey(existing.index)) {
|
||||
continue;
|
||||
}
|
||||
await connectionProvider.deleteChannel(existing.index);
|
||||
channelsProvider.removeChannel(existing.index);
|
||||
}
|
||||
}
|
||||
|
||||
double? _decodeAdvertCoordinate(int? value) {
|
||||
if (value == null || value == 0) {
|
||||
return null;
|
||||
}
|
||||
return value / 1e6;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user