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:
220
lib/utils/advert_helper.dart
Normal file
220
lib/utils/advert_helper.dart
Normal file
@@ -0,0 +1,220 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:vibration/vibration.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import 'toast_logger.dart';
|
||||
|
||||
enum AdvertMode { flood, direct }
|
||||
|
||||
Future<void> triggerAdvertFeedback(BuildContext context) async {
|
||||
final platform = Theme.of(context).platform;
|
||||
|
||||
try {
|
||||
if (platform == TargetPlatform.iOS) {
|
||||
await HapticFeedback.lightImpact();
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
await HapticFeedback.lightImpact();
|
||||
} else {
|
||||
if (await Vibration.hasVibrator()) {
|
||||
await Vibration.vibrate(duration: 50);
|
||||
} else {
|
||||
await HapticFeedback.mediumImpact();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Haptic feedback error: $e');
|
||||
await HapticFeedback.vibrate();
|
||||
}
|
||||
}
|
||||
|
||||
Future<AdvertMode?> showAdvertModeSheet(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return showModalBottomSheet<AdvertMode>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (context) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.advertMode,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
l10n.advertModeSubtitle,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 4,
|
||||
),
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.hub_rounded,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
title: Text(l10n.flood),
|
||||
subtitle: Text(l10n.relayThroughRepeatersAcrossTheMesh),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
onTap: () => Navigator.of(context).pop(AdvertMode.flood),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 4,
|
||||
),
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.near_me_rounded,
|
||||
color: theme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
title: Text(l10n.direct),
|
||||
subtitle: Text(l10n.nearbyOnlyWithoutRepeaterFlooding),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
onTap: () => Navigator.of(context).pop(AdvertMode.direct),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> advertiseDevice(
|
||||
BuildContext context, {
|
||||
bool floodMode = true,
|
||||
}) async {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
if (context.mounted) {
|
||||
ToastLogger.error(
|
||||
context,
|
||||
AppLocalizations.of(context)!.deviceNotConnected,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if location services are enabled
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
if (context.mounted) {
|
||||
ToastLogger.error(
|
||||
context,
|
||||
AppLocalizations.of(context)!.locationServicesDisabled,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check location permissions
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
if (context.mounted) {
|
||||
ToastLogger.error(
|
||||
context,
|
||||
AppLocalizations.of(context)!.locationPermissionDenied,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
if (context.mounted) {
|
||||
ToastLogger.error(
|
||||
context,
|
||||
AppLocalizations.of(context)!.locationPermissionPermanentlyDenied,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current GPS position
|
||||
Position? position;
|
||||
try {
|
||||
position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
distanceFilter: 0,
|
||||
),
|
||||
).timeout(const Duration(seconds: 5));
|
||||
} catch (e) {
|
||||
debugPrint('❌ Failed to get GPS position: $e');
|
||||
if (context.mounted) {
|
||||
ToastLogger.error(
|
||||
context,
|
||||
AppLocalizations.of(context)!.failedToGetGpsLocation,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Update lat/lon on device
|
||||
await connectionProvider.setAdvertLatLon(
|
||||
latitude: position.latitude,
|
||||
longitude: position.longitude,
|
||||
);
|
||||
|
||||
// Small delay to ensure the lat/lon is set
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
await connectionProvider.sendSelfAdvert(floodMode: floodMode);
|
||||
|
||||
if (context.mounted) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
ToastLogger.success(
|
||||
context,
|
||||
floodMode ? l10n.floodAdvertSent : l10n.directAdvertSent,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Failed to advertise device: $e');
|
||||
if (context.mounted) {
|
||||
ToastLogger.error(
|
||||
context,
|
||||
AppLocalizations.of(context)!.failedToAdvertise(e.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
lib/utils/avatar_label_helper.dart
Normal file
39
lib/utils/avatar_label_helper.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
class AvatarLabelHelper {
|
||||
static final RegExp _alnumChunks = RegExp(r'[A-Za-z0-9]+');
|
||||
|
||||
static String buildLabel(String name) {
|
||||
final trimmed = name.trim();
|
||||
if (trimmed.isEmpty) return '?';
|
||||
|
||||
if (trimmed.startsWith('#')) {
|
||||
final hashBody = trimmed.substring(1).replaceAll(RegExp(r'[\s_-]+'), '');
|
||||
if (hashBody.isEmpty) {
|
||||
return '#';
|
||||
}
|
||||
return '#${_take(hashBody, 2)}'.toUpperCase();
|
||||
}
|
||||
|
||||
final parts = _alnumChunks
|
||||
.allMatches(trimmed)
|
||||
.map((match) => match.group(0)!)
|
||||
.where((part) => part.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
if (parts.isEmpty) {
|
||||
return '?';
|
||||
}
|
||||
|
||||
if (parts.length >= 2) {
|
||||
final first = _take(parts[0], 1);
|
||||
final second = _take(parts[1], 1);
|
||||
return '$first$second'.toUpperCase();
|
||||
}
|
||||
|
||||
return _take(parts.first, 2).toUpperCase();
|
||||
}
|
||||
|
||||
static String _take(String value, int count) {
|
||||
if (value.length <= count) return value;
|
||||
return value.substring(0, count);
|
||||
}
|
||||
}
|
||||
37
lib/utils/battery_display_helper.dart
Normal file
37
lib/utils/battery_display_helper.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Utility class for battery and signal display helpers
|
||||
/// Used across home_screen.dart and contact_tile.dart
|
||||
class BatteryDisplayHelper {
|
||||
/// Get battery icon based on percentage level
|
||||
static IconData getBatteryIcon(double percentage) {
|
||||
if (percentage > 80) return Icons.battery_full;
|
||||
if (percentage > 50) return Icons.battery_5_bar;
|
||||
if (percentage > 20) return Icons.battery_3_bar;
|
||||
return Icons.battery_1_bar;
|
||||
}
|
||||
|
||||
/// Get battery color based on percentage level
|
||||
static Color getBatteryColor(double percentage) {
|
||||
if (percentage > 50) return Colors.green;
|
||||
if (percentage > 20) return Colors.orange;
|
||||
return Colors.red;
|
||||
}
|
||||
|
||||
/// Get signal strength color based on RSSI value
|
||||
static Color getSignalColor(int rssi) {
|
||||
if (rssi > -60) return Colors.green;
|
||||
if (rssi > -70) return Colors.orange;
|
||||
return Colors.red;
|
||||
}
|
||||
|
||||
/// Number of active signal bars (0-5) for a BLE RSSI value.
|
||||
static int getSignalBars(int rssi) {
|
||||
if (rssi > -50) return 5;
|
||||
if (rssi > -60) return 4;
|
||||
if (rssi > -70) return 3;
|
||||
if (rssi > -80) return 2;
|
||||
if (rssi > -90) return 1;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
174
lib/utils/contact_grouping.dart
Normal file
174
lib/utils/contact_grouping.dart
Normal file
@@ -0,0 +1,174 @@
|
||||
import '../models/contact.dart';
|
||||
|
||||
class InferredContactGroup {
|
||||
final String key;
|
||||
final String label;
|
||||
final List<Contact> contacts;
|
||||
final List<String> matchPrefixes;
|
||||
|
||||
const InferredContactGroup({
|
||||
required this.key,
|
||||
required this.label,
|
||||
required this.contacts,
|
||||
List<String>? matchPrefixes,
|
||||
}) : matchPrefixes = matchPrefixes ?? const <String>[];
|
||||
|
||||
DateTime get latestSeen => contacts.first.lastSeenTime;
|
||||
}
|
||||
|
||||
class ContactGrouping {
|
||||
static final RegExp _prefixedNamePattern = RegExp(
|
||||
r'^([A-Za-z0-9]{2,})([-_/:])',
|
||||
);
|
||||
|
||||
static String? inferredGroupLabelForContact(Contact contact) {
|
||||
return _extractPrefix(contact.displayName)?.label;
|
||||
}
|
||||
|
||||
static bool contactMatchesInferredGroupLabel(Contact contact, String query) {
|
||||
final normalizedQuery = query.trim().toLowerCase();
|
||||
if (normalizedQuery.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final groupLabel = inferredGroupLabelForContact(contact)?.toLowerCase();
|
||||
return groupLabel?.contains(normalizedQuery) ?? false;
|
||||
}
|
||||
|
||||
static String? sharedInferredGroupLabel(List<Contact> contacts) {
|
||||
String? sharedLabel;
|
||||
for (final contact in contacts) {
|
||||
final label = inferredGroupLabelForContact(contact);
|
||||
if (label == null) {
|
||||
return null;
|
||||
}
|
||||
if (sharedLabel == null) {
|
||||
sharedLabel = label;
|
||||
continue;
|
||||
}
|
||||
if (sharedLabel != label) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return sharedLabel;
|
||||
}
|
||||
|
||||
static String? sharedParentGroupLabel(List<String> labels) {
|
||||
if (labels.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var commonPrefix = labels.first;
|
||||
for (final label in labels.skip(1)) {
|
||||
final maxLength = commonPrefix.length < label.length
|
||||
? commonPrefix.length
|
||||
: label.length;
|
||||
var matchLength = 0;
|
||||
while (matchLength < maxLength &&
|
||||
commonPrefix.codeUnitAt(matchLength) ==
|
||||
label.codeUnitAt(matchLength)) {
|
||||
matchLength++;
|
||||
}
|
||||
commonPrefix = commonPrefix.substring(0, matchLength);
|
||||
if (commonPrefix.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final separatorIndex = commonPrefix.lastIndexOf(RegExp(r'[-_/:]'));
|
||||
if (separatorIndex < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final parentLabel = commonPrefix.substring(0, separatorIndex + 1);
|
||||
return parentLabel.length >= 3 ? parentLabel : null;
|
||||
}
|
||||
|
||||
static List<Contact> sortByLastSeen(List<Contact> contacts) {
|
||||
return List<Contact>.from(contacts)
|
||||
..sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
|
||||
}
|
||||
|
||||
static List<InferredContactGroup> inferGroups(
|
||||
List<Contact> contacts, {
|
||||
int minGroupSize = 4,
|
||||
int? maxNamedGroups,
|
||||
String? overflowGroupLabel,
|
||||
}) {
|
||||
final sortedContacts = sortByLastSeen(contacts);
|
||||
final groupedContacts = <String, List<Contact>>{};
|
||||
final groupLabels = <String, String>{};
|
||||
|
||||
for (final contact in sortedContacts) {
|
||||
final prefix = _extractPrefix(contact.displayName);
|
||||
if (prefix == null) continue;
|
||||
groupedContacts.putIfAbsent(prefix.key, () => <Contact>[]).add(contact);
|
||||
groupLabels.putIfAbsent(prefix.key, () => prefix.label);
|
||||
}
|
||||
|
||||
final rankedGroups = <InferredContactGroup>[];
|
||||
for (final entry in groupedContacts.entries) {
|
||||
if (entry.value.length < minGroupSize) continue;
|
||||
rankedGroups.add(
|
||||
InferredContactGroup(
|
||||
key: entry.key,
|
||||
label: groupLabels[entry.key] ?? entry.key,
|
||||
contacts: entry.value,
|
||||
matchPrefixes: [groupLabels[entry.key] ?? entry.key],
|
||||
),
|
||||
);
|
||||
}
|
||||
rankedGroups.sort((a, b) => b.latestSeen.compareTo(a.latestSeen));
|
||||
|
||||
if (maxNamedGroups != null &&
|
||||
overflowGroupLabel != null &&
|
||||
rankedGroups.length > maxNamedGroups) {
|
||||
final retainedGroups = rankedGroups.take(maxNamedGroups).toList();
|
||||
final overflowGroups = rankedGroups.skip(maxNamedGroups).toList();
|
||||
final parentLabel = sharedParentGroupLabel([
|
||||
for (final group in overflowGroups) ...group.matchPrefixes,
|
||||
]);
|
||||
if (parentLabel != null) {
|
||||
final overflowContacts = overflowGroups
|
||||
.expand((group) => group.contacts)
|
||||
.toList();
|
||||
return [
|
||||
...retainedGroups,
|
||||
InferredContactGroup(
|
||||
key: parentLabel,
|
||||
label: parentLabel,
|
||||
contacts: sortByLastSeen(overflowContacts),
|
||||
matchPrefixes: [parentLabel],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return rankedGroups;
|
||||
}
|
||||
return rankedGroups;
|
||||
}
|
||||
|
||||
static _GroupPrefix? _extractPrefix(String name) {
|
||||
final trimmed = name.trim();
|
||||
if (trimmed.isEmpty) return null;
|
||||
|
||||
final match = _prefixedNamePattern.firstMatch(trimmed);
|
||||
if (match == null) return null;
|
||||
|
||||
final rawPrefix = match.group(1);
|
||||
final separator = match.group(2);
|
||||
if (rawPrefix == null || separator == null) return null;
|
||||
|
||||
return _GroupPrefix(
|
||||
key: rawPrefix.toUpperCase(),
|
||||
label: '$rawPrefix$separator',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GroupPrefix {
|
||||
final String key;
|
||||
final String label;
|
||||
|
||||
const _GroupPrefix({required this.key, required this.label});
|
||||
}
|
||||
24
lib/utils/contact_sorting.dart
Normal file
24
lib/utils/contact_sorting.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import '../models/contact.dart';
|
||||
|
||||
int compareContactsByLastSeen(Contact a, Contact b) {
|
||||
return b.lastSeenTime.compareTo(a.lastSeenTime);
|
||||
}
|
||||
|
||||
int compareContactsByFavouriteThenLastSeen(Contact a, Contact b) {
|
||||
if (a.isFavourite != b.isFavourite) {
|
||||
return a.isFavourite ? -1 : 1;
|
||||
}
|
||||
|
||||
return compareContactsByLastSeen(a, b);
|
||||
}
|
||||
|
||||
int compareContactsByDisplayName(Contact a, Contact b) {
|
||||
final nameCompare = a.displayName.toLowerCase().compareTo(
|
||||
b.displayName.toLowerCase(),
|
||||
);
|
||||
if (nameCompare != 0) {
|
||||
return nameCompare;
|
||||
}
|
||||
|
||||
return a.publicKeyHex.compareTo(b.publicKeyHex);
|
||||
}
|
||||
18
lib/utils/custom_map_id.dart
Normal file
18
lib/utils/custom_map_id.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
const int customMapKey6HexLength = 12;
|
||||
|
||||
String? normalizeCustomMapId(String? mapId) {
|
||||
if (mapId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final normalized = mapId.trim().toLowerCase();
|
||||
if (normalized.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalized.length <= customMapKey6HexLength) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized.substring(0, customMapKey6HexLength);
|
||||
}
|
||||
8
lib/utils/debug_print.dart
Normal file
8
lib/utils/debug_print.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Debug print that only outputs in debug builds
|
||||
void debugPrint(Object? message) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(message);
|
||||
}
|
||||
}
|
||||
133
lib/utils/drawing_message_parser.dart
Normal file
133
lib/utils/drawing_message_parser.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../models/map_coordinate_space.dart';
|
||||
import '../models/map_drawing.dart';
|
||||
import 'custom_map_id.dart';
|
||||
|
||||
class DrawingMessageParser {
|
||||
static const String legacyPrefix = 'D:';
|
||||
static const String customMapPrefix = 'D2:';
|
||||
|
||||
static bool isDrawingMessage(String text) {
|
||||
return text.startsWith(legacyPrefix) || text.startsWith(customMapPrefix);
|
||||
}
|
||||
|
||||
static MapDrawing? parseDrawingMessage(
|
||||
String text, {
|
||||
String? senderName,
|
||||
String? messageId,
|
||||
}) {
|
||||
if (!isDrawingMessage(text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (text.startsWith(customMapPrefix)) {
|
||||
final json =
|
||||
jsonDecode(text.substring(customMapPrefix.length))
|
||||
as Map<String, dynamic>;
|
||||
final mapId = normalizeCustomMapId(json['m'] as String?);
|
||||
if (mapId == null || mapId.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return MapDrawing.fromNetworkJson(
|
||||
json,
|
||||
senderName: senderName,
|
||||
messageId: messageId,
|
||||
coordinateSpace: MapCoordinateSpace.customMap,
|
||||
mapId: mapId,
|
||||
);
|
||||
}
|
||||
|
||||
final json =
|
||||
jsonDecode(text.substring(legacyPrefix.length))
|
||||
as Map<String, dynamic>;
|
||||
return MapDrawing.fromNetworkJson(
|
||||
json,
|
||||
senderName: senderName,
|
||||
messageId: messageId,
|
||||
coordinateSpace: MapCoordinateSpace.geo,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static String createDrawingMessage(MapDrawing drawing) {
|
||||
final json = drawing.toNetworkJson();
|
||||
final prefix = drawing.coordinateSpace == MapCoordinateSpace.customMap
|
||||
? customMapPrefix
|
||||
: legacyPrefix;
|
||||
return '$prefix${jsonEncode(json)}';
|
||||
}
|
||||
|
||||
static String? getDrawingTypeDisplay(String text) {
|
||||
final metadata = getDrawingMetadata(text);
|
||||
return metadata?['type'] as String?;
|
||||
}
|
||||
|
||||
static String? getColorName(String text) {
|
||||
final metadata = getDrawingMetadata(text);
|
||||
return metadata?['color'] as String?;
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? getDrawingMetadata(String text) {
|
||||
if (!isDrawingMessage(text)) return null;
|
||||
|
||||
try {
|
||||
final json =
|
||||
jsonDecode(
|
||||
text.startsWith(customMapPrefix)
|
||||
? text.substring(customMapPrefix.length)
|
||||
: text.substring(legacyPrefix.length),
|
||||
)
|
||||
as Map<String, dynamic>;
|
||||
|
||||
final typeNum = json['t'] as int?;
|
||||
final colorIndex = json['c'] as int?;
|
||||
if (typeNum == null || colorIndex == null) return null;
|
||||
|
||||
String type;
|
||||
int? pointCount;
|
||||
switch (typeNum) {
|
||||
case 0:
|
||||
type = 'Line';
|
||||
final points = json['p'] as List?;
|
||||
pointCount = points != null ? points.length ~/ 2 : null;
|
||||
break;
|
||||
case 1:
|
||||
type = 'Rectangle';
|
||||
pointCount = 4;
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
const colorNames = [
|
||||
'Red',
|
||||
'Blue',
|
||||
'Green',
|
||||
'Yellow',
|
||||
'Orange',
|
||||
'Purple',
|
||||
'Pink',
|
||||
'Cyan',
|
||||
];
|
||||
final color = colorIndex >= 0 && colorIndex < colorNames.length
|
||||
? colorNames[colorIndex]
|
||||
: 'Unknown';
|
||||
|
||||
return {
|
||||
'type': type,
|
||||
'color': color,
|
||||
'pointCount': pointCount,
|
||||
'coordinateSpace': text.startsWith(customMapPrefix)
|
||||
? MapCoordinateSpace.customMap.name
|
||||
: MapCoordinateSpace.geo.name,
|
||||
'mapId': normalizeCustomMapId(json['m'] as String?),
|
||||
};
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
86
lib/utils/fast_gps_packet.dart
Normal file
86
lib/utils/fast_gps_packet.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Fast-GPS position beacon, wire-compatible with the MeshUI firmware
|
||||
/// (`maybeSendFastGpsUpdate` in MyMesh.cpp).
|
||||
///
|
||||
/// 16-byte binary payload sent as a `DATA_TYPE_DEV` group datagram on a
|
||||
/// non-public channel:
|
||||
///
|
||||
/// ```
|
||||
/// [0] magic 0x47 ('G')
|
||||
/// [1..6] sender public-key prefix (6 bytes)
|
||||
/// [7..10] latitude in microdegrees (int32, little-endian)
|
||||
/// [11..14] longitude in microdegrees (int32, little-endian)
|
||||
/// [15] ground speed in km/h (uint8, clamped 0..255)
|
||||
/// ```
|
||||
///
|
||||
/// The beacon carries no timestamp — receivers stamp their own RX time.
|
||||
class FastGpsPacket {
|
||||
static const int magic = 0x47; // 'G'
|
||||
static const int _payloadLength = 16;
|
||||
// Store coordinates in microdegrees. This preserves sub-meter precision,
|
||||
// which comfortably satisfies the meter-accuracy requirement.
|
||||
static const double coordinateScale = 1e6;
|
||||
|
||||
final String senderKey6;
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
|
||||
/// Ground speed in km/h (0..255), as carried in the beacon's trailing byte.
|
||||
final int speedKmh;
|
||||
|
||||
const FastGpsPacket({
|
||||
required this.senderKey6,
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
this.speedKmh = 0,
|
||||
});
|
||||
|
||||
static bool isFastGpsBinary(Uint8List payload) =>
|
||||
payload.length == _payloadLength && payload[0] == magic;
|
||||
|
||||
static FastGpsPacket? tryParseBinary(Uint8List payload) {
|
||||
if (!isFastGpsBinary(payload)) return null;
|
||||
|
||||
final key6 = payload
|
||||
.sublist(1, 7)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
final data = ByteData.sublistView(payload);
|
||||
final latitude = data.getInt32(7, Endian.little) / coordinateScale;
|
||||
final longitude = data.getInt32(11, Endian.little) / coordinateScale;
|
||||
final speedKmh = data.getUint8(15);
|
||||
|
||||
if (!_isValidCoordinate(latitude, longitude)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return FastGpsPacket(
|
||||
senderKey6: key6,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
speedKmh: speedKmh,
|
||||
);
|
||||
}
|
||||
|
||||
Uint8List encodeBinary() {
|
||||
final out = Uint8List(_payloadLength);
|
||||
final data = ByteData.sublistView(out);
|
||||
out[0] = magic;
|
||||
for (var i = 0; i < 6; i++) {
|
||||
out[1 + i] = int.parse(senderKey6.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
data.setInt32(7, (latitude * coordinateScale).round(), Endian.little);
|
||||
data.setInt32(11, (longitude * coordinateScale).round(), Endian.little);
|
||||
data.setUint8(15, speedKmh.clamp(0, 255));
|
||||
return out;
|
||||
}
|
||||
|
||||
static bool _isValidCoordinate(double latitude, double longitude) {
|
||||
if (!latitude.isFinite || !longitude.isFinite) return false;
|
||||
return latitude >= -90.0 &&
|
||||
latitude <= 90.0 &&
|
||||
longitude >= -180.0 &&
|
||||
longitude <= 180.0;
|
||||
}
|
||||
}
|
||||
618
lib/utils/image_message_parser.dart
Normal file
618
lib/utils/image_message_parser.dart
Normal file
@@ -0,0 +1,618 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
const int _maxCompanionFrameBytes = 172; // MeshCore MAX_FRAME_SIZE
|
||||
const int _cmdSendRawDataOverheadBytes = 2; // cmd + pathLen
|
||||
const int _maxMeshPacketPayloadBytes = 184; // MeshCore MAX_PACKET_PAYLOAD
|
||||
const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload
|
||||
const int _imagePacketHeaderBytes = 6; // image packet binary header in payload
|
||||
const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10)
|
||||
const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5)
|
||||
const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz)
|
||||
const int _defaultLoRaPreambleSymbols = 8;
|
||||
const int _defaultLoRaCrcEnabled = 1;
|
||||
const int _defaultLoRaExplicitHeader = 1;
|
||||
const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle
|
||||
|
||||
/// Compressed image format used in the image packet protocol.
|
||||
enum ImageFormat {
|
||||
avif(0, 'AVIF'),
|
||||
jpeg(1, 'JPEG');
|
||||
|
||||
const ImageFormat(this.id, this.label);
|
||||
final int id;
|
||||
final String label;
|
||||
|
||||
static ImageFormat fromId(int id) => ImageFormat.values.firstWhere(
|
||||
(f) => f.id == id,
|
||||
orElse: () => ImageFormat.avif,
|
||||
);
|
||||
}
|
||||
|
||||
/// A single binary fragment of a compressed image.
|
||||
///
|
||||
/// Binary format (direct contacts, via pushRawData / cmdSendRawData):
|
||||
/// [0x49 'I'][sessionId:4B][idx:1B][imageData...]
|
||||
///
|
||||
/// Legacy default is 152 data bytes per fragment.
|
||||
class ImagePacket {
|
||||
final String sessionId; // 8 hex chars (4 bytes)
|
||||
final ImageFormat format;
|
||||
final int index; // 0-based
|
||||
final int total; // total fragment count (1..255)
|
||||
final Uint8List data;
|
||||
|
||||
const ImagePacket({
|
||||
required this.sessionId,
|
||||
required this.format,
|
||||
required this.index,
|
||||
required this.total,
|
||||
required this.data,
|
||||
});
|
||||
|
||||
static const int _magic = 0x49; // 'I'
|
||||
static const int _headerLen = 6; // magic(1)+session(4)+idx(1)
|
||||
static const int maxDataBytes =
|
||||
152; // Conservative default for compatibility.
|
||||
|
||||
static bool isImageBinary(Uint8List payload) =>
|
||||
payload.isNotEmpty && payload[0] == _magic;
|
||||
|
||||
static ImagePacket? tryParseBinary(Uint8List payload) {
|
||||
if (payload.length < _headerLen) return null;
|
||||
if (payload[0] != _magic) return null;
|
||||
try {
|
||||
final sessionId = payload
|
||||
.sublist(1, 5)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
final index = payload[5];
|
||||
final data = payload.sublist(_headerLen);
|
||||
return ImagePacket(
|
||||
sessionId: sessionId,
|
||||
format: ImageFormat.avif,
|
||||
index: index,
|
||||
total: 0,
|
||||
data: data,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List encodeBinary() {
|
||||
final sessionBytes = Uint8List(4);
|
||||
for (var i = 0; i < 4; i++) {
|
||||
sessionBytes[i] = int.parse(
|
||||
sessionId.substring(i * 2, i * 2 + 2),
|
||||
radix: 16,
|
||||
);
|
||||
}
|
||||
final out = Uint8List(_headerLen + data.length);
|
||||
out[0] = _magic;
|
||||
out.setRange(1, 5, sessionBytes);
|
||||
out[5] = index;
|
||||
out.setRange(_headerLen, out.length, data);
|
||||
return out;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final suffix = total > 0 ? ' ${format.label} [$index/${total - 1}]' : ' [$index]';
|
||||
return 'ImagePacket($sessionId$suffix ${data.length}B)';
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the maximum safe image data bytes for a direct route path.
|
||||
///
|
||||
/// This accounts for:
|
||||
/// - companion command-frame limit (MAX_FRAME_SIZE=172),
|
||||
/// - cmdSendRawData overhead (`cmd` + `pathLen`),
|
||||
/// - image packet binary header (8 bytes),
|
||||
/// - mesh packet payload limit (MAX_PACKET_PAYLOAD=184).
|
||||
///
|
||||
/// Path length follows Contact.outPathLen semantics: 0 = direct, 1+ = hops.
|
||||
int safeImageDataBytesForPath(int pathLen) {
|
||||
final normalizedPathLen = pathLen.clamp(0, 64).toInt();
|
||||
final maxRawPayloadFromCommandFrame =
|
||||
_maxCompanionFrameBytes -
|
||||
_cmdSendRawDataOverheadBytes -
|
||||
normalizedPathLen;
|
||||
final maxRawPayloadFromMesh =
|
||||
_maxMeshPacketPayloadBytes - ImagePacket._headerLen;
|
||||
final maxRawPayload = maxRawPayloadFromCommandFrame < maxRawPayloadFromMesh
|
||||
? maxRawPayloadFromCommandFrame
|
||||
: maxRawPayloadFromMesh;
|
||||
final maxData = maxRawPayload - ImagePacket._headerLen;
|
||||
// Keep a safety margin below the theoretical direct-route ceiling.
|
||||
// Fragments at the absolute 172-byte command-frame limit have proven flaky
|
||||
// in practice, so cap to the conservative protocol default.
|
||||
return maxData.clamp(1, ImagePacket.maxDataBytes).toInt();
|
||||
}
|
||||
|
||||
/// Approximate end-to-end transmit time for image fragments on MeshCore LoRa.
|
||||
///
|
||||
/// The estimate uses:
|
||||
/// - LoRa airtime math with MeshCore companion defaults (SF10/BW250/CR5),
|
||||
/// - MeshCore airtime budget pacing (default factor 1.0),
|
||||
/// - hop multiplier (`pathLen + 1`) for direct routed packets.
|
||||
Duration estimateImageTransmitDuration({
|
||||
required int fragmentCount,
|
||||
required int sizeBytes,
|
||||
int pathLen = 0,
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
}) {
|
||||
if (fragmentCount <= 0 || sizeBytes <= 0) return Duration.zero;
|
||||
|
||||
final safePathLen = pathLen.clamp(0, 64);
|
||||
final hops = safePathLen + 1;
|
||||
final baseDataPerFragment = sizeBytes ~/ fragmentCount;
|
||||
final extraBytes = sizeBytes % fragmentCount;
|
||||
var totalMs = 0.0;
|
||||
|
||||
for (var i = 0; i < fragmentCount; i++) {
|
||||
final fragmentDataBytes = baseDataPerFragment + (i < extraBytes ? 1 : 0);
|
||||
final loraLen =
|
||||
_meshPacketHeaderBytes +
|
||||
safePathLen +
|
||||
_imagePacketHeaderBytes +
|
||||
fragmentDataBytes;
|
||||
final airtimeMs = _estimateLoRaAirtimeMs(
|
||||
loraLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
totalMs += airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops;
|
||||
}
|
||||
|
||||
return Duration(milliseconds: totalMs.round());
|
||||
}
|
||||
|
||||
double _estimateLoRaAirtimeMs(
|
||||
int payloadLenBytes, {
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
}) {
|
||||
final sf = _normalizeSf(radioSf);
|
||||
final bw = _resolveBandwidthHz(radioBw).toDouble();
|
||||
final cr = (_normalizeCr(radioCr) - 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;
|
||||
}
|
||||
|
||||
int _normalizeSf(int? value) {
|
||||
if (value == null) return _defaultLoRaSf;
|
||||
if (value >= 5 && value <= 12) return value;
|
||||
return _defaultLoRaSf;
|
||||
}
|
||||
|
||||
int _normalizeCr(int? value) {
|
||||
if (value == null) return _defaultLoRaCr;
|
||||
if (value >= 5 && value <= 8) return value;
|
||||
return _defaultLoRaCr;
|
||||
}
|
||||
|
||||
int _resolveBandwidthHz(int? rawBw) {
|
||||
if (rawBw == null) return _defaultLoRaBwHz;
|
||||
if (rawBw > 1000) return rawBw;
|
||||
switch (rawBw) {
|
||||
case 0:
|
||||
return 7800;
|
||||
case 1:
|
||||
return 10400;
|
||||
case 2:
|
||||
return 15600;
|
||||
case 3:
|
||||
return 20800;
|
||||
case 4:
|
||||
return 31250;
|
||||
case 5:
|
||||
return 41700;
|
||||
case 6:
|
||||
return 62500;
|
||||
case 7:
|
||||
return 125000;
|
||||
case 8:
|
||||
return 250000;
|
||||
case 9:
|
||||
return 500000;
|
||||
default:
|
||||
return _defaultLoRaBwHz;
|
||||
}
|
||||
}
|
||||
|
||||
/// Envelope announcing image availability (control plane).
|
||||
///
|
||||
/// Text format:
|
||||
/// IE4:{sid}:{fmt}:{total}:{w}:{h}:{bytes}
|
||||
/// Example:
|
||||
/// IE4:deadbeef:0:7:3k:3k:t6
|
||||
class ImageEnvelope {
|
||||
static const String _prefixV4 = 'IE4:';
|
||||
|
||||
final String sessionId; // 8 hex chars
|
||||
final ImageFormat format;
|
||||
final int total; // total fragment count
|
||||
final int width;
|
||||
final int height;
|
||||
final int sizeBytes; // total compressed image size
|
||||
final int version;
|
||||
|
||||
const ImageEnvelope({
|
||||
required this.sessionId,
|
||||
required this.format,
|
||||
required this.total,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.sizeBytes,
|
||||
this.version = 4,
|
||||
});
|
||||
|
||||
static bool isEnvelope(String text) => text.startsWith(_prefixV4);
|
||||
|
||||
static ImageEnvelope? tryParse(String text) {
|
||||
if (!isEnvelope(text)) return null;
|
||||
final body = text.substring(_prefixV4.length);
|
||||
final parts = body.split(':');
|
||||
if (parts.length != 6) return null;
|
||||
try {
|
||||
final sid = _decodeSessionId(parts[0]);
|
||||
final fmtId = _parseInt(parts[1], base36: true);
|
||||
final total = _parseInt(parts[2], base36: true);
|
||||
final w = _parseInt(parts[3], base36: true);
|
||||
final h = _parseInt(parts[4], base36: true);
|
||||
final bytes = _parseInt(parts[5], base36: true);
|
||||
|
||||
if (sid == null) return null;
|
||||
if (fmtId == null) return null;
|
||||
if (total == null || total < 1 || total > 255) return null;
|
||||
if (w == null || h == null || w < 1 || h < 1) return null;
|
||||
if (bytes == null || bytes < 1) return null;
|
||||
|
||||
return ImageEnvelope(
|
||||
sessionId: sid,
|
||||
format: ImageFormat.fromId(fmtId),
|
||||
total: total,
|
||||
width: w,
|
||||
height: h,
|
||||
sizeBytes: bytes,
|
||||
version: 4,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String encode() =>
|
||||
'$_prefixV4${_encodeSessionId(sessionId)}:'
|
||||
'${_toBase36(format.id)}:${_toBase36(total)}:${_toBase36(width)}:'
|
||||
'${_toBase36(height)}:${_toBase36(sizeBytes)}';
|
||||
}
|
||||
|
||||
/// Direct request to fetch image fragments (control plane).
|
||||
///
|
||||
/// Text format:
|
||||
/// IR4:{sid}:{want}:{requesterKey6}
|
||||
/// Example:
|
||||
/// IR4:deadbeef:a:aabbccddeeff
|
||||
class ImageFetchRequest {
|
||||
static const String _prefixV4 = 'IR4:';
|
||||
static const int _binaryMagic = 0x69; // 'i'
|
||||
|
||||
final String sessionId;
|
||||
final String want; // 'all' or 'missing'
|
||||
final List<int> missingIndices;
|
||||
final String requesterKey6; // 12 hex chars
|
||||
final int version;
|
||||
|
||||
const ImageFetchRequest({
|
||||
required this.sessionId,
|
||||
this.want = 'all',
|
||||
this.missingIndices = const [],
|
||||
required this.requesterKey6,
|
||||
this.version = 4,
|
||||
});
|
||||
|
||||
static bool isRequest(String text) => text.startsWith(_prefixV4);
|
||||
static bool isRequestBinary(Uint8List payload) =>
|
||||
payload.isNotEmpty && payload[0] == _binaryMagic;
|
||||
|
||||
static ImageFetchRequest? tryParse(String text) {
|
||||
if (!isRequest(text)) return null;
|
||||
final body = text.substring(_prefixV4.length);
|
||||
final parts = body.split(':');
|
||||
if (parts.length != 3) return null;
|
||||
try {
|
||||
final sid = _decodeSessionId(parts[0]);
|
||||
final wantToken = parts[1];
|
||||
final requesterKey6 = parts[2];
|
||||
final normalizedWant = wantToken == 'a'
|
||||
? 'all'
|
||||
: ((wantToken.startsWith('m')) ? 'missing' : wantToken);
|
||||
|
||||
if (sid == null) return null;
|
||||
final missingIndices = <int>[];
|
||||
if (normalizedWant == 'missing') {
|
||||
final encoded = wantToken.substring(1);
|
||||
if (encoded.isEmpty) return null;
|
||||
missingIndices.addAll(_decodeMissingIndicesCompact(encoded));
|
||||
if (missingIndices.isEmpty) return null;
|
||||
} else if (normalizedWant != 'all') {
|
||||
return null;
|
||||
}
|
||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) return null;
|
||||
|
||||
return ImageFetchRequest(
|
||||
sessionId: sid,
|
||||
want: normalizedWant,
|
||||
missingIndices: missingIndices,
|
||||
requesterKey6: requesterKey6.toLowerCase(),
|
||||
version: 4,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static ImageFetchRequest? tryParseBinary(Uint8List payload) {
|
||||
if (!isRequestBinary(payload)) return null;
|
||||
if (payload.length < 13) return null; // magic+sid+flags+key6+count
|
||||
try {
|
||||
final sid = payload
|
||||
.sublist(1, 5)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
final flags = payload[5];
|
||||
final requesterKey6 = payload
|
||||
.sublist(6, 12)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
final missingCount = payload[12];
|
||||
if (payload.length != 13 + missingCount) return null;
|
||||
final wantMissing = (flags & 0x01) == 0x01;
|
||||
final missing = <int>[];
|
||||
for (var i = 0; i < missingCount; i++) {
|
||||
missing.add(payload[13 + i]);
|
||||
}
|
||||
return ImageFetchRequest(
|
||||
sessionId: sid,
|
||||
want: wantMissing ? 'missing' : 'all',
|
||||
missingIndices: missing,
|
||||
requesterKey6: requesterKey6,
|
||||
version: 4,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String encode() {
|
||||
final wantToken = want == 'missing' && missingIndices.isNotEmpty
|
||||
? 'm${_encodeMissingIndicesCompact(missingIndices)}'
|
||||
: (want == 'all' ? 'a' : want);
|
||||
return '$_prefixV4${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}';
|
||||
}
|
||||
|
||||
Uint8List encodeBinary() {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
|
||||
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
|
||||
}
|
||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
|
||||
throw ArgumentError.value(
|
||||
requesterKey6,
|
||||
'requesterKey6',
|
||||
'Expected 12 hex chars',
|
||||
);
|
||||
}
|
||||
final useMissing = want == 'missing' && missingIndices.isNotEmpty;
|
||||
final missing = useMissing
|
||||
? missingIndices.where((v) => v >= 0 && v <= 254).toList()
|
||||
: <int>[];
|
||||
|
||||
final out = Uint8List(13 + missing.length);
|
||||
out[0] = _binaryMagic;
|
||||
for (var i = 0; i < 4; i++) {
|
||||
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
out[5] = useMissing ? 0x01 : 0x00;
|
||||
for (var i = 0; i < 6; i++) {
|
||||
out[6 + i] = int.parse(
|
||||
requesterKey6.substring(i * 2, i * 2 + 2),
|
||||
radix: 16,
|
||||
);
|
||||
}
|
||||
out[12] = missing.length;
|
||||
for (var i = 0; i < missing.length; i++) {
|
||||
out[13 + i] = missing[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-fragment ACK for raw image payload packets.
|
||||
///
|
||||
/// Binary format:
|
||||
/// [0x6a 'j'][sessionId:4B][index:1B]
|
||||
class ImageFragmentAck {
|
||||
static const int _binaryMagic = 0x6a; // 'j'
|
||||
|
||||
final String sessionId; // 8 hex chars
|
||||
final int index; // 0..254
|
||||
|
||||
const ImageFragmentAck({required this.sessionId, required this.index});
|
||||
|
||||
static bool isImageFragmentAckBinary(Uint8List payload) =>
|
||||
payload.length == 6 && payload[0] == _binaryMagic;
|
||||
|
||||
static ImageFragmentAck? tryParseBinary(Uint8List payload) {
|
||||
if (!isImageFragmentAckBinary(payload)) return null;
|
||||
try {
|
||||
final sid = payload
|
||||
.sublist(1, 5)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
final idx = payload[5];
|
||||
return ImageFragmentAck(sessionId: sid, index: idx);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List encodeBinary() {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
|
||||
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
|
||||
}
|
||||
if (index < 0 || index > 254) {
|
||||
throw ArgumentError.value(index, 'index', 'Expected 0..254');
|
||||
}
|
||||
final out = Uint8List(6);
|
||||
out[0] = _binaryMagic;
|
||||
for (var i = 0; i < 4; i++) {
|
||||
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
out[5] = index;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
int? _parseInt(String token, {required bool base36}) =>
|
||||
int.tryParse(token, radix: base36 ? 36 : 10);
|
||||
|
||||
String _toBase36(int value) => value.toRadixString(36);
|
||||
|
||||
String _encodeSessionId(String sessionIdHex) {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
|
||||
throw ArgumentError.value(
|
||||
sessionIdHex,
|
||||
'sessionIdHex',
|
||||
'Expected 8 hex chars',
|
||||
);
|
||||
}
|
||||
final value = int.parse(sessionIdHex, radix: 16);
|
||||
return value.toRadixString(36);
|
||||
}
|
||||
|
||||
String? _decodeSessionId(String token) {
|
||||
if (!RegExp(r'^[0-9a-z]{1,7}$').hasMatch(token)) return null;
|
||||
final value = int.tryParse(token, radix: 36);
|
||||
if (value == null || value < 0 || value > 0xFFFFFFFF) return null;
|
||||
return value.toRadixString(16).padLeft(8, '0');
|
||||
}
|
||||
|
||||
String _encodeMissingIndicesCompact(List<int> indices) {
|
||||
final sorted = indices.where((v) => v >= 0 && v <= 254).toSet().toList()
|
||||
..sort();
|
||||
if (sorted.isEmpty) return '';
|
||||
final chunks = <String>[];
|
||||
var start = sorted.first;
|
||||
var prev = sorted.first;
|
||||
for (var i = 1; i < sorted.length; i++) {
|
||||
final curr = sorted[i];
|
||||
if (curr == prev + 1) {
|
||||
prev = curr;
|
||||
continue;
|
||||
}
|
||||
chunks.add(
|
||||
start == prev
|
||||
? _toBase36(start)
|
||||
: '${_toBase36(start)}-${_toBase36(prev)}',
|
||||
);
|
||||
start = curr;
|
||||
prev = curr;
|
||||
}
|
||||
chunks.add(
|
||||
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
|
||||
);
|
||||
return chunks.join('.');
|
||||
}
|
||||
|
||||
List<int> _decodeMissingIndicesCompact(String encoded) {
|
||||
final out = <int>[];
|
||||
for (final token in encoded.split('.')) {
|
||||
if (token.isEmpty) continue;
|
||||
if (!token.contains('-')) {
|
||||
final value = int.tryParse(token, radix: 36);
|
||||
if (value == null || value < 0 || value > 254) return const [];
|
||||
out.add(value);
|
||||
continue;
|
||||
}
|
||||
final parts = token.split('-');
|
||||
if (parts.length != 2) return const [];
|
||||
final start = int.tryParse(parts[0], radix: 36);
|
||||
final end = int.tryParse(parts[1], radix: 36);
|
||||
if (start == null || end == null || start < 0 || end > 254 || start > end) {
|
||||
return const [];
|
||||
}
|
||||
for (var i = start; i <= end; i++) {
|
||||
out.add(i);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Fragment the compressed image bytes into [ImagePacket] list.
|
||||
///
|
||||
/// [sessionId] must be 8 lowercase hex chars.
|
||||
/// [format] is the image format used.
|
||||
/// Returns at most 255 packets; excess bytes are silently dropped.
|
||||
List<ImagePacket> fragmentImage({
|
||||
required String sessionId,
|
||||
required ImageFormat format,
|
||||
required Uint8List bytes,
|
||||
int maxDataBytes = ImagePacket.maxDataBytes,
|
||||
}) {
|
||||
final chunkSize = maxDataBytes.clamp(1, 255).toInt();
|
||||
final chunks = <Uint8List>[];
|
||||
for (var offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
final end = (offset + chunkSize).clamp(0, bytes.length);
|
||||
chunks.add(bytes.sublist(offset, end));
|
||||
if (chunks.length == 255) break; // protocol limit
|
||||
}
|
||||
final total = chunks.length;
|
||||
return [
|
||||
for (var i = 0; i < total; i++)
|
||||
ImagePacket(
|
||||
sessionId: sessionId,
|
||||
format: format,
|
||||
index: i,
|
||||
total: total,
|
||||
data: chunks[i],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Reassemble image bytes from received [packets].
|
||||
///
|
||||
/// Returns null if any fragment is missing.
|
||||
Uint8List? reassembleImage(List<ImagePacket?> packets) {
|
||||
if (packets.isEmpty) return null;
|
||||
if (packets.any((p) => p == null)) return null;
|
||||
final merged = <int>[];
|
||||
for (final p in packets) {
|
||||
merged.addAll(p!.data);
|
||||
}
|
||||
return Uint8List.fromList(merged);
|
||||
}
|
||||
18
lib/utils/key_comparison.dart
Normal file
18
lib/utils/key_comparison.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Extension on Uint8List to provide comparison functionality for public keys.
|
||||
///
|
||||
/// This extension is used to compare MeshCore public keys (32 bytes) or their
|
||||
/// prefixes (6 bytes) across the application.
|
||||
extension Uint8ListComparison on Uint8List {
|
||||
/// Compares this Uint8List with another for exact equality.
|
||||
///
|
||||
/// Returns true if both lists have the same length and identical bytes.
|
||||
bool matches(Uint8List other) {
|
||||
if (length != other.length) return false;
|
||||
for (int i = 0; i < length; i++) {
|
||||
if (this[i] != other[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
54
lib/utils/link_quality.dart
Normal file
54
lib/utils/link_quality.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
int rssiScore(int rssiDbm) {
|
||||
if (rssiDbm >= -60) return 5;
|
||||
if (rssiDbm >= -70) return 4;
|
||||
if (rssiDbm >= -80) return 3;
|
||||
if (rssiDbm >= -90) return 2;
|
||||
if (rssiDbm >= -100) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int snrScore(double snrDb) {
|
||||
if (snrDb >= 10) return 5;
|
||||
if (snrDb >= 5) return 4;
|
||||
if (snrDb >= 0) return 3;
|
||||
if (snrDb >= -5) return 2;
|
||||
if (snrDb >= -10) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
String linkQualityLabel(int? rssiDbm, double? snrDb) {
|
||||
var totalScore = 0;
|
||||
var metricCount = 0;
|
||||
|
||||
if (rssiDbm != null) {
|
||||
totalScore += rssiScore(rssiDbm);
|
||||
metricCount += 1;
|
||||
}
|
||||
if (snrDb != null) {
|
||||
totalScore += snrScore(snrDb);
|
||||
metricCount += 1;
|
||||
}
|
||||
|
||||
if (metricCount == 0) return 'Weak';
|
||||
|
||||
final averageScore = totalScore / metricCount;
|
||||
if (averageScore >= 4.5) return 'Excellent';
|
||||
if (averageScore >= 3.5) return 'Good';
|
||||
if (averageScore >= 2.5) return 'Fair';
|
||||
return 'Weak';
|
||||
}
|
||||
|
||||
Color linkQualityColor(String quality) {
|
||||
switch (quality) {
|
||||
case 'Excellent':
|
||||
return Colors.green;
|
||||
case 'Good':
|
||||
return Colors.lightGreen;
|
||||
case 'Fair':
|
||||
return Colors.orange;
|
||||
default:
|
||||
return Colors.redAccent;
|
||||
}
|
||||
}
|
||||
191
lib/utils/location_formats.dart
Normal file
191
lib/utils/location_formats.dart
Normal file
@@ -0,0 +1,191 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
enum CoordinateDisplayFormat { decimal, dms, utm }
|
||||
|
||||
String formatCoordinates(
|
||||
double latitude,
|
||||
double longitude,
|
||||
CoordinateDisplayFormat format,
|
||||
) {
|
||||
switch (format) {
|
||||
case CoordinateDisplayFormat.decimal:
|
||||
return '${latitude.toStringAsFixed(5)}, ${longitude.toStringAsFixed(5)}';
|
||||
case CoordinateDisplayFormat.dms:
|
||||
return '${formatDmsCoordinate(latitude, true)} ${formatDmsCoordinate(longitude, false)}';
|
||||
case CoordinateDisplayFormat.utm:
|
||||
return formatUtm(latitude, longitude);
|
||||
}
|
||||
}
|
||||
|
||||
String formatDmsCoordinate(double degrees, bool isLatitude) {
|
||||
final direction = isLatitude
|
||||
? (degrees >= 0 ? 'N' : 'S')
|
||||
: (degrees >= 0 ? 'E' : 'W');
|
||||
|
||||
final absolute = degrees.abs();
|
||||
final deg = absolute.floor();
|
||||
final minDecimal = (absolute - deg) * 60;
|
||||
final min = minDecimal.floor();
|
||||
final sec = (minDecimal - min) * 60;
|
||||
|
||||
return '$deg°${min.toString().padLeft(2, '0')}\'${sec.toStringAsFixed(2).padLeft(5, '0')}"$direction';
|
||||
}
|
||||
|
||||
String formatUtm(double latitude, double longitude) {
|
||||
if (latitude < -80 || latitude > 84) {
|
||||
return formatCoordinates(
|
||||
latitude,
|
||||
longitude,
|
||||
CoordinateDisplayFormat.decimal,
|
||||
);
|
||||
}
|
||||
|
||||
final zone = _utmZone(latitude, longitude);
|
||||
final band = _utmLatitudeBand(latitude);
|
||||
final latRad = _degreesToRadians(latitude);
|
||||
final lonRad = _degreesToRadians(longitude);
|
||||
final centralMeridianRad = _degreesToRadians((zone - 1) * 6 - 180 + 3);
|
||||
|
||||
const semiMajorAxis = 6378137.0;
|
||||
const flattening = 1 / 298.257223563;
|
||||
const scaleFactor = 0.9996;
|
||||
final eccentricitySquared = flattening * (2 - flattening);
|
||||
final secondEccentricitySquared =
|
||||
eccentricitySquared / (1 - eccentricitySquared);
|
||||
|
||||
final sinLat = math.sin(latRad);
|
||||
final cosLat = math.cos(latRad);
|
||||
final tanLat = math.tan(latRad);
|
||||
final n = semiMajorAxis /
|
||||
math.sqrt(1 - eccentricitySquared * sinLat * sinLat);
|
||||
final t = tanLat * tanLat;
|
||||
final c = secondEccentricitySquared * cosLat * cosLat;
|
||||
final a = cosLat * (lonRad - centralMeridianRad);
|
||||
|
||||
final meridianArc = semiMajorAxis *
|
||||
((1 -
|
||||
eccentricitySquared / 4 -
|
||||
3 * eccentricitySquared * eccentricitySquared / 64 -
|
||||
5 *
|
||||
eccentricitySquared *
|
||||
eccentricitySquared *
|
||||
eccentricitySquared /
|
||||
256) *
|
||||
latRad -
|
||||
(3 * eccentricitySquared / 8 +
|
||||
3 * eccentricitySquared * eccentricitySquared / 32 +
|
||||
45 *
|
||||
eccentricitySquared *
|
||||
eccentricitySquared *
|
||||
eccentricitySquared /
|
||||
1024) *
|
||||
math.sin(2 * latRad) +
|
||||
(15 * eccentricitySquared * eccentricitySquared / 256 +
|
||||
45 *
|
||||
eccentricitySquared *
|
||||
eccentricitySquared *
|
||||
eccentricitySquared /
|
||||
1024) *
|
||||
math.sin(4 * latRad) -
|
||||
(35 *
|
||||
eccentricitySquared *
|
||||
eccentricitySquared *
|
||||
eccentricitySquared /
|
||||
3072) *
|
||||
math.sin(6 * latRad));
|
||||
|
||||
final easting = scaleFactor *
|
||||
n *
|
||||
(a +
|
||||
(1 - t + c) * a * a * a / 6 +
|
||||
(5 - 18 * t + t * t + 72 * c - 58 * secondEccentricitySquared) *
|
||||
a *
|
||||
a *
|
||||
a *
|
||||
a *
|
||||
a /
|
||||
120) +
|
||||
500000;
|
||||
var northing = scaleFactor *
|
||||
(meridianArc +
|
||||
n *
|
||||
tanLat *
|
||||
(a * a / 2 +
|
||||
(5 - t + 9 * c + 4 * c * c) * a * a * a * a / 24 +
|
||||
(61 -
|
||||
58 * t +
|
||||
t * t +
|
||||
600 * c -
|
||||
330 * secondEccentricitySquared) *
|
||||
a *
|
||||
a *
|
||||
a *
|
||||
a *
|
||||
a *
|
||||
a /
|
||||
720));
|
||||
if (latitude < 0) {
|
||||
northing += 10000000;
|
||||
}
|
||||
|
||||
return '$zone$band ${easting.round()}E ${northing.round()}N';
|
||||
}
|
||||
|
||||
int _utmZone(double latitude, double longitude) {
|
||||
var zone = ((longitude + 180) / 6).floor() + 1;
|
||||
if (longitude == 180) {
|
||||
zone = 60;
|
||||
}
|
||||
if (latitude >= 56 &&
|
||||
latitude < 64 &&
|
||||
longitude >= 3 &&
|
||||
longitude < 12) {
|
||||
zone = 32;
|
||||
}
|
||||
if (latitude >= 72 && latitude < 84) {
|
||||
if (longitude >= 0 && longitude < 9) {
|
||||
zone = 31;
|
||||
} else if (longitude >= 9 && longitude < 21) {
|
||||
zone = 33;
|
||||
} else if (longitude >= 21 && longitude < 33) {
|
||||
zone = 35;
|
||||
} else if (longitude >= 33 && longitude < 42) {
|
||||
zone = 37;
|
||||
}
|
||||
}
|
||||
return zone.clamp(1, 60);
|
||||
}
|
||||
|
||||
String _utmLatitudeBand(double latitude) {
|
||||
const bands = 'CDEFGHJKLMNPQRSTUVWX';
|
||||
final index = ((latitude + 80) / 8).floor().clamp(0, bands.length - 1);
|
||||
return bands[index];
|
||||
}
|
||||
|
||||
double _degreesToRadians(double degrees) => degrees * math.pi / 180;
|
||||
|
||||
String formatPlusCode(double lat, double lon) {
|
||||
const base = '23456789CFGHJMPQRVWX';
|
||||
|
||||
var normalizedLat = (lat + 90) / 180;
|
||||
var normalizedLon = (lon + 180) / 360;
|
||||
|
||||
final buffer = StringBuffer();
|
||||
for (var i = 0; i < 8; i++) {
|
||||
if (i == 4) {
|
||||
buffer.write('+');
|
||||
}
|
||||
|
||||
final latDigit = (normalizedLat * 20).floor() % 20;
|
||||
final lonDigit = (normalizedLon * 20).floor() % 20;
|
||||
|
||||
buffer
|
||||
..write(base[latDigit])
|
||||
..write(base[lonDigit]);
|
||||
|
||||
normalizedLat = (normalizedLat * 20) % 1;
|
||||
normalizedLon = (normalizedLon * 20) % 1;
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
260
lib/utils/log_rx_route_decoder.dart
Normal file
260
lib/utils/log_rx_route_decoder.dart
Normal file
@@ -0,0 +1,260 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../models/contact.dart';
|
||||
|
||||
class DecodedLogRxRoute {
|
||||
final int payloadType;
|
||||
final int pathDescriptor;
|
||||
final List<int> pathBytes;
|
||||
final int hashSize;
|
||||
|
||||
const DecodedLogRxRoute({
|
||||
required this.payloadType,
|
||||
required this.pathDescriptor,
|
||||
required this.pathBytes,
|
||||
required this.hashSize,
|
||||
});
|
||||
|
||||
List<String> get hopHashes =>
|
||||
LogRxRouteDecoder.splitHopHashes(pathBytes, hashSize: hashSize);
|
||||
|
||||
int get hopCount => hopHashes.length;
|
||||
|
||||
String? get originalSenderHashHex =>
|
||||
hopHashes.isEmpty ? null : hopHashes.first;
|
||||
}
|
||||
|
||||
class ResolvedNodeHash {
|
||||
final String hashHex;
|
||||
final String label;
|
||||
final bool isOwnNode;
|
||||
final bool isUniqueMatch;
|
||||
final int matchCount;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
|
||||
const ResolvedNodeHash({
|
||||
required this.hashHex,
|
||||
required this.label,
|
||||
required this.isOwnNode,
|
||||
required this.isUniqueMatch,
|
||||
required this.matchCount,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
});
|
||||
|
||||
String get hexLabel => '0x${hashHex.toUpperCase()}';
|
||||
}
|
||||
|
||||
class LogRxRouteDecoder {
|
||||
const LogRxRouteDecoder._();
|
||||
|
||||
static DecodedLogRxRoute? decode(
|
||||
Uint8List rawData, {
|
||||
int? preferredHashSize,
|
||||
}) {
|
||||
if (rawData.length < 5 || rawData[0] != 0x88) return null;
|
||||
|
||||
final rawPacketData = rawData.sublist(3);
|
||||
if (rawPacketData.length < 2) return null;
|
||||
|
||||
final header = rawPacketData[0];
|
||||
final routeType = header & 0x03;
|
||||
final payloadType = (header >> 2) & 0x0F;
|
||||
|
||||
var index = 1;
|
||||
if (routeType == 0x00 || routeType == 0x03) {
|
||||
if (rawPacketData.length < index + 5) return null;
|
||||
index += 4;
|
||||
}
|
||||
|
||||
if (rawPacketData.length <= index) return null;
|
||||
final pathDescriptor = rawPacketData[index++];
|
||||
final pathMode = (pathDescriptor & 0xFF) >> 6;
|
||||
final pathByteLen = pathMode == 0
|
||||
? pathDescriptor
|
||||
: descriptorByteLength(pathDescriptor);
|
||||
if (pathByteLen == null || rawPacketData.length < index + pathByteLen) {
|
||||
return null;
|
||||
}
|
||||
final pathBytes = rawPacketData.sublist(index, index + pathByteLen);
|
||||
final hashSize = pathMode == 0
|
||||
? 1
|
||||
: (descriptorHashSize(pathDescriptor) ??
|
||||
inferHashSize(pathBytes, preferredHashSize: preferredHashSize));
|
||||
|
||||
return DecodedLogRxRoute(
|
||||
payloadType: payloadType,
|
||||
pathDescriptor: pathDescriptor,
|
||||
pathBytes: pathBytes,
|
||||
hashSize: hashSize,
|
||||
);
|
||||
}
|
||||
|
||||
static int? descriptorHashSize(int pathDescriptor) {
|
||||
final normalized = pathDescriptor & 0xFF;
|
||||
final mode = normalized >> 6;
|
||||
if (mode == 3) return null;
|
||||
return mode + 1;
|
||||
}
|
||||
|
||||
static int? descriptorHopCount(int pathDescriptor) {
|
||||
final hashSize = descriptorHashSize(pathDescriptor);
|
||||
if (hashSize == null) return null;
|
||||
return (pathDescriptor & 0xFF) & 0x3F;
|
||||
}
|
||||
|
||||
static int? descriptorByteLength(int pathDescriptor) {
|
||||
final hashSize = descriptorHashSize(pathDescriptor);
|
||||
final hopCount = descriptorHopCount(pathDescriptor);
|
||||
if (hashSize == null || hopCount == null) return null;
|
||||
final byteLen = hopCount * hashSize;
|
||||
return byteLen <= 64 ? byteLen : null;
|
||||
}
|
||||
|
||||
static int inferHashSize(List<int> pathBytes, {int? preferredHashSize}) {
|
||||
if (pathBytes.isEmpty) return 1;
|
||||
|
||||
final normalizedPreferred =
|
||||
preferredHashSize != null &&
|
||||
preferredHashSize >= 1 &&
|
||||
preferredHashSize <= 3
|
||||
? preferredHashSize
|
||||
: null;
|
||||
if (normalizedPreferred != null &&
|
||||
pathBytes.length % normalizedPreferred == 0) {
|
||||
return normalizedPreferred;
|
||||
}
|
||||
|
||||
for (final candidate in const [3, 2, 1]) {
|
||||
if (pathBytes.length % candidate == 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static List<String> splitHopHashes(
|
||||
List<int> pathBytes, {
|
||||
required int hashSize,
|
||||
}) {
|
||||
if (pathBytes.isEmpty) return const [];
|
||||
if (hashSize < 1 || hashSize > 3 || pathBytes.length % hashSize != 0) {
|
||||
return pathBytes
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.toList();
|
||||
}
|
||||
|
||||
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(),
|
||||
);
|
||||
}
|
||||
return hops;
|
||||
}
|
||||
|
||||
static List<int> reverseHopBytes(
|
||||
List<int> pathBytes, {
|
||||
required int hashSize,
|
||||
}) {
|
||||
if (pathBytes.isEmpty) return const [];
|
||||
if (hashSize < 1 || hashSize > 3 || pathBytes.length % hashSize != 0) {
|
||||
return List<int>.from(pathBytes.reversed);
|
||||
}
|
||||
|
||||
final reversed = <int>[];
|
||||
for (
|
||||
var index = pathBytes.length - hashSize;
|
||||
index >= 0;
|
||||
index -= hashSize
|
||||
) {
|
||||
reversed.addAll(pathBytes.sublist(index, index + hashSize));
|
||||
}
|
||||
return reversed;
|
||||
}
|
||||
|
||||
static ResolvedNodeHash resolveHash(
|
||||
String hashHex, {
|
||||
required Iterable<Contact> contacts,
|
||||
Uint8List? ownPublicKey,
|
||||
String? ownName,
|
||||
double? ownLatitude,
|
||||
double? ownLongitude,
|
||||
}) {
|
||||
final normalizedHashHex = hashHex.toLowerCase();
|
||||
final ownKeyHex = _bytesToHex(ownPublicKey);
|
||||
if (ownKeyHex != null && ownKeyHex.startsWith(normalizedHashHex)) {
|
||||
final ownLabel = (ownName != null && ownName.trim().isNotEmpty)
|
||||
? '$ownName (you)'
|
||||
: 'You';
|
||||
return ResolvedNodeHash(
|
||||
hashHex: normalizedHashHex,
|
||||
label: ownLabel,
|
||||
isOwnNode: true,
|
||||
isUniqueMatch: true,
|
||||
matchCount: 1,
|
||||
latitude: ownLatitude,
|
||||
longitude: ownLongitude,
|
||||
);
|
||||
}
|
||||
|
||||
final matches = contacts.where((contact) {
|
||||
return contact.publicKeyHex.toLowerCase().startsWith(normalizedHashHex);
|
||||
}).toList();
|
||||
|
||||
if (matches.isEmpty) {
|
||||
return ResolvedNodeHash(
|
||||
hashHex: normalizedHashHex,
|
||||
label: 'Unknown',
|
||||
isOwnNode: false,
|
||||
isUniqueMatch: false,
|
||||
matchCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
if (matches.length == 1) {
|
||||
final location = matches.first.displayLocation;
|
||||
return ResolvedNodeHash(
|
||||
hashHex: normalizedHashHex,
|
||||
label: matches.first.displayName,
|
||||
isOwnNode: false,
|
||||
isUniqueMatch: true,
|
||||
matchCount: 1,
|
||||
latitude: location?.latitude,
|
||||
longitude: location?.longitude,
|
||||
);
|
||||
}
|
||||
|
||||
final candidateNames = matches
|
||||
.map((contact) => contact.displayName)
|
||||
.where((name) => name.trim().isNotEmpty)
|
||||
.take(2)
|
||||
.join(', ');
|
||||
final extraCount = matches.length - 2;
|
||||
final label = candidateNames.isEmpty
|
||||
? '${matches.length} contacts'
|
||||
: extraCount > 0
|
||||
? '$candidateNames +$extraCount'
|
||||
: candidateNames;
|
||||
return ResolvedNodeHash(
|
||||
hashHex: normalizedHashHex,
|
||||
label: label,
|
||||
isOwnNode: false,
|
||||
isUniqueMatch: false,
|
||||
matchCount: matches.length,
|
||||
);
|
||||
}
|
||||
|
||||
static String? _bytesToHex(Uint8List? bytes) {
|
||||
if (bytes == null || bytes.isEmpty) return null;
|
||||
return bytes
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
}
|
||||
}
|
||||
168
lib/utils/media_swarm_protocol.dart
Normal file
168
lib/utils/media_swarm_protocol.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
const int _swarmMagic = 0x6d; // 'm'
|
||||
const int _swarmKindRequest = 0x01;
|
||||
const int _swarmKindAvailability = 0x02;
|
||||
|
||||
class MediaSwarmRequest {
|
||||
final String mediaType;
|
||||
final String sessionId;
|
||||
final String requesterKey6;
|
||||
final List<int> missingIndices;
|
||||
|
||||
const MediaSwarmRequest({
|
||||
required this.mediaType,
|
||||
required this.sessionId,
|
||||
required this.requesterKey6,
|
||||
this.missingIndices = const [],
|
||||
});
|
||||
|
||||
bool get requestsAll => missingIndices.isEmpty;
|
||||
|
||||
Uint8List encodeBinary() {
|
||||
final normalizedMissing = missingIndices.toSet().toList()..sort();
|
||||
final out = Uint8List(14 + normalizedMissing.length);
|
||||
out[0] = _swarmMagic;
|
||||
out[1] = _swarmKindRequest;
|
||||
out[2] = _encodeMediaType(mediaType);
|
||||
_writeSessionId(out, 3, sessionId);
|
||||
_writeKey6(out, 7, requesterKey6);
|
||||
out[13] = normalizedMissing.length;
|
||||
for (var i = 0; i < normalizedMissing.length; i++) {
|
||||
out[14 + i] = normalizedMissing[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static MediaSwarmRequest? tryParseBinary(Uint8List payload) {
|
||||
if (payload.length < 14 ||
|
||||
payload[0] != _swarmMagic ||
|
||||
payload[1] != _swarmKindRequest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final mediaType = _decodeMediaType(payload[2]);
|
||||
if (mediaType == null) return null;
|
||||
|
||||
final missingCount = payload[13];
|
||||
if (payload.length != 14 + missingCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return MediaSwarmRequest(
|
||||
mediaType: mediaType,
|
||||
sessionId: _readSessionId(payload, 3),
|
||||
requesterKey6: _readKey6(payload, 7),
|
||||
missingIndices: payload.sublist(14),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MediaSwarmAvailability {
|
||||
final String mediaType;
|
||||
final String sessionId;
|
||||
final String requesterKey6;
|
||||
final String responderKey6;
|
||||
final List<int> availableIndices;
|
||||
|
||||
const MediaSwarmAvailability({
|
||||
required this.mediaType,
|
||||
required this.sessionId,
|
||||
required this.requesterKey6,
|
||||
required this.responderKey6,
|
||||
required this.availableIndices,
|
||||
});
|
||||
|
||||
bool get servesAll => availableIndices.isEmpty;
|
||||
|
||||
Uint8List encodeBinary() {
|
||||
final normalizedAvailable = availableIndices.toSet().toList()..sort();
|
||||
final out = Uint8List(20 + normalizedAvailable.length);
|
||||
out[0] = _swarmMagic;
|
||||
out[1] = _swarmKindAvailability;
|
||||
out[2] = _encodeMediaType(mediaType);
|
||||
_writeSessionId(out, 3, sessionId);
|
||||
_writeKey6(out, 7, requesterKey6);
|
||||
_writeKey6(out, 13, responderKey6);
|
||||
out[19] = normalizedAvailable.length;
|
||||
for (var i = 0; i < normalizedAvailable.length; i++) {
|
||||
out[20 + i] = normalizedAvailable[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static MediaSwarmAvailability? tryParseBinary(Uint8List payload) {
|
||||
if (payload.length < 20 ||
|
||||
payload[0] != _swarmMagic ||
|
||||
payload[1] != _swarmKindAvailability) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final mediaType = _decodeMediaType(payload[2]);
|
||||
if (mediaType == null) return null;
|
||||
|
||||
final availableCount = payload[19];
|
||||
if (payload.length != 20 + availableCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return MediaSwarmAvailability(
|
||||
mediaType: mediaType,
|
||||
sessionId: _readSessionId(payload, 3),
|
||||
requesterKey6: _readKey6(payload, 7),
|
||||
responderKey6: _readKey6(payload, 13),
|
||||
availableIndices: payload.sublist(20),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int _encodeMediaType(String mediaType) {
|
||||
return switch (mediaType) {
|
||||
'voice' => 0x01,
|
||||
'image' => 0x02,
|
||||
_ => throw ArgumentError.value(mediaType, 'mediaType'),
|
||||
};
|
||||
}
|
||||
|
||||
String? _decodeMediaType(int raw) {
|
||||
return switch (raw) {
|
||||
0x01 => 'voice',
|
||||
0x02 => 'image',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
void _writeSessionId(Uint8List out, int offset, String sessionId) {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
|
||||
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
|
||||
}
|
||||
for (var i = 0; i < 4; i++) {
|
||||
out[offset + i] = int.parse(
|
||||
sessionId.substring(i * 2, i * 2 + 2),
|
||||
radix: 16,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _readSessionId(Uint8List payload, int offset) {
|
||||
return payload
|
||||
.sublist(offset, offset + 4)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
}
|
||||
|
||||
void _writeKey6(Uint8List out, int offset, String key6) {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(key6)) {
|
||||
throw ArgumentError.value(key6, 'key6', 'Expected 12 hex chars');
|
||||
}
|
||||
for (var i = 0; i < 6; i++) {
|
||||
out[offset + i] = int.parse(key6.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
}
|
||||
|
||||
String _readKey6(Uint8List payload, int offset) {
|
||||
return payload
|
||||
.sublist(offset, offset + 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
}
|
||||
149
lib/utils/message_airtime_estimator.dart
Normal file
149
lib/utils/message_airtime_estimator.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
import '../models/message.dart';
|
||||
import 'image_message_parser.dart';
|
||||
import 'voice_message_parser.dart';
|
||||
|
||||
const int _defaultLoRaSf = 10;
|
||||
const int _defaultLoRaCr = 5;
|
||||
const int _defaultLoRaBwHz = 250000;
|
||||
const int _defaultLoRaPreambleSymbols = 8;
|
||||
const int _defaultLoRaCrcEnabled = 1;
|
||||
const int _defaultLoRaExplicitHeader = 1;
|
||||
const double _defaultAirtimeBudgetFactor = 1.0;
|
||||
const int _meshPacketHeaderBytes = 2;
|
||||
const int _textFrameBaseBytes = 10;
|
||||
|
||||
Duration estimateMessageTransmitDuration(
|
||||
Message message, {
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
}) {
|
||||
final imageEnvelope = ImageEnvelope.tryParse(message.text);
|
||||
if (imageEnvelope != null) {
|
||||
return estimateImageTransmitDuration(
|
||||
fragmentCount: imageEnvelope.total,
|
||||
sizeBytes: imageEnvelope.sizeBytes,
|
||||
pathLen: message.pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
}
|
||||
|
||||
final voiceEnvelope = VoiceEnvelope.tryParseText(message.text);
|
||||
if (voiceEnvelope != null) {
|
||||
return estimateVoiceTransmitDuration(
|
||||
mode: voiceEnvelope.mode,
|
||||
packetCount: voiceEnvelope.total,
|
||||
durationMs: voiceEnvelope.durationMs,
|
||||
pathLen: message.pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
}
|
||||
|
||||
final voicePacket = VoicePacket.tryParseText(message.text);
|
||||
if (voicePacket != null) {
|
||||
return estimateVoiceTransmitDuration(
|
||||
mode: voicePacket.mode,
|
||||
packetCount: voicePacket.total,
|
||||
durationMs: voicePacket.durationMs * voicePacket.total,
|
||||
pathLen: message.pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
}
|
||||
|
||||
final normalizedPathLen = _normalizedPathLen(message.pathLen);
|
||||
final payloadBytes = _textFrameBaseBytes + message.text.length;
|
||||
final hops = normalizedPathLen + 1;
|
||||
final airtimeMs = _estimateLoRaAirtimeMs(
|
||||
_meshPacketHeaderBytes + normalizedPathLen + payloadBytes,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
|
||||
return Duration(
|
||||
milliseconds: (airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops)
|
||||
.round(),
|
||||
);
|
||||
}
|
||||
|
||||
int _normalizedPathLen(int pathLen) {
|
||||
if (pathLen < 0 || pathLen >= 255) return 0;
|
||||
return pathLen.clamp(0, 64).toInt();
|
||||
}
|
||||
|
||||
double _estimateLoRaAirtimeMs(
|
||||
int payloadLenBytes, {
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
}) {
|
||||
final sf = _normalizeSf(radioSf);
|
||||
final bw = _resolveBandwidthHz(radioBw).toDouble();
|
||||
final cr = (_normalizeCr(radioCr) - 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;
|
||||
}
|
||||
|
||||
int _normalizeSf(int? value) {
|
||||
if (value == null) return _defaultLoRaSf;
|
||||
if (value >= 5 && value <= 12) return value;
|
||||
return _defaultLoRaSf;
|
||||
}
|
||||
|
||||
int _normalizeCr(int? value) {
|
||||
if (value == null) return _defaultLoRaCr;
|
||||
if (value >= 5 && value <= 8) return value;
|
||||
return _defaultLoRaCr;
|
||||
}
|
||||
|
||||
int _resolveBandwidthHz(int? rawBw) {
|
||||
if (rawBw == null) return _defaultLoRaBwHz;
|
||||
if (rawBw > 1000) return rawBw;
|
||||
switch (rawBw) {
|
||||
case 0:
|
||||
return 7800;
|
||||
case 1:
|
||||
return 10400;
|
||||
case 2:
|
||||
return 15600;
|
||||
case 3:
|
||||
return 20800;
|
||||
case 4:
|
||||
return 31250;
|
||||
case 5:
|
||||
return 41700;
|
||||
case 6:
|
||||
return 62500;
|
||||
case 7:
|
||||
return 125000;
|
||||
case 8:
|
||||
return 250000;
|
||||
case 9:
|
||||
return 500000;
|
||||
default:
|
||||
return _defaultLoRaBwHz;
|
||||
}
|
||||
}
|
||||
181
lib/utils/message_extensions.dart
Normal file
181
lib/utils/message_extensions.dart
Normal file
@@ -0,0 +1,181 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/message_route_metadata.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/messages_provider.dart';
|
||||
|
||||
/// Extension for Message to provide localized delivery status
|
||||
extension MessageLocalization on Message {
|
||||
/// Get localized delivery status text
|
||||
String getLocalizedDeliveryStatus(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final messagesProvider = context.read<MessagesProvider>();
|
||||
final routeMetadata = messagesProvider.getMessageRouteMetadata(id);
|
||||
|
||||
// For channel messages, show echo count instead of delivery status
|
||||
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
|
||||
final latestMeta = _formatChannelStatusMeta(context, routeMetadata);
|
||||
if (echoCount == 0) {
|
||||
if (messagesProvider.hasChannelSendWarning(id)) {
|
||||
return 'Broadcast may have failed';
|
||||
}
|
||||
return l10n.broadcast; // "Broadcast (no echoes yet)"
|
||||
} else if (echoCount == 1) {
|
||||
return latestMeta == null ? '1 node' : '1 node • $latestMeta';
|
||||
} else {
|
||||
return latestMeta == null
|
||||
? '$echoCount nodes'
|
||||
: '$echoCount nodes • $latestMeta';
|
||||
}
|
||||
}
|
||||
|
||||
switch (deliveryStatus) {
|
||||
case MessageDeliveryStatus.sending:
|
||||
if (isContactMessage) {
|
||||
if (retryAttempt > 0) {
|
||||
final routeSuffix = routeMetadata != null
|
||||
? ' • ${routeMetadata.modeLabel}'
|
||||
: '';
|
||||
return '${l10n.pending} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
|
||||
}
|
||||
return routeMetadata == null
|
||||
? l10n.pending
|
||||
: '${l10n.pending} • ${routeMetadata.modeLabel}';
|
||||
}
|
||||
return l10n.sending;
|
||||
case MessageDeliveryStatus.sent:
|
||||
return routeMetadata == null
|
||||
? l10n.sent
|
||||
: '${l10n.sent} • ${routeMetadata.modeLabel}';
|
||||
case MessageDeliveryStatus.delivered:
|
||||
if (retryAttempt > 0 && roundTripTimeMs != null) {
|
||||
final routeSuffix = routeMetadata != null
|
||||
? ' • ${routeMetadata.modeLabel}'
|
||||
: '';
|
||||
return '${l10n.deliveredWithTime(roundTripTimeMs!)} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
|
||||
}
|
||||
if (retryAttempt > 0) {
|
||||
final routeSuffix = routeMetadata != null
|
||||
? ' • ${routeMetadata.modeLabel}'
|
||||
: '';
|
||||
return '${l10n.delivered} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
|
||||
}
|
||||
if (roundTripTimeMs != null) {
|
||||
return routeMetadata == null
|
||||
? l10n.deliveredWithTime(roundTripTimeMs!)
|
||||
: '${l10n.deliveredWithTime(roundTripTimeMs!)} • ${routeMetadata.modeLabel}';
|
||||
}
|
||||
return routeMetadata == null
|
||||
? l10n.delivered
|
||||
: '${l10n.delivered} • ${routeMetadata.modeLabel}';
|
||||
case MessageDeliveryStatus.failed:
|
||||
if (retryAttempt > 0) {
|
||||
final routeSuffix = routeMetadata != null
|
||||
? ' • ${routeMetadata.modeLabel}'
|
||||
: '';
|
||||
return '${l10n.failed} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
|
||||
}
|
||||
return routeMetadata == null
|
||||
? l10n.failed
|
||||
: '${l10n.failed} • ${routeMetadata.modeLabel}';
|
||||
case MessageDeliveryStatus.received:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get localized time ago string
|
||||
String getLocalizedTimeAgo(BuildContext context) {
|
||||
final diff = DateTime.now().difference(sentAt);
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
if (diff.inMinutes < 1) return l10n.justNow;
|
||||
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
|
||||
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
|
||||
return l10n.daysAgo(diff.inDays);
|
||||
}
|
||||
|
||||
String? _formatEchoMeta(BuildContext context) {
|
||||
if (lastEchoSnrRaw == null &&
|
||||
lastEchoRssiDbm == null &&
|
||||
lastEchoAt == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final parts = <String>[];
|
||||
if (lastEchoRssiDbm != null) {
|
||||
parts.add('R ${_barsForRssi(lastEchoRssiDbm!)} $lastEchoRssiDbm dBm');
|
||||
}
|
||||
if (lastEchoSnrRaw != null) {
|
||||
final snrDb = lastEchoSnrRaw!.toSigned(8) / 4.0;
|
||||
parts.add('S ${_barsForSnr(snrDb)} ${snrDb.toStringAsFixed(1)} dB');
|
||||
}
|
||||
if (lastEchoAt != null) {
|
||||
final diff = DateTime.now().difference(lastEchoAt!);
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (diff.inMinutes < 1) {
|
||||
parts.add(l10n.justNow);
|
||||
} else if (diff.inMinutes < 60) {
|
||||
parts.add(l10n.minutesAgo(diff.inMinutes));
|
||||
} else if (diff.inHours < 24) {
|
||||
parts.add(l10n.hoursAgo(diff.inHours));
|
||||
} else {
|
||||
parts.add(l10n.daysAgo(diff.inDays));
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.isEmpty) return null;
|
||||
return parts.join(' • ');
|
||||
}
|
||||
|
||||
String? _formatChannelStatusMeta(
|
||||
BuildContext context,
|
||||
MessageRouteMetadata? routeMetadata,
|
||||
) {
|
||||
final parts = <String>[];
|
||||
final hopLabel = _formatChannelHopMeta(routeMetadata);
|
||||
if (hopLabel != null) {
|
||||
parts.add(hopLabel);
|
||||
}
|
||||
|
||||
final echoMeta = _formatEchoMeta(context);
|
||||
if (echoMeta != null) {
|
||||
parts.add(echoMeta);
|
||||
}
|
||||
|
||||
if (parts.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parts.join(' • ');
|
||||
}
|
||||
|
||||
String? _formatChannelHopMeta(MessageRouteMetadata? routeMetadata) {
|
||||
if (routeMetadata?.mode.name == 'flood') {
|
||||
return routeMetadata!.modeLabel;
|
||||
}
|
||||
|
||||
final effectivePathLen = routeMetadata?.hopCount ?? pathLen;
|
||||
if (effectivePathLen <= 0 || effectivePathLen >= 255) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '$effectivePathLen hop${effectivePathLen == 1 ? '' : 's'}';
|
||||
}
|
||||
|
||||
String _barsForRssi(int rssiDbm) {
|
||||
// Approximate useful RSSI range: -120..-70 dBm
|
||||
final score = ((rssiDbm + 120) / 10).round().clamp(0, 5);
|
||||
return _asciiBars(score, 5);
|
||||
}
|
||||
|
||||
String _barsForSnr(double snrDb) {
|
||||
// Approximate useful SNR range: -5..+20 dB
|
||||
final score = ((snrDb + 5.0) / 5.0).round().clamp(0, 5);
|
||||
return _asciiBars(score, 5);
|
||||
}
|
||||
|
||||
String _asciiBars(int filled, int total) {
|
||||
return '[${'#' * filled}${'-' * (total - filled)}]';
|
||||
}
|
||||
}
|
||||
87
lib/utils/raw_route_probe.dart
Normal file
87
lib/utils/raw_route_probe.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
class RawRouteProbeRequest {
|
||||
static const int _binaryMagic = 0x70; // 'p'
|
||||
|
||||
final int nonce;
|
||||
final String requesterKey6;
|
||||
|
||||
const RawRouteProbeRequest({
|
||||
required this.nonce,
|
||||
required this.requesterKey6,
|
||||
});
|
||||
|
||||
static RawRouteProbeRequest? tryParseBinary(Uint8List payload) {
|
||||
if (payload.length != 11 || payload[0] != _binaryMagic) return null;
|
||||
try {
|
||||
final nonce =
|
||||
(payload[1] << 24) |
|
||||
(payload[2] << 16) |
|
||||
(payload[3] << 8) |
|
||||
payload[4];
|
||||
final requesterKey6 = payload
|
||||
.sublist(5, 11)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
return RawRouteProbeRequest(nonce: nonce, requesterKey6: requesterKey6);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List encodeBinary() {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
|
||||
throw ArgumentError.value(
|
||||
requesterKey6,
|
||||
'requesterKey6',
|
||||
'Expected 12 hex chars',
|
||||
);
|
||||
}
|
||||
final out = Uint8List(11);
|
||||
out[0] = _binaryMagic;
|
||||
out[1] = (nonce >> 24) & 0xFF;
|
||||
out[2] = (nonce >> 16) & 0xFF;
|
||||
out[3] = (nonce >> 8) & 0xFF;
|
||||
out[4] = nonce & 0xFF;
|
||||
for (var i = 0; i < 6; i++) {
|
||||
out[5 + i] = int.parse(
|
||||
requesterKey6.substring(i * 2, i * 2 + 2),
|
||||
radix: 16,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
class RawRouteProbeAck {
|
||||
static const int _binaryMagic = 0x71; // 'q'
|
||||
|
||||
final int nonce;
|
||||
|
||||
const RawRouteProbeAck({required this.nonce});
|
||||
|
||||
static RawRouteProbeAck? tryParseBinary(Uint8List payload) {
|
||||
if (payload.length != 5 || payload[0] != _binaryMagic) return null;
|
||||
try {
|
||||
final nonce =
|
||||
(payload[1] << 24) |
|
||||
(payload[2] << 16) |
|
||||
(payload[3] << 8) |
|
||||
payload[4];
|
||||
return RawRouteProbeAck(nonce: nonce);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List encodeBinary() {
|
||||
final out = Uint8List(5);
|
||||
out[0] = _binaryMagic;
|
||||
out[1] = (nonce >> 24) & 0xFF;
|
||||
out[2] = (nonce >> 16) & 0xFF;
|
||||
out[3] = (nonce >> 8) & 0xFF;
|
||||
out[4] = nonce & 0xFF;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
160
lib/utils/rssi_location_estimator.dart
Normal file
160
lib/utils/rssi_location_estimator.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// An RSSI observation from a single repeater.
|
||||
class RssiObservation {
|
||||
final LatLng repeaterLocation;
|
||||
final int rssiDbm;
|
||||
final DateTime observedAt;
|
||||
|
||||
const RssiObservation({
|
||||
required this.repeaterLocation,
|
||||
required this.rssiDbm,
|
||||
required this.observedAt,
|
||||
});
|
||||
|
||||
double? get estimatedDistanceMeters =>
|
||||
RssiLocationEstimator.estimateDistanceMeters(rssiDbm);
|
||||
}
|
||||
|
||||
/// Estimate distance from RSSI using the log-distance path loss model.
|
||||
///
|
||||
/// For LoRa at ~900 MHz:
|
||||
/// - Path loss exponent (n) ≈ 2.7-3.5 for outdoor environments
|
||||
/// - Reference distance: 1m, reference RSSI: -30 dBm (typical LoRa at 1m)
|
||||
///
|
||||
/// Formula: distance = 10 ^ ((txPower - rssi) / (10 * n))
|
||||
class RssiLocationEstimator {
|
||||
/// Estimate distance in meters from RSSI value.
|
||||
static double? estimateDistanceMeters(int rssiDbm) {
|
||||
if (rssiDbm > -20 || rssiDbm < -140) return null;
|
||||
|
||||
const double referenceRssi = -30.0;
|
||||
const double pathLossExponent = 3.0;
|
||||
|
||||
final distance = math.pow(
|
||||
10.0,
|
||||
(referenceRssi - rssiDbm) / (10.0 * pathLossExponent),
|
||||
).toDouble();
|
||||
|
||||
return distance.clamp(10.0, 50000.0);
|
||||
}
|
||||
|
||||
/// Offset a point by distance and bearing (Haversine inverse).
|
||||
static LatLng offsetPoint(
|
||||
LatLng origin,
|
||||
double distanceMeters,
|
||||
double bearingDegrees,
|
||||
) {
|
||||
const double earthRadius = 6371000.0;
|
||||
final lat1 = origin.latitude * math.pi / 180.0;
|
||||
final lon1 = origin.longitude * math.pi / 180.0;
|
||||
final bearing = bearingDegrees * math.pi / 180.0;
|
||||
final angDist = distanceMeters / earthRadius;
|
||||
|
||||
final lat2 = math.asin(
|
||||
math.sin(lat1) * math.cos(angDist) +
|
||||
math.cos(lat1) * math.sin(angDist) * math.cos(bearing),
|
||||
);
|
||||
final lon2 = lon1 +
|
||||
math.atan2(
|
||||
math.sin(bearing) * math.sin(angDist) * math.cos(lat1),
|
||||
math.cos(angDist) - math.sin(lat1) * math.sin(lat2),
|
||||
);
|
||||
|
||||
return LatLng(lat2 * 180.0 / math.pi, lon2 * 180.0 / math.pi);
|
||||
}
|
||||
|
||||
/// Single-repeater estimate: offset from repeater by RSSI distance.
|
||||
static LatLng? estimateFromRepeater({
|
||||
required LatLng repeaterLocation,
|
||||
required int rssiDbm,
|
||||
required List<int> contactPublicKey,
|
||||
}) {
|
||||
final distance = estimateDistanceMeters(rssiDbm);
|
||||
if (distance == null) return null;
|
||||
|
||||
final keyHash = contactPublicKey.fold<int>(0, (a, b) => a ^ b);
|
||||
final bearing = (keyHash % 360).toDouble();
|
||||
|
||||
return offsetPoint(repeaterLocation, distance, bearing);
|
||||
}
|
||||
|
||||
/// Trilateration from multiple RSSI observations.
|
||||
///
|
||||
/// With 1 observation: offset from repeater (bearing from key hash).
|
||||
/// With 2 observations: weighted midpoint on the line between circles.
|
||||
/// With 3+ observations: weighted centroid of circle intersection region.
|
||||
///
|
||||
/// Each observation is weighted by 1/distance² (closer = more accurate).
|
||||
static LatLng? trilaterate({
|
||||
required List<RssiObservation> observations,
|
||||
required List<int> contactPublicKey,
|
||||
Duration maxAge = const Duration(minutes: 30),
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
final recent = observations
|
||||
.where((o) =>
|
||||
now.difference(o.observedAt) <= maxAge &&
|
||||
o.estimatedDistanceMeters != null)
|
||||
.toList();
|
||||
|
||||
if (recent.isEmpty) return null;
|
||||
|
||||
if (recent.length == 1) {
|
||||
return estimateFromRepeater(
|
||||
repeaterLocation: recent.first.repeaterLocation,
|
||||
rssiDbm: recent.first.rssiDbm,
|
||||
contactPublicKey: contactPublicKey,
|
||||
);
|
||||
}
|
||||
|
||||
// Weighted centroid: each repeater contributes a candidate point
|
||||
// on the circle towards the centroid of all repeaters.
|
||||
// Weight = 1/distance² (inverse square — closer observations dominate).
|
||||
|
||||
// Step 1: compute raw centroid of all repeater locations
|
||||
double centroidLat = 0, centroidLon = 0;
|
||||
for (final obs in recent) {
|
||||
centroidLat += obs.repeaterLocation.latitude;
|
||||
centroidLon += obs.repeaterLocation.longitude;
|
||||
}
|
||||
centroidLat /= recent.length;
|
||||
centroidLon /= recent.length;
|
||||
final centroid = LatLng(centroidLat, centroidLon);
|
||||
|
||||
// Step 2: for each observation, compute a candidate point on the
|
||||
// circle (at RSSI distance) in the direction of the centroid.
|
||||
double weightedLat = 0, weightedLon = 0, totalWeight = 0;
|
||||
|
||||
for (final obs in recent) {
|
||||
final dist = obs.estimatedDistanceMeters!;
|
||||
final weight = 1.0 / (dist * dist);
|
||||
|
||||
// Bearing from this repeater towards the centroid
|
||||
final bearing = _bearingDegrees(obs.repeaterLocation, centroid);
|
||||
final candidate = offsetPoint(obs.repeaterLocation, dist, bearing);
|
||||
|
||||
weightedLat += candidate.latitude * weight;
|
||||
weightedLon += candidate.longitude * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
|
||||
if (totalWeight <= 0) return null;
|
||||
|
||||
return LatLng(weightedLat / totalWeight, weightedLon / totalWeight);
|
||||
}
|
||||
|
||||
/// Bearing in degrees from point A to point B.
|
||||
static double _bearingDegrees(LatLng a, LatLng b) {
|
||||
final lat1 = a.latitude * math.pi / 180.0;
|
||||
final lat2 = b.latitude * math.pi / 180.0;
|
||||
final dLon = (b.longitude - a.longitude) * math.pi / 180.0;
|
||||
|
||||
final y = math.sin(dLon) * math.cos(lat2);
|
||||
final x = math.cos(lat1) * math.sin(lat2) -
|
||||
math.sin(lat1) * math.cos(lat2) * math.cos(dLon);
|
||||
|
||||
return (math.atan2(y, x) * 180.0 / math.pi + 360.0) % 360.0;
|
||||
}
|
||||
}
|
||||
525
lib/utils/sample_data_generator.dart
Normal file
525
lib/utils/sample_data_generator.dart
Normal file
@@ -0,0 +1,525 @@
|
||||
import 'dart:typed_data';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/message_contact_location.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// Generates sample data for testing/demo purposes
|
||||
class SampleDataGenerator {
|
||||
static final Random _random = Random();
|
||||
|
||||
static MessageContactLocation _sampleSnapshot({
|
||||
required LatLng location,
|
||||
required DateTime receivedAt,
|
||||
required String source,
|
||||
}) {
|
||||
return MessageContactLocation(
|
||||
location: location,
|
||||
source: source,
|
||||
capturedAt: receivedAt,
|
||||
sourceTimestamp: receivedAt.subtract(const Duration(minutes: 2)),
|
||||
);
|
||||
}
|
||||
|
||||
static LatLng _randomNearbyLocation(LatLng center, double spread) {
|
||||
final latOffset = (_random.nextDouble() - 0.5) * spread;
|
||||
final lonOffset = (_random.nextDouble() - 0.5) * spread;
|
||||
return LatLng(center.latitude + latOffset, center.longitude + lonOffset);
|
||||
}
|
||||
|
||||
static SampleMessageBatch generateAllMessages({
|
||||
required LatLng centerLocation,
|
||||
required AppLocalizations l10n,
|
||||
int foundPersonCount = 2,
|
||||
int fireCount = 1,
|
||||
int stagingCount = 1,
|
||||
int objectCount = 1,
|
||||
int generalChannelMessages = 8,
|
||||
int emergencyChannelMessages = 5,
|
||||
}) {
|
||||
final sarBatch = generateSarMarkerMessages(
|
||||
centerLocation: centerLocation,
|
||||
l10n: l10n,
|
||||
foundPersonCount: foundPersonCount,
|
||||
fireCount: fireCount,
|
||||
stagingCount: stagingCount,
|
||||
objectCount: objectCount,
|
||||
);
|
||||
final channelBatch = generateChannelMessages(
|
||||
centerLocation: centerLocation,
|
||||
l10n: l10n,
|
||||
generalChannelMessages: generalChannelMessages,
|
||||
emergencyChannelMessages: emergencyChannelMessages,
|
||||
);
|
||||
|
||||
return SampleMessageBatch(
|
||||
messages: [...sarBatch.messages, ...channelBatch.messages],
|
||||
contactLocations: {
|
||||
...sarBatch.contactLocations,
|
||||
...channelBatch.contactLocations,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Generate sample contacts around a center location
|
||||
static List<Contact> generateContacts({
|
||||
required LatLng centerLocation,
|
||||
required AppLocalizations l10n,
|
||||
int teamMemberCount = 5,
|
||||
int channelCount = 2,
|
||||
}) {
|
||||
final contacts = <Contact>[];
|
||||
final now = DateTime.now();
|
||||
|
||||
final teamNames = [
|
||||
'👮${l10n.samplePoliceLead}',
|
||||
'🚁${l10n.sampleDroneOperator}',
|
||||
'🧑🏻🚒${l10n.sampleFirefighterAlpha}',
|
||||
'🧑⚕️${l10n.sampleMedicCharlie}',
|
||||
'📡${l10n.sampleCommandDelta}',
|
||||
'🚒${l10n.sampleFireEngine}',
|
||||
'👨✈️${l10n.sampleAirSupport}',
|
||||
'🧑💼${l10n.sampleBaseCoordinator}',
|
||||
];
|
||||
|
||||
final channelNames = [
|
||||
l10n.general,
|
||||
l10n.channelEmergency,
|
||||
l10n.channelCoordination,
|
||||
l10n.channelUpdates,
|
||||
];
|
||||
|
||||
// Generate team members (chat contacts)
|
||||
for (int i = 0; i < teamMemberCount && i < teamNames.length; i++) {
|
||||
// Generate location within ~1km radius
|
||||
final latOffset = (_random.nextDouble() - 0.5) * 0.02; // ~1km
|
||||
final lonOffset = (_random.nextDouble() - 0.5) * 0.02;
|
||||
final lat = centerLocation.latitude + latOffset;
|
||||
final lon = centerLocation.longitude + lonOffset;
|
||||
|
||||
// Generate random public key
|
||||
final publicKey = Uint8List.fromList(
|
||||
List.generate(32, (_) => _random.nextInt(256)),
|
||||
);
|
||||
|
||||
// Random battery 20-100%
|
||||
final battery = 20 + _random.nextInt(81);
|
||||
|
||||
// Random temperature 15-35°C
|
||||
final temp = 15.0 + _random.nextDouble() * 20.0;
|
||||
|
||||
final telemetry = ContactTelemetry(
|
||||
gpsLocation: LatLng(lat, lon),
|
||||
batteryPercentage: battery.toDouble(),
|
||||
batteryMilliVolts: 3000.0 + (battery / 100.0) * 1200.0,
|
||||
temperature: temp,
|
||||
timestamp: now.subtract(Duration(minutes: _random.nextInt(10))),
|
||||
);
|
||||
|
||||
final contact = Contact(
|
||||
publicKey: publicKey,
|
||||
type: ContactType.chat,
|
||||
flags: 0,
|
||||
outPathLen: 1,
|
||||
outPath: Uint8List(32),
|
||||
advName: teamNames[i],
|
||||
lastAdvert: now.millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: (lat * 1e6).toInt(),
|
||||
advLon: (lon * 1e6).toInt(),
|
||||
lastMod: now.millisecondsSinceEpoch ~/ 1000,
|
||||
telemetry: telemetry,
|
||||
);
|
||||
|
||||
contacts.add(contact);
|
||||
}
|
||||
|
||||
// Generate channels/rooms
|
||||
for (int i = 0; i < channelCount && i < channelNames.length; i++) {
|
||||
// Generate random public key
|
||||
final publicKey = Uint8List.fromList(
|
||||
List.generate(32, (_) => _random.nextInt(256)),
|
||||
);
|
||||
|
||||
// Channel index stored in outPath[0]
|
||||
final outPath = Uint8List(32);
|
||||
outPath[0] = i; // Channel index
|
||||
|
||||
final channel = Contact(
|
||||
publicKey: publicKey,
|
||||
type: ContactType.room,
|
||||
flags: 0,
|
||||
outPathLen: 1,
|
||||
outPath: outPath,
|
||||
advName: channelNames[i],
|
||||
lastAdvert: now.millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: 0, // Channels don't have location
|
||||
advLon: 0,
|
||||
lastMod: now.millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
|
||||
contacts.add(channel);
|
||||
}
|
||||
|
||||
return contacts;
|
||||
}
|
||||
|
||||
/// Generate sample SAR markers around a center location
|
||||
static SampleMessageBatch generateSarMarkerMessages({
|
||||
required LatLng centerLocation,
|
||||
required AppLocalizations l10n,
|
||||
int foundPersonCount = 2,
|
||||
int fireCount = 1,
|
||||
int stagingCount = 1,
|
||||
int objectCount = 1,
|
||||
}) {
|
||||
final messages = <Message>[];
|
||||
final contactLocations = <String, MessageContactLocation>{};
|
||||
final now = DateTime.now();
|
||||
int messageId = 1;
|
||||
|
||||
// Generate found person markers
|
||||
for (int i = 0; i < foundPersonCount; i++) {
|
||||
final latOffset = (_random.nextDouble() - 0.5) * 0.015;
|
||||
final lonOffset = (_random.nextDouble() - 0.5) * 0.015;
|
||||
final lat = centerLocation.latitude + latOffset;
|
||||
final lon = centerLocation.longitude + lonOffset;
|
||||
|
||||
final senderKey = Uint8List.fromList(
|
||||
List.generate(32, (_) => _random.nextInt(256)),
|
||||
);
|
||||
|
||||
final timestamp = now.subtract(Duration(minutes: 10 + i * 5));
|
||||
final message = Message(
|
||||
id: 'sample_fp_$messageId',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
text: 'S:🧑:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}',
|
||||
receivedAt: timestamp,
|
||||
isSarMarker: true,
|
||||
sarGpsCoordinates: LatLng(lat, lon),
|
||||
sarCustomEmoji: '🧑',
|
||||
senderName: l10n.sampleTeamMember,
|
||||
);
|
||||
messages.add(message);
|
||||
contactLocations[message.id] = _sampleSnapshot(
|
||||
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.006),
|
||||
receivedAt: timestamp,
|
||||
source: 'telemetry',
|
||||
);
|
||||
messageId++;
|
||||
}
|
||||
|
||||
// Generate fire markers
|
||||
for (int i = 0; i < fireCount; i++) {
|
||||
final latOffset = (_random.nextDouble() - 0.5) * 0.015;
|
||||
final lonOffset = (_random.nextDouble() - 0.5) * 0.015;
|
||||
final lat = centerLocation.latitude + latOffset;
|
||||
final lon = centerLocation.longitude + lonOffset;
|
||||
|
||||
final senderKey = Uint8List.fromList(
|
||||
List.generate(32, (_) => _random.nextInt(256)),
|
||||
);
|
||||
|
||||
final timestamp = now.subtract(Duration(minutes: 20 + i * 5));
|
||||
final message = Message(
|
||||
id: 'sample_fire_$messageId',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
text: 'S:🔥:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}',
|
||||
receivedAt: timestamp,
|
||||
isSarMarker: true,
|
||||
sarGpsCoordinates: LatLng(lat, lon),
|
||||
sarCustomEmoji: '🔥',
|
||||
senderName: l10n.sampleScout,
|
||||
);
|
||||
messages.add(message);
|
||||
contactLocations[message.id] = _sampleSnapshot(
|
||||
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.008),
|
||||
receivedAt: timestamp,
|
||||
source: 'advert',
|
||||
);
|
||||
messageId++;
|
||||
}
|
||||
|
||||
// Generate staging area markers
|
||||
for (int i = 0; i < stagingCount; i++) {
|
||||
final latOffset = (_random.nextDouble() - 0.5) * 0.015;
|
||||
final lonOffset = (_random.nextDouble() - 0.5) * 0.015;
|
||||
final lat = centerLocation.latitude + latOffset;
|
||||
final lon = centerLocation.longitude + lonOffset;
|
||||
|
||||
final senderKey = Uint8List.fromList(
|
||||
List.generate(32, (_) => _random.nextInt(256)),
|
||||
);
|
||||
|
||||
final timestamp = now.subtract(Duration(minutes: 30 + i * 5));
|
||||
final message = Message(
|
||||
id: 'sample_staging_$messageId',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
text: 'S:🏕️:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}',
|
||||
receivedAt: timestamp,
|
||||
isSarMarker: true,
|
||||
sarGpsCoordinates: LatLng(lat, lon),
|
||||
sarCustomEmoji: '🏕️',
|
||||
senderName: l10n.sampleBase,
|
||||
);
|
||||
messages.add(message);
|
||||
contactLocations[message.id] = _sampleSnapshot(
|
||||
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.01),
|
||||
receivedAt: timestamp,
|
||||
source: 'advert',
|
||||
);
|
||||
messageId++;
|
||||
}
|
||||
|
||||
// Generate object markers
|
||||
for (int i = 0; i < objectCount; i++) {
|
||||
final latOffset = (_random.nextDouble() - 0.5) * 0.015;
|
||||
final lonOffset = (_random.nextDouble() - 0.5) * 0.015;
|
||||
final lat = centerLocation.latitude + latOffset;
|
||||
final lon = centerLocation.longitude + lonOffset;
|
||||
|
||||
final senderKey = Uint8List.fromList(
|
||||
List.generate(32, (_) => _random.nextInt(256)),
|
||||
);
|
||||
|
||||
final timestamp = now.subtract(Duration(minutes: 40 + i * 5));
|
||||
final notes = [
|
||||
l10n.sampleObjectBackpack,
|
||||
l10n.sampleObjectVehicle,
|
||||
l10n.sampleObjectCamping,
|
||||
l10n.sampleObjectTrailMarker,
|
||||
];
|
||||
|
||||
final message = Message(
|
||||
id: 'sample_object_$messageId',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
text:
|
||||
'S:📦:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}${notes[i % notes.length]}',
|
||||
receivedAt: timestamp,
|
||||
isSarMarker: true,
|
||||
sarGpsCoordinates: LatLng(lat, lon),
|
||||
sarCustomEmoji: '📦',
|
||||
senderName: l10n.sampleSearcher,
|
||||
);
|
||||
messages.add(message);
|
||||
contactLocations[message.id] = _sampleSnapshot(
|
||||
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.007),
|
||||
receivedAt: timestamp,
|
||||
source: 'telemetry',
|
||||
);
|
||||
messageId++;
|
||||
}
|
||||
|
||||
return SampleMessageBatch(
|
||||
messages: messages,
|
||||
contactLocations: contactLocations,
|
||||
);
|
||||
}
|
||||
|
||||
/// Generate sample map drawings
|
||||
static List<dynamic> generateDrawings({
|
||||
required LatLng centerLocation,
|
||||
required AppLocalizations l10n,
|
||||
}) {
|
||||
final drawings = <dynamic>[];
|
||||
final now = DateTime.now();
|
||||
|
||||
// Generate a line drawing (e.g., search path)
|
||||
final linePoints = <LatLng>[
|
||||
LatLng(centerLocation.latitude + 0.002, centerLocation.longitude - 0.003),
|
||||
LatLng(centerLocation.latitude + 0.004, centerLocation.longitude - 0.002),
|
||||
LatLng(centerLocation.latitude + 0.005, centerLocation.longitude + 0.001),
|
||||
LatLng(centerLocation.latitude + 0.003, centerLocation.longitude + 0.003),
|
||||
];
|
||||
|
||||
drawings.add({
|
||||
'type': 'line',
|
||||
'id': 'sample_line_${now.millisecondsSinceEpoch}',
|
||||
'color': Colors.blue.toARGB32(),
|
||||
'createdAt': now.subtract(const Duration(minutes: 15)).toIso8601String(),
|
||||
'points': linePoints
|
||||
.map((p) => {'lat': p.latitude, 'lon': p.longitude})
|
||||
.toList(),
|
||||
'sender': l10n.sampleTeamMember,
|
||||
});
|
||||
|
||||
// Generate a rectangle drawing (e.g., search area)
|
||||
drawings.add({
|
||||
'type': 'rectangle',
|
||||
'id': 'sample_rect_${now.millisecondsSinceEpoch + 1}',
|
||||
'color': Colors.red.toARGB32(),
|
||||
'createdAt': now.subtract(const Duration(minutes: 10)).toIso8601String(),
|
||||
'topLeft': {
|
||||
'lat': centerLocation.latitude - 0.003,
|
||||
'lon': centerLocation.longitude - 0.004,
|
||||
},
|
||||
'bottomRight': {
|
||||
'lat': centerLocation.latitude - 0.001,
|
||||
'lon': centerLocation.longitude - 0.001,
|
||||
},
|
||||
'sender': l10n.sampleScout,
|
||||
});
|
||||
|
||||
return drawings;
|
||||
}
|
||||
|
||||
/// Generate sample channel messages for public channels
|
||||
static SampleMessageBatch generateChannelMessages({
|
||||
LatLng? centerLocation,
|
||||
required AppLocalizations l10n,
|
||||
int generalChannelMessages = 8,
|
||||
int emergencyChannelMessages = 5,
|
||||
}) {
|
||||
// Use provided location or default to Ljubljana, Slovenia
|
||||
final center = centerLocation ?? const LatLng(46.0569, 14.5058);
|
||||
final messages = <Message>[];
|
||||
final contactLocations = <String, MessageContactLocation>{};
|
||||
final now = DateTime.now();
|
||||
int messageId = 1000; // Start with high ID to avoid conflicts
|
||||
|
||||
// Sample messages for General channel (index 0)
|
||||
final generalMessages = [
|
||||
l10n.sampleMsgAllTeamsCheckIn,
|
||||
l10n.sampleMsgWeatherUpdate,
|
||||
l10n.sampleMsgBaseCamp,
|
||||
l10n.sampleMsgTeamAlpha,
|
||||
l10n.sampleMsgRadioCheck,
|
||||
l10n.sampleMsgWaterSupply,
|
||||
l10n.sampleMsgTeamBravo,
|
||||
l10n.sampleMsgEtaRallyPoint,
|
||||
l10n.sampleMsgSupplyDrop,
|
||||
l10n.sampleMsgDroneSurvey,
|
||||
l10n.sampleMsgTeamCharlie,
|
||||
l10n.sampleMsgRadioDiscipline,
|
||||
];
|
||||
|
||||
// Sample messages for Emergency channel (index 1)
|
||||
// Mix regular messages and SAR markers
|
||||
final emergencyMessages = [
|
||||
l10n.sampleMsgUrgentMedical,
|
||||
'S:🧑:${center.latitude.toStringAsFixed(5)},${(center.longitude + 0.005).toStringAsFixed(5)}${l10n.sampleMsgAdultMale}',
|
||||
l10n.sampleMsgFireSpotted,
|
||||
'S:🔥:${(center.latitude + 0.008).toStringAsFixed(5)},${(center.longitude + 0.003).toStringAsFixed(5)}${l10n.sampleMsgSpreadingRapidly}',
|
||||
l10n.sampleMsgPriorityHelicopter,
|
||||
l10n.sampleMsgMedicalTeamEnRoute,
|
||||
l10n.sampleMsgEvacHelicopter,
|
||||
l10n.sampleMsgEmergencyResolved,
|
||||
'S:🏕️:${(center.latitude - 0.002).toStringAsFixed(5)},${(center.longitude - 0.004).toStringAsFixed(5)}${l10n.sampleMsgEmergencyStagingArea}',
|
||||
l10n.sampleMsgEmergencyServices,
|
||||
];
|
||||
|
||||
final teamNames = [
|
||||
l10n.sampleAlphaTeamLead,
|
||||
l10n.sampleBravoScout,
|
||||
l10n.sampleCharlieMedic,
|
||||
l10n.sampleDeltaNavigator,
|
||||
l10n.sampleEchoSupport,
|
||||
l10n.sampleBaseCommand,
|
||||
l10n.sampleFieldCoordinator,
|
||||
l10n.sampleMedicalTeam,
|
||||
];
|
||||
|
||||
// Generate General channel messages
|
||||
for (
|
||||
int i = 0;
|
||||
i < generalChannelMessages && i < generalMessages.length;
|
||||
i++
|
||||
) {
|
||||
final senderKey = Uint8List.fromList(
|
||||
List.generate(32, (_) => _random.nextInt(256)),
|
||||
);
|
||||
|
||||
// Messages spread over the last 2 hours
|
||||
final minutesAgo = 120 - (i * 15) - _random.nextInt(10);
|
||||
final timestamp = now.subtract(Duration(minutes: minutesAgo));
|
||||
|
||||
final message = Message(
|
||||
id: 'sample_general_$messageId',
|
||||
messageType: MessageType.channel,
|
||||
channelIdx: 0, // General channel
|
||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
text: generalMessages[i],
|
||||
receivedAt: timestamp,
|
||||
senderName: teamNames[_random.nextInt(teamNames.length)],
|
||||
);
|
||||
messages.add(message);
|
||||
contactLocations[message.id] = _sampleSnapshot(
|
||||
location: _randomNearbyLocation(center, 0.018),
|
||||
receivedAt: timestamp,
|
||||
source: i.isEven ? 'telemetry' : 'advert',
|
||||
);
|
||||
messageId++;
|
||||
}
|
||||
|
||||
// Generate Emergency channel messages
|
||||
for (
|
||||
int i = 0;
|
||||
i < emergencyChannelMessages && i < emergencyMessages.length;
|
||||
i++
|
||||
) {
|
||||
final senderKey = Uint8List.fromList(
|
||||
List.generate(32, (_) => _random.nextInt(256)),
|
||||
);
|
||||
|
||||
// Emergency messages more recent (last hour)
|
||||
final minutesAgo = 60 - (i * 10) - _random.nextInt(5);
|
||||
final timestamp = now.subtract(Duration(minutes: minutesAgo));
|
||||
|
||||
final message = Message(
|
||||
id: 'sample_emergency_$messageId',
|
||||
messageType: MessageType.channel,
|
||||
channelIdx: 1, // Emergency channel
|
||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
text: emergencyMessages[i],
|
||||
receivedAt: timestamp,
|
||||
senderName: teamNames[_random.nextInt(teamNames.length)],
|
||||
);
|
||||
messages.add(message);
|
||||
contactLocations[message.id] = _sampleSnapshot(
|
||||
location: _randomNearbyLocation(center, 0.012),
|
||||
receivedAt: timestamp,
|
||||
source: i.isEven ? 'advert' : 'telemetry',
|
||||
);
|
||||
messageId++;
|
||||
}
|
||||
|
||||
return SampleMessageBatch(
|
||||
messages: messages,
|
||||
contactLocations: contactLocations,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SampleMessageBatch {
|
||||
final List<Message> messages;
|
||||
final Map<String, MessageContactLocation> contactLocations;
|
||||
|
||||
const SampleMessageBatch({
|
||||
required this.messages,
|
||||
required this.contactLocations,
|
||||
});
|
||||
}
|
||||
24
lib/utils/sar_marker_extensions.dart
Normal file
24
lib/utils/sar_marker_extensions.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// Extension for SarMarkerType to provide localized display names
|
||||
extension SarMarkerTypeLocalization on SarMarkerType {
|
||||
/// Get localized display name for this SAR marker type
|
||||
String getLocalizedName(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
switch (this) {
|
||||
case SarMarkerType.foundPerson:
|
||||
return l10n.foundPerson;
|
||||
case SarMarkerType.fire:
|
||||
return l10n.fire;
|
||||
case SarMarkerType.stagingArea:
|
||||
return l10n.stagingArea;
|
||||
case SarMarkerType.object:
|
||||
return 'Object'; // Not commonly used, keeping English for now
|
||||
case SarMarkerType.unknown:
|
||||
return 'Unknown'; // Not commonly used, keeping English for now
|
||||
}
|
||||
}
|
||||
}
|
||||
219
lib/utils/sar_message_parser.dart
Normal file
219
lib/utils/sar_message_parser.dart
Normal file
@@ -0,0 +1,219 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/map_coordinate_space.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
import 'custom_map_id.dart';
|
||||
|
||||
class SarMessageParser {
|
||||
static const String legacyPrefix = 'S:';
|
||||
static const String customMapPrefix = 'S2:';
|
||||
|
||||
static final RegExp _sarPatternNew = RegExp(
|
||||
r'^S:([^:]+):(\d):(-?\d+\.?\d*),(-?\d+\.?\d*):?(.*)',
|
||||
multiLine: false,
|
||||
);
|
||||
|
||||
static final RegExp _sarPatternOld = RegExp(
|
||||
r'^S:([^:]+):(-?\d+\.?\d*),(-?\d+\.?\d*):?(.*)',
|
||||
multiLine: false,
|
||||
);
|
||||
|
||||
static bool isSarMessage(String text) {
|
||||
final lines = text.trim().split('\n');
|
||||
final firstLine = lines.isEmpty ? text.trim() : lines.first;
|
||||
return firstLine.startsWith(legacyPrefix) ||
|
||||
firstLine.startsWith(customMapPrefix);
|
||||
}
|
||||
|
||||
static SarMarkerInfo? parse(String text) {
|
||||
final trimmed = text.trim();
|
||||
if (trimmed.startsWith(customMapPrefix)) {
|
||||
return _parseCustomMap(trimmed);
|
||||
}
|
||||
if (!trimmed.startsWith(legacyPrefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final firstLine = trimmed.split('\n').first;
|
||||
var match = _sarPatternNew.firstMatch(firstLine);
|
||||
final isNewFormat = match != null;
|
||||
match ??= _sarPatternOld.firstMatch(firstLine);
|
||||
if (match == null) return null;
|
||||
|
||||
try {
|
||||
final emoji = match.group(1)!;
|
||||
final colorIndex = isNewFormat ? int.parse(match.group(2)!) : null;
|
||||
final latitude = double.parse(match.group(isNewFormat ? 3 : 2)!);
|
||||
final longitude = double.parse(match.group(isNewFormat ? 4 : 3)!);
|
||||
final inlineMessage = match.group(isNewFormat ? 5 : 4)?.trim();
|
||||
if (latitude < -90 || latitude > 90) return null;
|
||||
if (longitude < -180 || longitude > 180) return null;
|
||||
|
||||
final additionalNotes = extractNotes(text);
|
||||
String? notes;
|
||||
if (inlineMessage != null && inlineMessage.isNotEmpty) {
|
||||
notes = inlineMessage;
|
||||
}
|
||||
if (additionalNotes != null && additionalNotes.isNotEmpty) {
|
||||
notes = notes != null ? '$notes\n$additionalNotes' : additionalNotes;
|
||||
}
|
||||
|
||||
return SarMarkerInfo(
|
||||
type: SarMarkerType.fromEmoji(emoji),
|
||||
location: LatLng(latitude, longitude),
|
||||
emoji: emoji,
|
||||
notes: notes,
|
||||
colorIndex: colorIndex != null && colorIndex >= 0 && colorIndex <= 7
|
||||
? colorIndex
|
||||
: null,
|
||||
coordinateSpace: MapCoordinateSpace.geo,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static SarMarkerInfo? _parseCustomMap(String text) {
|
||||
try {
|
||||
final json =
|
||||
jsonDecode(text.substring(customMapPrefix.length))
|
||||
as Map<String, dynamic>;
|
||||
final emoji = json['e'];
|
||||
final mapId = normalizeCustomMapId(json['m'] as String?);
|
||||
final rawPoint = json['p'];
|
||||
if (emoji is! String || mapId == null || rawPoint is! List) {
|
||||
return null;
|
||||
}
|
||||
final point = rawPoint.cast<num>();
|
||||
if (point.length != 2) return null;
|
||||
final colorIndex = json['c'];
|
||||
final notes = json['n'];
|
||||
return SarMarkerInfo(
|
||||
type: SarMarkerType.fromEmoji(emoji),
|
||||
location: LatLng(point[0].toDouble(), point[1].toDouble()),
|
||||
emoji: emoji,
|
||||
notes: notes is String && notes.isNotEmpty ? notes : null,
|
||||
colorIndex: colorIndex is int ? colorIndex : null,
|
||||
coordinateSpace: MapCoordinateSpace.customMap,
|
||||
mapId: mapId,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Message enhanceMessage(Message message) {
|
||||
final sarInfo = parse(message.text);
|
||||
if (sarInfo == null) return message;
|
||||
|
||||
return message.copyWith(
|
||||
isSarMarker: true,
|
||||
sarGpsCoordinates: sarInfo.coordinateSpace == MapCoordinateSpace.geo
|
||||
? sarInfo.location
|
||||
: null,
|
||||
sarCustomMapPoint: sarInfo.coordinateSpace == MapCoordinateSpace.customMap
|
||||
? sarInfo.location
|
||||
: null,
|
||||
sarCustomMapId: sarInfo.mapId,
|
||||
sarNotes: sarInfo.notes,
|
||||
sarCustomEmoji: sarInfo.emoji,
|
||||
sarColorIndex: sarInfo.colorIndex,
|
||||
);
|
||||
}
|
||||
|
||||
static String createSarMessage({
|
||||
required SarMarkerType type,
|
||||
required LatLng location,
|
||||
String? notes,
|
||||
int? colorIndex,
|
||||
}) {
|
||||
final colorIdx = colorIndex ?? 0;
|
||||
final text =
|
||||
'S:${type.emoji}:$colorIdx:${location.latitude},${location.longitude}';
|
||||
if (notes != null && notes.isNotEmpty) {
|
||||
return '$text:$notes';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
static String createCustomMapSarMessage({
|
||||
required String emoji,
|
||||
required String mapId,
|
||||
required LatLng point,
|
||||
String? notes,
|
||||
int? colorIndex,
|
||||
}) {
|
||||
final payload = <String, dynamic>{
|
||||
'e': emoji,
|
||||
'c': colorIndex ?? 0,
|
||||
'm': normalizeCustomMapId(mapId),
|
||||
'p': [point.latitude.round(), point.longitude.round()],
|
||||
};
|
||||
if (notes != null && notes.isNotEmpty) {
|
||||
payload['n'] = notes;
|
||||
}
|
||||
return '$customMapPrefix${jsonEncode(payload)}';
|
||||
}
|
||||
|
||||
static String? extractNotes(String text) {
|
||||
final trimmed = text.trim();
|
||||
final lines = trimmed.split('\n');
|
||||
if (lines.length <= 1) return null;
|
||||
return lines.sublist(1).join('\n').trim();
|
||||
}
|
||||
|
||||
static bool isValidFormat(String text) {
|
||||
return isSarMessage(text) && parse(text) != null;
|
||||
}
|
||||
|
||||
static String? getFormatError(String text) {
|
||||
final trimmed = text.trim();
|
||||
if (trimmed.startsWith(customMapPrefix)) {
|
||||
return parse(text) == null
|
||||
? 'Invalid format for custom map SAR marker'
|
||||
: null;
|
||||
}
|
||||
if (!trimmed.startsWith(legacyPrefix)) {
|
||||
return 'SAR message must start with "S:"';
|
||||
}
|
||||
|
||||
final firstLine = trimmed.split('\n').first;
|
||||
if (firstLine == legacyPrefix) {
|
||||
return 'Invalid format for SAR marker';
|
||||
}
|
||||
final parts = firstLine.split(':');
|
||||
if (parts.length < 2 || parts[1].isEmpty) {
|
||||
return 'Missing emoji';
|
||||
}
|
||||
|
||||
return parse(text) == null ? 'Invalid format for SAR marker' : null;
|
||||
}
|
||||
}
|
||||
|
||||
class SarMarkerInfo {
|
||||
final SarMarkerType type;
|
||||
final LatLng location;
|
||||
final String emoji;
|
||||
final String? notes;
|
||||
final int? colorIndex;
|
||||
final MapCoordinateSpace coordinateSpace;
|
||||
final String? mapId;
|
||||
|
||||
SarMarkerInfo({
|
||||
required this.type,
|
||||
required this.location,
|
||||
required this.emoji,
|
||||
this.notes,
|
||||
this.colorIndex,
|
||||
required this.coordinateSpace,
|
||||
this.mapId,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SarMarkerInfo(type: ${type.displayName}, location: $location, colorIndex: $colorIndex, space: ${coordinateSpace.name}, mapId: $mapId)';
|
||||
}
|
||||
}
|
||||
87
lib/utils/slovenian_crs.dart
Normal file
87
lib/utils/slovenian_crs.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'dart:math' show Point;
|
||||
import 'dart:ui' show Rect;
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:proj4dart/proj4dart.dart' as proj4;
|
||||
|
||||
/// EPSG:3794 - Slovenia 1996 / Slovene National Grid
|
||||
/// Transverse Mercator projection for Slovenia
|
||||
///
|
||||
/// Official definition from https://epsg.io/3794:
|
||||
/// +proj=tmerc +lat_0=0 +lon_0=15 +k=0.9999 +x_0=500000 +y_0=-5000000
|
||||
/// +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs
|
||||
///
|
||||
/// This CRS is used by Slovenian government WMS services (prostor.zgs.gov.si)
|
||||
|
||||
/// Register and get EPSG:3794 projection
|
||||
proj4.Projection getEpsg3794Projection() {
|
||||
const epsg3794Def =
|
||||
'+proj=tmerc +lat_0=0 +lon_0=15 +k=0.9999 '
|
||||
'+x_0=500000 +y_0=-5000000 +ellps=GRS80 '
|
||||
'+towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs';
|
||||
|
||||
// Register projection if not already registered
|
||||
try {
|
||||
return proj4.Projection.get('EPSG:3794') ??
|
||||
proj4.Projection.add('EPSG:3794', epsg3794Def);
|
||||
} catch (e) {
|
||||
// If already registered, get it
|
||||
return proj4.Projection.get('EPSG:3794')!;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create Proj4Crs for EPSG:3794
|
||||
///
|
||||
/// Configuration matches the GeoWebCache tile grid used by prostor.zgs.gov.si
|
||||
///
|
||||
/// Official tile grid from WMTS GetCapabilities:
|
||||
/// - TopLeftCorner: 373217.6542445397, 246158.298050262
|
||||
/// - Bounds: X: 373217.65 to 695777.65, Y: 31118.30 to 246158.30
|
||||
/// - Tile size: 256x256 pixels
|
||||
/// - Scale denominators converted to resolutions using: resolution = scaleDenom * 0.00028
|
||||
Crs getSlovenianCrs() {
|
||||
final projection = getEpsg3794Projection();
|
||||
|
||||
// Resolutions calculated from GeoWebCache scale denominators
|
||||
// Formula: resolution (m/px) = scaleDenominator * 0.00028 (OGC standard)
|
||||
final resolutions = <double>[
|
||||
420.0, // Zoom 0 - ScaleDenom: 1500000
|
||||
280.0, // Zoom 1 - ScaleDenom: 1000000
|
||||
210.0, // Zoom 2 - ScaleDenom: 750000
|
||||
140.0, // Zoom 3 - ScaleDenom: 500000
|
||||
70.0, // Zoom 4 - ScaleDenom: 250000
|
||||
28.0, // Zoom 5 - ScaleDenom: 100000
|
||||
14.0, // Zoom 6 - ScaleDenom: 50000
|
||||
7.0, // Zoom 7 - ScaleDenom: 25000
|
||||
4.2, // Zoom 8 - ScaleDenom: 15000
|
||||
2.8, // Zoom 9 - ScaleDenom: 10000
|
||||
1.4, // Zoom 10 - ScaleDenom: 5000
|
||||
0.56, // Zoom 11 - ScaleDenom: 2000
|
||||
0.28, // Zoom 12 - ScaleDenom: 1000
|
||||
0.14, // Zoom 13 - ScaleDenom: 500
|
||||
0.07, // Zoom 14 - ScaleDenom: 250
|
||||
0.028, // Zoom 15 - ScaleDenom: 100
|
||||
];
|
||||
|
||||
// Bounds from WMS capabilities (actual data extent in Slovenia)
|
||||
final bounds = Rect.fromLTRB(
|
||||
373217.65, // min X (west)
|
||||
31118.30, // min Y (south) - top in Rect coordinates
|
||||
695777.65, // max X (east)
|
||||
246158.30, // max Y (north) - bottom in Rect coordinates
|
||||
);
|
||||
|
||||
// Origin from WMTS TileMatrixSet TopLeftCorner
|
||||
// This is the top-left corner of the tile pyramid (min X, max Y)
|
||||
final origin = Point<double>(373217.6542445397, 246158.298050262);
|
||||
|
||||
return Proj4Crs.fromFactory(
|
||||
code: 'EPSG:3794',
|
||||
proj4Projection: projection,
|
||||
resolutions: resolutions,
|
||||
bounds: bounds,
|
||||
origins: [origin],
|
||||
);
|
||||
}
|
||||
|
||||
/// Singleton instance of Slovenian CRS for reuse
|
||||
final Crs slovenianCrs = getSlovenianCrs();
|
||||
163
lib/utils/tictactoe_message_parser.dart
Normal file
163
lib/utils/tictactoe_message_parser.dart
Normal file
@@ -0,0 +1,163 @@
|
||||
enum TicTacToeEventType { start, move }
|
||||
|
||||
class TicTacToeEvent {
|
||||
final TicTacToeEventType type;
|
||||
final String gameId;
|
||||
final String playerKey6;
|
||||
final int? cell;
|
||||
final int timestampSec;
|
||||
|
||||
const TicTacToeEvent({
|
||||
required this.type,
|
||||
required this.gameId,
|
||||
required this.playerKey6,
|
||||
this.cell,
|
||||
required this.timestampSec,
|
||||
});
|
||||
}
|
||||
|
||||
class TicTacToeMessageParser {
|
||||
static const String _prefix = 'TTT1:';
|
||||
|
||||
static bool isTicTacToe(String text) => text.startsWith(_prefix);
|
||||
|
||||
static TicTacToeEvent? tryParse(String text) {
|
||||
if (!isTicTacToe(text)) return null;
|
||||
final body = text.substring(_prefix.length);
|
||||
final parts = body.split(':');
|
||||
if (parts.length < 4) return null;
|
||||
|
||||
final action = parts[0];
|
||||
final gameId = parts[1].toLowerCase();
|
||||
if (!RegExp(r'^[0-9a-f]{8}$').hasMatch(gameId)) return null;
|
||||
|
||||
if (action == 'S' && parts.length == 4) {
|
||||
final starterKey6 = parts[2].toLowerCase();
|
||||
final ts = int.tryParse(parts[3]);
|
||||
if (!RegExp(r'^[0-9a-f]{12}$').hasMatch(starterKey6)) return null;
|
||||
if (ts == null || ts <= 0) return null;
|
||||
return TicTacToeEvent(
|
||||
type: TicTacToeEventType.start,
|
||||
gameId: gameId,
|
||||
playerKey6: starterKey6,
|
||||
timestampSec: ts,
|
||||
);
|
||||
}
|
||||
|
||||
if (action == 'M' && parts.length == 5) {
|
||||
final cell = int.tryParse(parts[2]);
|
||||
final playerKey6 = parts[3].toLowerCase();
|
||||
final ts = int.tryParse(parts[4]);
|
||||
if (cell == null || cell < 0 || cell > 8) return null;
|
||||
if (!RegExp(r'^[0-9a-f]{12}$').hasMatch(playerKey6)) return null;
|
||||
if (ts == null || ts <= 0) return null;
|
||||
return TicTacToeEvent(
|
||||
type: TicTacToeEventType.move,
|
||||
gameId: gameId,
|
||||
playerKey6: playerKey6,
|
||||
cell: cell,
|
||||
timestampSec: ts,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static String encodeStart({
|
||||
required String gameId,
|
||||
required String starterKey6,
|
||||
required int timestampSec,
|
||||
}) {
|
||||
return '$_prefix'
|
||||
'S:${gameId.toLowerCase()}:${starterKey6.toLowerCase()}:$timestampSec';
|
||||
}
|
||||
|
||||
static String encodeMove({
|
||||
required String gameId,
|
||||
required int cell,
|
||||
required String playerKey6,
|
||||
required int timestampSec,
|
||||
}) {
|
||||
return '$_prefix'
|
||||
'M:${gameId.toLowerCase()}:$cell:${playerKey6.toLowerCase()}:$timestampSec';
|
||||
}
|
||||
}
|
||||
|
||||
class TicTacToeGameState {
|
||||
final String gameId;
|
||||
final String xPlayerKey6;
|
||||
final String oPlayerKey6;
|
||||
final List<String?> board; // 'X' / 'O' / null
|
||||
final String nextSymbol;
|
||||
final String? winnerSymbol;
|
||||
|
||||
const TicTacToeGameState({
|
||||
required this.gameId,
|
||||
required this.xPlayerKey6,
|
||||
required this.oPlayerKey6,
|
||||
required this.board,
|
||||
required this.nextSymbol,
|
||||
this.winnerSymbol,
|
||||
});
|
||||
|
||||
bool get isDraw =>
|
||||
winnerSymbol == null && board.every((cell) => cell != null);
|
||||
bool get isFinished => winnerSymbol != null || isDraw;
|
||||
}
|
||||
|
||||
TicTacToeGameState buildTicTacToeState({
|
||||
required String gameId,
|
||||
required String xPlayerKey6,
|
||||
required String oPlayerKey6,
|
||||
required List<TicTacToeEvent> events,
|
||||
}) {
|
||||
final board = List<String?>.filled(9, null);
|
||||
var next = 'X';
|
||||
String? winner;
|
||||
|
||||
final sorted = [...events]
|
||||
..sort((a, b) => a.timestampSec.compareTo(b.timestampSec));
|
||||
|
||||
for (final event in sorted) {
|
||||
if (event.type != TicTacToeEventType.move) continue;
|
||||
if (winner != null) break;
|
||||
|
||||
final expectedKey = next == 'X' ? xPlayerKey6 : oPlayerKey6;
|
||||
final cell = event.cell;
|
||||
if (cell == null) continue;
|
||||
if (event.playerKey6 != expectedKey) continue;
|
||||
if (board[cell] != null) continue;
|
||||
|
||||
board[cell] = next;
|
||||
winner = _computeWinner(board);
|
||||
next = next == 'X' ? 'O' : 'X';
|
||||
}
|
||||
|
||||
return TicTacToeGameState(
|
||||
gameId: gameId,
|
||||
xPlayerKey6: xPlayerKey6,
|
||||
oPlayerKey6: oPlayerKey6,
|
||||
board: board,
|
||||
nextSymbol: next,
|
||||
winnerSymbol: winner,
|
||||
);
|
||||
}
|
||||
|
||||
String? _computeWinner(List<String?> board) {
|
||||
const lines = <List<int>>[
|
||||
[0, 1, 2],
|
||||
[3, 4, 5],
|
||||
[6, 7, 8],
|
||||
[0, 3, 6],
|
||||
[1, 4, 7],
|
||||
[2, 5, 8],
|
||||
[0, 4, 8],
|
||||
[2, 4, 6],
|
||||
];
|
||||
for (final line in lines) {
|
||||
final a = board[line[0]];
|
||||
if (a == null) continue;
|
||||
if (a == board[line[1]] && a == board[line[2]]) return a;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
25
lib/utils/time_ago_extensions.dart
Normal file
25
lib/utils/time_ago_extensions.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// Extension to provide localized "time ago" formatting
|
||||
extension TimeAgoExtension on Duration {
|
||||
/// Get localized time ago string
|
||||
String toLocalizedTimeAgo(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
if (inMinutes < 1) return l10n.justNow;
|
||||
if (inMinutes < 60) return l10n.minutesAgo(inMinutes);
|
||||
if (inHours < 24) return l10n.hoursAgo(inHours);
|
||||
return l10n.daysAgo(inDays);
|
||||
}
|
||||
|
||||
/// Get localized time ago string with seconds support
|
||||
String toLocalizedTimeAgoWithSeconds(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
if (inSeconds < 60) return l10n.secondsAgo(inSeconds);
|
||||
if (inMinutes < 60) return l10n.minutesAgo(inMinutes);
|
||||
if (inHours < 24) return l10n.hoursAgo(inHours);
|
||||
return l10n.daysAgo(inDays);
|
||||
}
|
||||
}
|
||||
84
lib/utils/toast_logger.dart
Normal file
84
lib/utils/toast_logger.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/messages_provider.dart';
|
||||
|
||||
/// Global messenger key wired to [MaterialApp.scaffoldMessengerKey] in main.dart
|
||||
/// so toasts can be shown even when the calling context has no Scaffold.
|
||||
final GlobalKey<ScaffoldMessengerState> rootScaffoldMessengerKey =
|
||||
GlobalKey<ScaffoldMessengerState>();
|
||||
|
||||
/// Toast logger utility - shows a floating SnackBar and records a system
|
||||
/// message in the channels tab history
|
||||
class ToastLogger {
|
||||
/// Log an info message
|
||||
static void info(BuildContext context, String message) {
|
||||
_log(context, message, 'info');
|
||||
}
|
||||
|
||||
/// Log a success message
|
||||
static void success(BuildContext context, String message) {
|
||||
_log(context, message, 'success');
|
||||
}
|
||||
|
||||
/// Log a warning message
|
||||
static void warning(BuildContext context, String message) {
|
||||
_log(context, message, 'warning');
|
||||
}
|
||||
|
||||
/// Log an error message
|
||||
static void error(BuildContext context, String message) {
|
||||
_log(context, message, 'error');
|
||||
}
|
||||
|
||||
/// Internal method to show a snackbar and log a system message
|
||||
static void _log(BuildContext context, String message, String level) {
|
||||
_showSnackBar(context, message, level);
|
||||
|
||||
// Keep recording as a system message (feeds history/stats)
|
||||
try {
|
||||
final messagesProvider = context.read<MessagesProvider>();
|
||||
messagesProvider.logSystemMessage(text: message, level: level);
|
||||
} catch (e) {
|
||||
// Provider not available - snackbar above is still shown
|
||||
debugPrint('[$level] $message');
|
||||
}
|
||||
}
|
||||
|
||||
static void _showSnackBar(BuildContext context, String message, String level) {
|
||||
final messenger =
|
||||
rootScaffoldMessengerKey.currentState ??
|
||||
ScaffoldMessenger.maybeOf(context);
|
||||
if (messenger == null) {
|
||||
debugPrint('[$level] $message');
|
||||
return;
|
||||
}
|
||||
|
||||
Color? backgroundColor;
|
||||
switch (level) {
|
||||
case 'error':
|
||||
backgroundColor = context.mounted
|
||||
? Theme.of(context).colorScheme.error
|
||||
: Colors.red;
|
||||
break;
|
||||
case 'warning':
|
||||
backgroundColor = Colors.orange;
|
||||
break;
|
||||
case 'success':
|
||||
backgroundColor = Colors.green;
|
||||
break;
|
||||
default:
|
||||
backgroundColor = null; // default surface color
|
||||
}
|
||||
|
||||
// Don't queue rapid toasts behind each other
|
||||
messenger.hideCurrentSnackBar();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: backgroundColor,
|
||||
duration: Duration(seconds: level == 'error' ? 4 : 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
170
lib/utils/transmission_target_resolver.dart
Normal file
170
lib/utils/transmission_target_resolver.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../models/contact.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
|
||||
enum TransmissionTargetFailure {
|
||||
unknownContact,
|
||||
unknownRoute,
|
||||
tooFar,
|
||||
unreachable,
|
||||
}
|
||||
|
||||
class TransmissionTargetResolution {
|
||||
final Contact? target;
|
||||
final TransmissionTargetFailure? failure;
|
||||
final int maxHops;
|
||||
|
||||
const TransmissionTargetResolution({
|
||||
required this.target,
|
||||
required this.failure,
|
||||
required this.maxHops,
|
||||
});
|
||||
|
||||
int get hops => target?.routeHopCount ?? -1;
|
||||
bool get isValid => target != null && failure == null;
|
||||
}
|
||||
|
||||
class TransmissionTargetResolver {
|
||||
const TransmissionTargetResolver._();
|
||||
|
||||
static Contact? resolveLocalTarget({
|
||||
required ContactsProvider contactsProvider,
|
||||
required bool isSentByMe,
|
||||
Uint8List? recipientPublicKey,
|
||||
Uint8List? senderPublicKeyPrefix,
|
||||
String? senderKey6FromEnvelope,
|
||||
String? senderName,
|
||||
}) {
|
||||
if (isSentByMe) {
|
||||
final recipient = _findByRecipientKey(
|
||||
contactsProvider,
|
||||
recipientPublicKey,
|
||||
);
|
||||
if (recipient != null) return recipient;
|
||||
}
|
||||
|
||||
final byEnvelope = _findByEnvelopeKey6(
|
||||
contactsProvider,
|
||||
senderKey6FromEnvelope,
|
||||
);
|
||||
if (byEnvelope != null) return byEnvelope;
|
||||
|
||||
final byPrefix = _findByPrefix(contactsProvider, senderPublicKeyPrefix);
|
||||
if (byPrefix != null) return byPrefix;
|
||||
|
||||
return _findByName(contactsProvider, senderName);
|
||||
}
|
||||
|
||||
static Future<TransmissionTargetResolution> resolveFetchTarget({
|
||||
required ContactsProvider contactsProvider,
|
||||
required Future<void> Function() refreshContacts,
|
||||
required bool isSentByMe,
|
||||
Uint8List? recipientPublicKey,
|
||||
Uint8List? senderPublicKeyPrefix,
|
||||
String? senderKey6FromEnvelope,
|
||||
String? senderName,
|
||||
required int maxFetchHops,
|
||||
}) async {
|
||||
var target = resolveLocalTarget(
|
||||
contactsProvider: contactsProvider,
|
||||
isSentByMe: isSentByMe,
|
||||
recipientPublicKey: recipientPublicKey,
|
||||
senderPublicKeyPrefix: senderPublicKeyPrefix,
|
||||
senderKey6FromEnvelope: senderKey6FromEnvelope,
|
||||
senderName: senderName,
|
||||
);
|
||||
|
||||
if (target == null ||
|
||||
!target.routeHasPath ||
|
||||
target.routeHopCount > maxFetchHops) {
|
||||
await refreshContacts();
|
||||
target = resolveLocalTarget(
|
||||
contactsProvider: contactsProvider,
|
||||
isSentByMe: isSentByMe,
|
||||
recipientPublicKey: recipientPublicKey,
|
||||
senderPublicKeyPrefix: senderPublicKeyPrefix,
|
||||
senderKey6FromEnvelope: senderKey6FromEnvelope,
|
||||
senderName: senderName,
|
||||
);
|
||||
}
|
||||
|
||||
if (target == null) {
|
||||
return TransmissionTargetResolution(
|
||||
target: null,
|
||||
failure: TransmissionTargetFailure.unknownContact,
|
||||
maxHops: maxFetchHops,
|
||||
);
|
||||
}
|
||||
if (!target.routeHasPath) {
|
||||
return TransmissionTargetResolution(
|
||||
target: target,
|
||||
failure: TransmissionTargetFailure.unknownRoute,
|
||||
maxHops: maxFetchHops,
|
||||
);
|
||||
}
|
||||
if (target.routeHopCount > maxFetchHops) {
|
||||
return TransmissionTargetResolution(
|
||||
target: target,
|
||||
failure: TransmissionTargetFailure.tooFar,
|
||||
maxHops: maxFetchHops,
|
||||
);
|
||||
}
|
||||
return TransmissionTargetResolution(
|
||||
target: target,
|
||||
failure: null,
|
||||
maxHops: maxFetchHops,
|
||||
);
|
||||
}
|
||||
|
||||
static Contact? _findByRecipientKey(
|
||||
ContactsProvider contactsProvider,
|
||||
Uint8List? recipientKey,
|
||||
) {
|
||||
if (recipientKey == null || recipientKey.isEmpty) return null;
|
||||
final byKey = contactsProvider.findContactByKey(recipientKey);
|
||||
if (byKey != null) return byKey;
|
||||
if (recipientKey.length >= 6) {
|
||||
return contactsProvider.findContactByPrefix(
|
||||
Uint8List.fromList(recipientKey.sublist(0, 6)),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Contact? _findByEnvelopeKey6(
|
||||
ContactsProvider contactsProvider,
|
||||
String? senderKey6FromEnvelope,
|
||||
) {
|
||||
if (senderKey6FromEnvelope == null || senderKey6FromEnvelope.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return contactsProvider.findContactByPrefixHex(senderKey6FromEnvelope);
|
||||
}
|
||||
|
||||
static Contact? _findByPrefix(
|
||||
ContactsProvider contactsProvider,
|
||||
Uint8List? senderPublicKeyPrefix,
|
||||
) {
|
||||
if (senderPublicKeyPrefix == null || senderPublicKeyPrefix.length < 6) {
|
||||
return null;
|
||||
}
|
||||
return contactsProvider.findContactByPrefix(
|
||||
Uint8List.fromList(senderPublicKeyPrefix.sublist(0, 6)),
|
||||
);
|
||||
}
|
||||
|
||||
static Contact? _findByName(
|
||||
ContactsProvider contactsProvider,
|
||||
String? senderName,
|
||||
) {
|
||||
final normalized = senderName?.trim();
|
||||
if (normalized == null || normalized.isEmpty) return null;
|
||||
for (final contact in contactsProvider.contacts) {
|
||||
if (contact.advName.trim().toLowerCase() == normalized.toLowerCase()) {
|
||||
return contact;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
725
lib/utils/voice_message_parser.dart
Normal file
725
lib/utils/voice_message_parser.dart
Normal file
@@ -0,0 +1,725 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload
|
||||
const int _voicePacketHeaderBytes = 6; // voice packet binary header in payload
|
||||
const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10)
|
||||
const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5)
|
||||
const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz)
|
||||
const int _defaultLoRaPreambleSymbols = 8;
|
||||
const int _defaultLoRaCrcEnabled = 1;
|
||||
const int _defaultLoRaExplicitHeader = 1;
|
||||
const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle
|
||||
|
||||
/// Identifies which voice codec/mode was used for a packet.
|
||||
/// Matches the modeId byte in the text/binary packet header.
|
||||
enum VoicePacketMode {
|
||||
mode700c(0, '700C', 8000, 100, 1600),
|
||||
mode1200(1, '1200', 8000, 150, 1040),
|
||||
mode2400(2, '2400', 8000, 300, 520),
|
||||
mode1300(3, '1300', 8000, 175, 880),
|
||||
mode1400(4, '1400', 8000, 175, 880),
|
||||
mode1600(5, '1600', 8000, 200, 800),
|
||||
mode3200(6, '3200', 8000, 400, 400);
|
||||
|
||||
const VoicePacketMode(
|
||||
this.id,
|
||||
this.label,
|
||||
this.sampleRateHz,
|
||||
this.bytesPerSecond,
|
||||
this.packetDurationMs,
|
||||
);
|
||||
final int id;
|
||||
final String label;
|
||||
final int sampleRateHz;
|
||||
final int bytesPerSecond;
|
||||
final int packetDurationMs;
|
||||
|
||||
int get samplesPerPacket => sampleRateHz * packetDurationMs ~/ 1000;
|
||||
|
||||
static VoicePacketMode fromId(int id) => VoicePacketMode.values.firstWhere(
|
||||
(m) => m.id == id,
|
||||
orElse: () => VoicePacketMode.mode1300,
|
||||
);
|
||||
}
|
||||
|
||||
/// A single Codec2-encoded chunk belonging to a multi-packet voice session.
|
||||
///
|
||||
/// Text format (channels):
|
||||
/// V:{sessionId8hex}:{modeId}:{index}/{total}:{base64Codec2}
|
||||
///
|
||||
/// Binary format (direct contacts, received via pushRawData):
|
||||
/// [0x56 'V'][sessionId:4B][index:1B][codec2Data...]
|
||||
class VoicePacket {
|
||||
final String sessionId; // 8 hex chars (4 bytes)
|
||||
final VoicePacketMode mode;
|
||||
final int index; // 0-based
|
||||
final int total; // total packet count
|
||||
final Uint8List codec2Data;
|
||||
|
||||
const VoicePacket({
|
||||
required this.sessionId,
|
||||
required this.mode,
|
||||
required this.index,
|
||||
required this.total,
|
||||
required this.codec2Data,
|
||||
});
|
||||
|
||||
// ── Text (channel) format ────────────────────────────────────────────────
|
||||
|
||||
static const String _textPrefix = 'V:';
|
||||
|
||||
static bool isVoiceText(String text) => text.startsWith(_textPrefix);
|
||||
|
||||
/// Parse a text-format voice packet. Returns null on failure.
|
||||
static VoicePacket? tryParseText(String text) {
|
||||
if (!text.startsWith(_textPrefix)) return null;
|
||||
try {
|
||||
final body = text.substring(_textPrefix.length);
|
||||
final parts = body.split(':');
|
||||
// parts: [sessionId, modeId, 'idx/total', base64data]
|
||||
if (parts.length != 4) return null;
|
||||
|
||||
final sessionId = parts[0];
|
||||
if (sessionId.length != 8) return null;
|
||||
|
||||
final modeId = int.tryParse(parts[1]);
|
||||
if (modeId == null) return null;
|
||||
|
||||
final indexTotal = parts[2].split('/');
|
||||
if (indexTotal.length != 2) return null;
|
||||
final index = int.tryParse(indexTotal[0]);
|
||||
final total = int.tryParse(indexTotal[1]);
|
||||
if (index == null || total == null || total < 1) return null;
|
||||
|
||||
final codec2Data = base64.decode(parts[3]);
|
||||
|
||||
return VoicePacket(
|
||||
sessionId: sessionId,
|
||||
mode: VoicePacketMode.fromId(modeId),
|
||||
index: index,
|
||||
total: total,
|
||||
codec2Data: codec2Data,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode as text (channel format). Max ~198 chars for 700C/1200/2400.
|
||||
String encodeText() {
|
||||
final b64 = base64.encode(codec2Data);
|
||||
return 'V:$sessionId:${mode.id}:$index/$total:$b64';
|
||||
}
|
||||
|
||||
// ── Binary format ────────────────────────────────────────────────────────
|
||||
|
||||
static const int _binaryMagic = 0x56; // 'V'
|
||||
static const int _binaryHeaderLen = 6; // magic(1)+session(4)+idx(1)
|
||||
|
||||
static bool isVoiceBinary(Uint8List payload) =>
|
||||
payload.isNotEmpty && payload[0] == _binaryMagic;
|
||||
|
||||
/// Parse binary-format voice packet (from pushRawData payload).
|
||||
static VoicePacket? tryParseBinary(Uint8List payload) {
|
||||
if (payload.length < _binaryHeaderLen) return null;
|
||||
if (payload[0] != _binaryMagic) return null;
|
||||
try {
|
||||
final sessionBytes = payload.sublist(1, 5);
|
||||
final sessionId = sessionBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
final index = payload[5];
|
||||
final codec2Data = payload.sublist(_binaryHeaderLen);
|
||||
return VoicePacket(
|
||||
sessionId: sessionId,
|
||||
mode: VoicePacketMode.mode1300,
|
||||
index: index,
|
||||
total: 0,
|
||||
codec2Data: codec2Data,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode as binary payload (for cmdSendRawData).
|
||||
Uint8List encodeBinary() {
|
||||
final sessionBytes = Uint8List(4);
|
||||
for (var i = 0; i < 4; i++) {
|
||||
sessionBytes[i] = int.parse(
|
||||
sessionId.substring(i * 2, i * 2 + 2),
|
||||
radix: 16,
|
||||
);
|
||||
}
|
||||
final out = Uint8List(_binaryHeaderLen + codec2Data.length);
|
||||
out[0] = _binaryMagic;
|
||||
out.setRange(1, 5, sessionBytes);
|
||||
out[5] = index;
|
||||
out.setRange(_binaryHeaderLen, out.length, codec2Data);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Duration helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/// Estimated audio duration of this packet in milliseconds.
|
||||
int get durationMs {
|
||||
try {
|
||||
final bytesPerSecond = voiceModeBytesPerSecond(mode);
|
||||
if (bytesPerSecond <= 0) return 0;
|
||||
return (codec2Data.length * 1000 ~/ bytesPerSecond).clamp(0, 1500);
|
||||
} catch (_) {
|
||||
// Be permissive with stale or malformed persisted voice metadata.
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final suffix = total > 0
|
||||
? ' ${mode.label} [$index/${total - 1}]'
|
||||
: ' [$index]';
|
||||
return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)';
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight public/direct message envelope advertising voice availability.
|
||||
///
|
||||
/// Text format:
|
||||
/// VE3:{sid}:{mode}:{total}:{durS}
|
||||
/// Example:
|
||||
/// VE3:00112233:1:4:4
|
||||
class VoiceEnvelope {
|
||||
static const String _prefix = 'VE3:';
|
||||
|
||||
final String sessionId;
|
||||
final VoicePacketMode mode;
|
||||
final int total;
|
||||
final int durationMs;
|
||||
final int version;
|
||||
|
||||
const VoiceEnvelope({
|
||||
required this.sessionId,
|
||||
required this.mode,
|
||||
required this.total,
|
||||
required this.durationMs,
|
||||
this.version = 3,
|
||||
});
|
||||
|
||||
static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix);
|
||||
|
||||
static VoiceEnvelope? tryParseText(String text) {
|
||||
if (!isVoiceEnvelopeText(text)) return null;
|
||||
final body = text.substring(_prefix.length);
|
||||
return _tryParse(body);
|
||||
}
|
||||
|
||||
static VoiceEnvelope? _tryParse(String body) {
|
||||
final parts = body.split(':');
|
||||
if (parts.length != 4) return null;
|
||||
try {
|
||||
final sid = _decodeSessionId(parts[0]);
|
||||
final mode = _parseInt(parts[1], base36: true);
|
||||
final total = _parseInt(parts[2], base36: true);
|
||||
final durS = _parseInt(parts[3], base36: true);
|
||||
|
||||
if (sid == null) {
|
||||
return null;
|
||||
}
|
||||
if (mode == null || mode < 0 || mode >= VoicePacketMode.values.length) {
|
||||
return null;
|
||||
}
|
||||
if (total == null || total < 1 || total > 255) return null;
|
||||
if (durS == null || durS < 0 || durS > 10 * 60) return null;
|
||||
|
||||
return VoiceEnvelope(
|
||||
sessionId: sid,
|
||||
mode: VoicePacketMode.fromId(mode),
|
||||
total: total,
|
||||
durationMs: durS * 1000,
|
||||
version: 3,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String encodeText() {
|
||||
final durationSec = (durationMs / 1000).ceil().clamp(0, 10 * 60);
|
||||
return '$_prefix${_encodeSessionId(sessionId)}:${_toBase36(mode.id)}:${_toBase36(total)}:${_toBase36(durationSec)}';
|
||||
}
|
||||
}
|
||||
|
||||
int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) {
|
||||
VoicePacketMode.mode700c => 100,
|
||||
VoicePacketMode.mode1200 => 150,
|
||||
VoicePacketMode.mode2400 => 300,
|
||||
VoicePacketMode.mode1300 => 175,
|
||||
VoicePacketMode.mode1400 => 175,
|
||||
VoicePacketMode.mode1600 => 200,
|
||||
VoicePacketMode.mode3200 => 400,
|
||||
};
|
||||
|
||||
/// Approximate end-to-end transmit time for a voice session over MeshCore LoRa.
|
||||
///
|
||||
/// Uses envelope-level metadata (mode + duration + packet count) when only the
|
||||
/// envelope is available and packet bytes are not yet received locally.
|
||||
Duration estimateVoiceTransmitDuration({
|
||||
required VoicePacketMode mode,
|
||||
required int packetCount,
|
||||
required int durationMs,
|
||||
int pathLen = 0,
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
}) {
|
||||
if (packetCount <= 0 || durationMs <= 0) return Duration.zero;
|
||||
|
||||
final bytesPerSecond = voiceModeBytesPerSecond(mode);
|
||||
final totalCodecBytes = (durationMs * bytesPerSecond / 1000.0).round();
|
||||
final safePathLen = pathLen.clamp(0, 64);
|
||||
final hops = safePathLen + 1;
|
||||
final baseBytesPerPacket = totalCodecBytes ~/ packetCount;
|
||||
final extraBytes = totalCodecBytes % packetCount;
|
||||
var totalMs = 0.0;
|
||||
|
||||
for (var i = 0; i < packetCount; i++) {
|
||||
final codecBytes = baseBytesPerPacket + (i < extraBytes ? 1 : 0);
|
||||
final loraLen =
|
||||
_meshPacketHeaderBytes +
|
||||
safePathLen +
|
||||
_voicePacketHeaderBytes +
|
||||
codecBytes;
|
||||
final airtimeMs = _estimateLoRaAirtimeMs(
|
||||
loraLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
totalMs += airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops;
|
||||
}
|
||||
|
||||
return Duration(milliseconds: totalMs.round());
|
||||
}
|
||||
|
||||
/// Approximate transmit time using actually received voice packet sizes.
|
||||
Duration estimateVoiceTransmitDurationFromPackets({
|
||||
required Iterable<VoicePacket?> packets,
|
||||
int pathLen = 0,
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
}) {
|
||||
final safePathLen = pathLen.clamp(0, 64);
|
||||
final hops = safePathLen + 1;
|
||||
var totalMs = 0.0;
|
||||
|
||||
for (final packet in packets) {
|
||||
if (packet == null) continue;
|
||||
final loraLen =
|
||||
_meshPacketHeaderBytes +
|
||||
safePathLen +
|
||||
_voicePacketHeaderBytes +
|
||||
packet.codec2Data.length;
|
||||
final airtimeMs = _estimateLoRaAirtimeMs(
|
||||
loraLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
totalMs += airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops;
|
||||
}
|
||||
|
||||
return Duration(milliseconds: totalMs.round());
|
||||
}
|
||||
|
||||
double _estimateLoRaAirtimeMs(
|
||||
int payloadLenBytes, {
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
}) {
|
||||
final sf = _normalizeSf(radioSf);
|
||||
final bw = _resolveBandwidthHz(radioBw).toDouble();
|
||||
final cr = (_normalizeCr(radioCr) - 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;
|
||||
}
|
||||
|
||||
int _normalizeSf(int? value) {
|
||||
if (value == null) return _defaultLoRaSf;
|
||||
if (value >= 5 && value <= 12) return value;
|
||||
return _defaultLoRaSf;
|
||||
}
|
||||
|
||||
int _normalizeCr(int? value) {
|
||||
if (value == null) return _defaultLoRaCr;
|
||||
if (value >= 5 && value <= 8) return value;
|
||||
return _defaultLoRaCr;
|
||||
}
|
||||
|
||||
int _resolveBandwidthHz(int? rawBw) {
|
||||
if (rawBw == null) return _defaultLoRaBwHz;
|
||||
if (rawBw > 1000) return rawBw;
|
||||
switch (rawBw) {
|
||||
case 0:
|
||||
return 7800;
|
||||
case 1:
|
||||
return 10400;
|
||||
case 2:
|
||||
return 15600;
|
||||
case 3:
|
||||
return 20800;
|
||||
case 4:
|
||||
return 31250;
|
||||
case 5:
|
||||
return 41700;
|
||||
case 6:
|
||||
return 62500;
|
||||
case 7:
|
||||
return 125000;
|
||||
case 8:
|
||||
return 250000;
|
||||
case 9:
|
||||
return 500000;
|
||||
default:
|
||||
return _defaultLoRaBwHz;
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct control-plane request to fetch voice packets for a session.
|
||||
///
|
||||
/// Text format:
|
||||
/// VR3:{sid}:{want}:{requesterKey6}
|
||||
/// Example:
|
||||
/// VR3:00112233:a:aabbccddeeff
|
||||
class VoiceFetchRequest {
|
||||
static const String _prefix = 'VR3:';
|
||||
static const int _binaryMagic = 0x72; // 'r'
|
||||
|
||||
final String sessionId;
|
||||
final String want;
|
||||
final List<int> missingIndices;
|
||||
final String requesterKey6;
|
||||
final int version;
|
||||
|
||||
const VoiceFetchRequest({
|
||||
required this.sessionId,
|
||||
this.want = 'all',
|
||||
this.missingIndices = const [],
|
||||
required this.requesterKey6,
|
||||
this.version = 3,
|
||||
});
|
||||
|
||||
static bool isVoiceFetchRequestText(String text) => text.startsWith(_prefix);
|
||||
static bool isVoiceFetchRequestBinary(Uint8List payload) =>
|
||||
payload.isNotEmpty && payload[0] == _binaryMagic;
|
||||
|
||||
static VoiceFetchRequest? tryParseText(String text) {
|
||||
if (!isVoiceFetchRequestText(text)) return null;
|
||||
final body = text.substring(_prefix.length);
|
||||
return _tryParse(body);
|
||||
}
|
||||
|
||||
static VoiceFetchRequest? tryParseBinary(Uint8List payload) {
|
||||
if (!isVoiceFetchRequestBinary(payload)) return null;
|
||||
if (payload.length < 13) return null; // magic+sid+flags+key6+count
|
||||
try {
|
||||
final sidBytes = payload.sublist(1, 5);
|
||||
final sid = sidBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
final flags = payload[5];
|
||||
final requesterKey6 = payload
|
||||
.sublist(6, 12)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
final missingCount = payload[12];
|
||||
if (payload.length != 13 + missingCount) return null;
|
||||
final wantMissing = (flags & 0x01) == 0x01;
|
||||
final missing = <int>[];
|
||||
for (var i = 0; i < missingCount; i++) {
|
||||
missing.add(payload[13 + i]);
|
||||
}
|
||||
return VoiceFetchRequest(
|
||||
sessionId: sid,
|
||||
want: wantMissing ? 'missing' : 'all',
|
||||
missingIndices: missing,
|
||||
requesterKey6: requesterKey6,
|
||||
version: 3,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static VoiceFetchRequest? _tryParse(String body) {
|
||||
final parts = body.split(':');
|
||||
if (parts.length != 3) return null;
|
||||
try {
|
||||
final sid = _decodeSessionId(parts[0]);
|
||||
final wantToken = parts[1];
|
||||
final requesterKey6 = parts[2];
|
||||
final normalizedWant = wantToken == 'a'
|
||||
? 'all'
|
||||
: ((wantToken.startsWith('m')) ? 'missing' : wantToken);
|
||||
|
||||
if (sid == null) {
|
||||
return null;
|
||||
}
|
||||
final missingIndices = <int>[];
|
||||
if (normalizedWant == 'missing') {
|
||||
final encoded = wantToken.substring(1);
|
||||
if (encoded.isEmpty) return null;
|
||||
missingIndices.addAll(_decodeMissingIndicesCompact(encoded));
|
||||
if (missingIndices.isEmpty) return null;
|
||||
} else if (normalizedWant != 'all') {
|
||||
return null;
|
||||
}
|
||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return VoiceFetchRequest(
|
||||
sessionId: sid,
|
||||
want: normalizedWant,
|
||||
missingIndices: missingIndices,
|
||||
requesterKey6: requesterKey6.toLowerCase(),
|
||||
version: 3,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String encodeText() {
|
||||
final wantToken = want == 'missing' && missingIndices.isNotEmpty
|
||||
? 'm${_encodeMissingIndicesCompact(missingIndices)}'
|
||||
: (want == 'all' ? 'a' : want);
|
||||
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}';
|
||||
}
|
||||
|
||||
Uint8List encodeBinary() {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
|
||||
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
|
||||
}
|
||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
|
||||
throw ArgumentError.value(
|
||||
requesterKey6,
|
||||
'requesterKey6',
|
||||
'Expected 12 hex chars',
|
||||
);
|
||||
}
|
||||
final useMissing = want == 'missing' && missingIndices.isNotEmpty;
|
||||
final missing = useMissing
|
||||
? missingIndices.where((v) => v >= 0 && v <= 254).toList()
|
||||
: <int>[];
|
||||
|
||||
final out = Uint8List(13 + missing.length);
|
||||
out[0] = _binaryMagic;
|
||||
for (var i = 0; i < 4; i++) {
|
||||
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
out[5] = useMissing ? 0x01 : 0x00;
|
||||
for (var i = 0; i < 6; i++) {
|
||||
out[6 + i] = int.parse(
|
||||
requesterKey6.substring(i * 2, i * 2 + 2),
|
||||
radix: 16,
|
||||
);
|
||||
}
|
||||
out[12] = missing.length;
|
||||
for (var i = 0; i < missing.length; i++) {
|
||||
out[13 + i] = missing[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-fragment ACK for raw voice payload packets.
|
||||
///
|
||||
/// Binary format:
|
||||
/// [0x76 'v'][sessionId:4B][index:1B]
|
||||
class VoiceFragmentAck {
|
||||
static const int _binaryMagic = 0x76; // 'v'
|
||||
|
||||
final String sessionId; // 8 hex chars
|
||||
final int index; // 0..254
|
||||
|
||||
const VoiceFragmentAck({required this.sessionId, required this.index});
|
||||
|
||||
static bool isVoiceFragmentAckBinary(Uint8List payload) =>
|
||||
payload.length == 6 && payload[0] == _binaryMagic;
|
||||
|
||||
static VoiceFragmentAck? tryParseBinary(Uint8List payload) {
|
||||
if (!isVoiceFragmentAckBinary(payload)) return null;
|
||||
try {
|
||||
final sid = payload
|
||||
.sublist(1, 5)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
final idx = payload[5];
|
||||
return VoiceFragmentAck(sessionId: sid, index: idx);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List encodeBinary() {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
|
||||
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
|
||||
}
|
||||
if (index < 0 || index > 254) {
|
||||
throw ArgumentError.value(index, 'index', 'Expected 0..254');
|
||||
}
|
||||
final out = Uint8List(6);
|
||||
out[0] = _binaryMagic;
|
||||
for (var i = 0; i < 4; i++) {
|
||||
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
out[5] = index;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
int? _parseInt(String token, {required bool base36}) =>
|
||||
int.tryParse(token, radix: base36 ? 36 : 10);
|
||||
|
||||
String _toBase36(int value) => value.toRadixString(36);
|
||||
|
||||
String _encodeSessionId(String sessionIdHex) {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
|
||||
throw ArgumentError.value(
|
||||
sessionIdHex,
|
||||
'sessionIdHex',
|
||||
'Expected 8 hex chars',
|
||||
);
|
||||
}
|
||||
final value = int.parse(sessionIdHex, radix: 16);
|
||||
return value.toRadixString(36);
|
||||
}
|
||||
|
||||
String? _decodeSessionId(String token) {
|
||||
if (!RegExp(r'^[0-9a-z]{1,7}$').hasMatch(token)) return null;
|
||||
final value = int.tryParse(token, radix: 36);
|
||||
if (value == null || value < 0 || value > 0xFFFFFFFF) return null;
|
||||
return value.toRadixString(16).padLeft(8, '0');
|
||||
}
|
||||
|
||||
String _encodeMissingIndicesCompact(List<int> indices) {
|
||||
final sorted = indices.where((v) => v >= 0 && v <= 254).toSet().toList()
|
||||
..sort();
|
||||
if (sorted.isEmpty) return '';
|
||||
final chunks = <String>[];
|
||||
var start = sorted.first;
|
||||
var prev = sorted.first;
|
||||
for (var i = 1; i < sorted.length; i++) {
|
||||
final curr = sorted[i];
|
||||
if (curr == prev + 1) {
|
||||
prev = curr;
|
||||
continue;
|
||||
}
|
||||
chunks.add(
|
||||
start == prev
|
||||
? _toBase36(start)
|
||||
: '${_toBase36(start)}-${_toBase36(prev)}',
|
||||
);
|
||||
start = curr;
|
||||
prev = curr;
|
||||
}
|
||||
chunks.add(
|
||||
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
|
||||
);
|
||||
return chunks.join('.');
|
||||
}
|
||||
|
||||
List<int> _decodeMissingIndicesCompact(String encoded) {
|
||||
final out = <int>[];
|
||||
for (final token in encoded.split('.')) {
|
||||
if (token.isEmpty) continue;
|
||||
if (!token.contains('-')) {
|
||||
final value = int.tryParse(token, radix: 36);
|
||||
if (value == null || value < 0 || value > 254) return const [];
|
||||
out.add(value);
|
||||
continue;
|
||||
}
|
||||
final parts = token.split('-');
|
||||
if (parts.length != 2) return const [];
|
||||
final start = int.tryParse(parts[0], radix: 36);
|
||||
final end = int.tryParse(parts[1], radix: 36);
|
||||
if (start == null || end == null || start < 0 || end > 254 || start > end) {
|
||||
return const [];
|
||||
}
|
||||
for (var i = start; i <= end; i++) {
|
||||
out.add(i);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Builds a compact visual waveform from real voice packet bytes.
|
||||
///
|
||||
/// Note: This uses the encoded Codec2 packet bytes as the source so it works
|
||||
/// even before full PCM decode/playback is available.
|
||||
class VoiceWaveform {
|
||||
static List<double> buildBarsFromPackets(
|
||||
Iterable<VoicePacket?> packets, {
|
||||
int bars = 24,
|
||||
}) {
|
||||
if (bars <= 0) return const [];
|
||||
|
||||
final merged = <int>[];
|
||||
for (final pkt in packets) {
|
||||
if (pkt == null || pkt.codec2Data.isEmpty) continue;
|
||||
merged.addAll(pkt.codec2Data);
|
||||
}
|
||||
if (merged.isEmpty) return List<double>.filled(bars, 0.0);
|
||||
|
||||
final out = List<double>.filled(bars, 0.0);
|
||||
for (var i = 0; i < bars; i++) {
|
||||
final start = (i * merged.length) ~/ bars;
|
||||
var end = ((i + 1) * merged.length) ~/ bars;
|
||||
if (end <= start) end = start + 1;
|
||||
if (end > merged.length) end = merged.length;
|
||||
|
||||
var sum = 0.0;
|
||||
for (var j = start; j < end; j++) {
|
||||
final centered = (merged[j] - 128).abs();
|
||||
sum += centered / 127.0;
|
||||
}
|
||||
out[i] = (sum / (end - start)).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
// Light smoothing to avoid jittery adjacent bars.
|
||||
if (bars > 2) {
|
||||
final smoothed = List<double>.from(out);
|
||||
for (var i = 1; i < bars - 1; i++) {
|
||||
smoothed[i] = ((out[i - 1] + out[i] + out[i + 1]) / 3.0).clamp(
|
||||
0.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
return smoothed;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user