mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 00:40:28 +00:00
Compare commits
11 Commits
v2026.0313
...
feat/updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e502bc23b7 | ||
|
|
7be1460ef6 | ||
|
|
30c273c656 | ||
|
|
5712feba53 | ||
|
|
6de12225fc | ||
|
|
6e9e9e397d | ||
|
|
a3147e9d7c | ||
|
|
5e1912d9f2 | ||
|
|
b42328bd82 | ||
|
|
1b801a10ef | ||
|
|
06632ee32d |
13
CHANGELOG.md
Normal file
13
CHANGELOG.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-03-08 to 2026-03-14
|
||||||
|
|
||||||
|
- Discovery and contacts: finished the discovery flow, removed artificial UI delays, added profile storage and sync, and allowed contact name overrides.
|
||||||
|
- Messaging and channels: fixed channel sync, deduplication, overwrite and deletion issues, cleared message input after send, and added delayed-message warning tracking.
|
||||||
|
- Voice and transport: improved voice playback quality, improved scan and advert parsing, and kept route path data visible in logs.
|
||||||
|
- Maps and field operations: enabled iOS background GPS updates, added GPX trail import/export, introduced a fullscreen repeater map, and added live mesh traffic views.
|
||||||
|
- Localization and UI polish: added multilingual localization coverage, updated locale strings, fixed onboarding layout issues, and refined channel and contacts presentation.
|
||||||
|
|
||||||
|
## Key PRs
|
||||||
|
|
||||||
|
- [#16](https://github.com/dz0ny/meshcore-sar/pull/16) Merge pull request #16 from `MGJ520/main`
|
||||||
@@ -5,24 +5,22 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.001473">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000235">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.569758">
|
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.385323">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="95.271262">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="93.530304">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="91.795184">
|
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="210.562484">
|
||||||
|
|
||||||
<failure message="/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/actions/actions_helper.rb:67:in 'Fastlane::Actions.execute_action' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:255:in 'block in Fastlane::Runner#execute_action' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:229:in 'Dir.chdir' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:229:in 'Fastlane::Runner#execute_action' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:157:in 'Fastlane::Runner#trigger_action_by_name' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/fast_file.rb:159:in 'Fastlane::FastFile#method_missing' Fastfile:23:in 'block (2 levels) in Fastlane::FastFile#parsing_binding' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/lane.rb:41:in 'Fastlane::Lane#call' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:49:in 'block in Fastlane::Runner#execute' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:45:in 'Dir.chdir' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:45:in 'Fastlane::Runner#execute' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/lane_manager.rb:46:in 'Fastlane::LaneManager.cruise_lane' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/command_line_handler.rb:34:in 'Fastlane::CommandLineHandler.handle' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:110:in 'block (2 levels) in Fastlane::CommandsGenerator#run' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in 'Commander::Command#call' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in 'Commander::Command#run' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in 'Commander::Runner#run_active_command' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in 'Commander::Runner#run!' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in 'Commander::Delegates#run!' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:363:in 'Fastlane::CommandsGenerator#run' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:43:in 'Fastlane::CommandsGenerator.start' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/cli_tools_distributor.rb:132:in 'Fastlane::CLIToolsDistributor.take_off' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/bin/fastlane:23:in '<top (required)>' /opt/homebrew/Cellar/ruby/4.0.1/lib/ruby/4.0.0/rubygems.rb:303:in 'Kernel#load' /opt/homebrew/Cellar/ruby/4.0.1/lib/ruby/4.0.0/rubygems.rb:303:in 'Gem.activate_and_load_bin_path' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/bin/fastlane:25:in '<main>' Invalid username and password combination. Used 'hey@dz0n.dev' as the username." />
|
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import 'services/locale_preferences.dart';
|
|||||||
import 'services/mesh_map_nodes_service.dart';
|
import 'services/mesh_map_nodes_service.dart';
|
||||||
import 'services/update_checker_service.dart';
|
import 'services/update_checker_service.dart';
|
||||||
import 'services/wizard_preferences.dart';
|
import 'services/wizard_preferences.dart';
|
||||||
|
import 'screens/discovery_screen.dart';
|
||||||
import 'screens/home_screen.dart';
|
import 'screens/home_screen.dart';
|
||||||
import 'screens/welcome_wizard_screen.dart';
|
import 'screens/welcome_wizard_screen.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
@@ -42,11 +43,13 @@ class MeshCoreSarApp extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||||
|
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
|
||||||
AppThemeMode _themeMode = AppThemeMode.system;
|
AppThemeMode _themeMode = AppThemeMode.system;
|
||||||
Locale? _locale;
|
Locale? _locale;
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
bool _shouldShowPermissionDialog = false;
|
bool _shouldShowPermissionDialog = false;
|
||||||
bool _wizardCompleted = true; // Will be updated in _initializeApp()
|
bool _wizardCompleted = true; // Will be updated in _initializeApp()
|
||||||
|
String? _pendingNotificationPayload;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -66,6 +69,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
|
|
||||||
// Set up notification tap handler for update notifications
|
// Set up notification tap handler for update notifications
|
||||||
NotificationService().onNotificationTapped = _handleNotificationTap;
|
NotificationService().onNotificationTapped = _handleNotificationTap;
|
||||||
|
_pendingNotificationPayload = NotificationService().consumeLaunchPayload();
|
||||||
|
|
||||||
// Check if we need to request location permissions
|
// Check if we need to request location permissions
|
||||||
await _checkLocationPermissions();
|
await _checkLocationPermissions();
|
||||||
@@ -81,6 +85,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
_wizardCompleted = wizardCompleted;
|
_wizardCompleted = wizardCompleted;
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (_pendingNotificationPayload != null) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final payload = _pendingNotificationPayload;
|
||||||
|
_pendingNotificationPayload = null;
|
||||||
|
_handleNotificationTap(payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle notification tap
|
/// Handle notification tap
|
||||||
@@ -89,10 +101,27 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
|
|
||||||
debugPrint('[Main] Notification tapped: $payload');
|
debugPrint('[Main] Notification tapped: $payload');
|
||||||
|
|
||||||
|
if (!_wizardCompleted) {
|
||||||
|
_pendingNotificationPayload = payload;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle update notification tap
|
// Handle update notification tap
|
||||||
if (payload.startsWith('update:')) {
|
if (payload.startsWith('update:')) {
|
||||||
final downloadUrl = payload.substring(7); // Remove 'update:' prefix
|
final downloadUrl = payload.substring(7); // Remove 'update:' prefix
|
||||||
_launchUpdateDownload(downloadUrl);
|
_launchUpdateDownload(downloadUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.startsWith('discovery:')) {
|
||||||
|
final navigator = _navigatorKey.currentState;
|
||||||
|
if (navigator == null) {
|
||||||
|
_pendingNotificationPayload = payload;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute(builder: (context) => const DiscoveryScreen()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// SAR and message notifications handled by their respective providers
|
// SAR and message notifications handled by their respective providers
|
||||||
}
|
}
|
||||||
@@ -304,6 +333,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
builder: (context) {
|
builder: (context) {
|
||||||
final systemBrightness = MediaQuery.platformBrightnessOf(context);
|
final systemBrightness = MediaQuery.platformBrightnessOf(context);
|
||||||
final materialApp = MaterialApp(
|
final materialApp = MaterialApp(
|
||||||
|
navigatorKey: _navigatorKey,
|
||||||
key: ValueKey<String?>(
|
key: ValueKey<String?>(
|
||||||
'${_locale?.languageCode ?? 'system'}_${_themeMode.name}',
|
'${_locale?.languageCode ?? 'system'}_${_themeMode.name}',
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ class DeviceInfo {
|
|||||||
final int? advLat;
|
final int? advLat;
|
||||||
final int? advLon;
|
final int? advLon;
|
||||||
final bool? manualAddContacts;
|
final bool? manualAddContacts;
|
||||||
|
final bool? autoAddUsers;
|
||||||
|
final bool? autoAddRepeaters;
|
||||||
|
final bool? autoAddRoomServers;
|
||||||
|
final bool? autoAddSensors;
|
||||||
|
final bool? autoAddOverwriteOldest;
|
||||||
final int? radioFreq;
|
final int? radioFreq;
|
||||||
final int? radioBw;
|
final int? radioBw;
|
||||||
final int? radioSf;
|
final int? radioSf;
|
||||||
@@ -113,6 +118,11 @@ class DeviceInfo {
|
|||||||
this.advLat,
|
this.advLat,
|
||||||
this.advLon,
|
this.advLon,
|
||||||
this.manualAddContacts,
|
this.manualAddContacts,
|
||||||
|
this.autoAddUsers,
|
||||||
|
this.autoAddRepeaters,
|
||||||
|
this.autoAddRoomServers,
|
||||||
|
this.autoAddSensors,
|
||||||
|
this.autoAddOverwriteOldest,
|
||||||
this.radioFreq,
|
this.radioFreq,
|
||||||
this.radioBw,
|
this.radioBw,
|
||||||
this.radioSf,
|
this.radioSf,
|
||||||
@@ -239,6 +249,11 @@ class DeviceInfo {
|
|||||||
int? advLat,
|
int? advLat,
|
||||||
int? advLon,
|
int? advLon,
|
||||||
bool? manualAddContacts,
|
bool? manualAddContacts,
|
||||||
|
bool? autoAddUsers,
|
||||||
|
bool? autoAddRepeaters,
|
||||||
|
bool? autoAddRoomServers,
|
||||||
|
bool? autoAddSensors,
|
||||||
|
bool? autoAddOverwriteOldest,
|
||||||
int? radioFreq,
|
int? radioFreq,
|
||||||
int? radioBw,
|
int? radioBw,
|
||||||
int? radioSf,
|
int? radioSf,
|
||||||
@@ -278,6 +293,12 @@ class DeviceInfo {
|
|||||||
advLat: advLat ?? this.advLat,
|
advLat: advLat ?? this.advLat,
|
||||||
advLon: advLon ?? this.advLon,
|
advLon: advLon ?? this.advLon,
|
||||||
manualAddContacts: manualAddContacts ?? this.manualAddContacts,
|
manualAddContacts: manualAddContacts ?? this.manualAddContacts,
|
||||||
|
autoAddUsers: autoAddUsers ?? this.autoAddUsers,
|
||||||
|
autoAddRepeaters: autoAddRepeaters ?? this.autoAddRepeaters,
|
||||||
|
autoAddRoomServers: autoAddRoomServers ?? this.autoAddRoomServers,
|
||||||
|
autoAddSensors: autoAddSensors ?? this.autoAddSensors,
|
||||||
|
autoAddOverwriteOldest:
|
||||||
|
autoAddOverwriteOldest ?? this.autoAddOverwriteOldest,
|
||||||
radioFreq: radioFreq ?? this.radioFreq,
|
radioFreq: radioFreq ?? this.radioFreq,
|
||||||
radioBw: radioBw ?? this.radioBw,
|
radioBw: radioBw ?? this.radioBw,
|
||||||
radioSf: radioSf ?? this.radioSf,
|
radioSf: radioSf ?? this.radioSf,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class MessageReceptionDetails {
|
|||||||
final int? senderToReceiptMs;
|
final int? senderToReceiptMs;
|
||||||
final int? estimatedTransmitMs;
|
final int? estimatedTransmitMs;
|
||||||
final int? postTransmitDelayMs;
|
final int? postTransmitDelayMs;
|
||||||
|
final int receivedCopies;
|
||||||
|
|
||||||
const MessageReceptionDetails({
|
const MessageReceptionDetails({
|
||||||
required this.capturedAt,
|
required this.capturedAt,
|
||||||
@@ -40,6 +41,7 @@ class MessageReceptionDetails {
|
|||||||
this.senderToReceiptMs,
|
this.senderToReceiptMs,
|
||||||
this.estimatedTransmitMs,
|
this.estimatedTransmitMs,
|
||||||
this.postTransmitDelayMs,
|
this.postTransmitDelayMs,
|
||||||
|
this.receivedCopies = 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
|
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
|
||||||
@@ -56,9 +58,60 @@ class MessageReceptionDetails {
|
|||||||
'senderToReceiptMs': senderToReceiptMs,
|
'senderToReceiptMs': senderToReceiptMs,
|
||||||
'estimatedTransmitMs': estimatedTransmitMs,
|
'estimatedTransmitMs': estimatedTransmitMs,
|
||||||
'postTransmitDelayMs': postTransmitDelayMs,
|
'postTransmitDelayMs': postTransmitDelayMs,
|
||||||
|
'receivedCopies': receivedCopies,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MessageReceptionDetails copyWith({
|
||||||
|
DateTime? capturedAt,
|
||||||
|
DateTime? packetLoggedAt,
|
||||||
|
int? rssiDbm,
|
||||||
|
double? snrDb,
|
||||||
|
List<int>? pathBytes,
|
||||||
|
int? senderToReceiptMs,
|
||||||
|
int? estimatedTransmitMs,
|
||||||
|
int? postTransmitDelayMs,
|
||||||
|
int? receivedCopies,
|
||||||
|
}) {
|
||||||
|
return MessageReceptionDetails(
|
||||||
|
capturedAt: capturedAt ?? this.capturedAt,
|
||||||
|
packetLoggedAt: packetLoggedAt ?? this.packetLoggedAt,
|
||||||
|
rssiDbm: rssiDbm ?? this.rssiDbm,
|
||||||
|
snrDb: snrDb ?? this.snrDb,
|
||||||
|
pathBytes: pathBytes ?? this.pathBytes,
|
||||||
|
senderToReceiptMs: senderToReceiptMs ?? this.senderToReceiptMs,
|
||||||
|
estimatedTransmitMs: estimatedTransmitMs ?? this.estimatedTransmitMs,
|
||||||
|
postTransmitDelayMs: postTransmitDelayMs ?? this.postTransmitDelayMs,
|
||||||
|
receivedCopies: receivedCopies ?? this.receivedCopies,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static MessageReceptionDetails mergeDuplicate({
|
||||||
|
MessageReceptionDetails? existing,
|
||||||
|
MessageReceptionDetails? incoming,
|
||||||
|
}) {
|
||||||
|
final base =
|
||||||
|
existing ??
|
||||||
|
incoming ??
|
||||||
|
MessageReceptionDetails(capturedAt: DateTime.now());
|
||||||
|
|
||||||
|
return base.copyWith(
|
||||||
|
capturedAt: incoming?.capturedAt ?? existing?.capturedAt,
|
||||||
|
packetLoggedAt: incoming?.packetLoggedAt ?? existing?.packetLoggedAt,
|
||||||
|
rssiDbm: incoming?.rssiDbm ?? existing?.rssiDbm,
|
||||||
|
snrDb: incoming?.snrDb ?? existing?.snrDb,
|
||||||
|
pathBytes: incoming?.pathBytes ?? existing?.pathBytes,
|
||||||
|
senderToReceiptMs:
|
||||||
|
incoming?.senderToReceiptMs ?? existing?.senderToReceiptMs,
|
||||||
|
estimatedTransmitMs:
|
||||||
|
incoming?.estimatedTransmitMs ?? existing?.estimatedTransmitMs,
|
||||||
|
postTransmitDelayMs:
|
||||||
|
incoming?.postTransmitDelayMs ?? existing?.postTransmitDelayMs,
|
||||||
|
receivedCopies:
|
||||||
|
(existing?.receivedCopies ?? 1) + (incoming?.receivedCopies ?? 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static MessageReceptionDetails? fromJson(Map<String, dynamic> json) {
|
static MessageReceptionDetails? fromJson(Map<String, dynamic> json) {
|
||||||
final capturedAtMillis = json['capturedAtMillis'];
|
final capturedAtMillis = json['capturedAtMillis'];
|
||||||
if (capturedAtMillis is! int) {
|
if (capturedAtMillis is! int) {
|
||||||
@@ -81,6 +134,7 @@ class MessageReceptionDetails {
|
|||||||
senderToReceiptMs: json['senderToReceiptMs'] as int?,
|
senderToReceiptMs: json['senderToReceiptMs'] as int?,
|
||||||
estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
|
estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
|
||||||
postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
|
postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
|
||||||
|
receivedCopies: json['receivedCopies'] as int? ?? 1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:meshcore_client/meshcore_client.dart' show BufferReader;
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'connection_provider.dart';
|
import 'connection_provider.dart';
|
||||||
import 'contacts_provider.dart';
|
import 'contacts_provider.dart';
|
||||||
@@ -17,6 +19,7 @@ import '../services/nearest_router_selector.dart';
|
|||||||
import '../services/packet_capture_storage_service.dart';
|
import '../services/packet_capture_storage_service.dart';
|
||||||
import '../services/path_history_service.dart';
|
import '../services/path_history_service.dart';
|
||||||
import '../services/route_hash_preferences.dart';
|
import '../services/route_hash_preferences.dart';
|
||||||
|
import '../services/notification_service.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
import '../models/ble_packet_log.dart';
|
import '../models/ble_packet_log.dart';
|
||||||
@@ -56,9 +59,65 @@ class _DirectMessageRouteSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ParsedRawAdvert {
|
||||||
|
final Uint8List publicKey;
|
||||||
|
final String? advName;
|
||||||
|
final int typeValue;
|
||||||
|
final int flags;
|
||||||
|
final int lastAdvert;
|
||||||
|
final int? advLat;
|
||||||
|
final int? advLon;
|
||||||
|
final int? signedEncodedPathLen;
|
||||||
|
final Uint8List? paddedPathBytes;
|
||||||
|
|
||||||
|
const _ParsedRawAdvert({
|
||||||
|
required this.publicKey,
|
||||||
|
required this.advName,
|
||||||
|
required this.typeValue,
|
||||||
|
required this.flags,
|
||||||
|
required this.lastAdvert,
|
||||||
|
required this.advLat,
|
||||||
|
required this.advLon,
|
||||||
|
required this.signedEncodedPathLen,
|
||||||
|
required this.paddedPathBytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ParsedRepeaterStatus {
|
||||||
|
final int batteryMv;
|
||||||
|
final int queueLen;
|
||||||
|
final int lastRssi;
|
||||||
|
final int lastSnrRaw;
|
||||||
|
final int uptimeSecs;
|
||||||
|
|
||||||
|
const _ParsedRepeaterStatus({
|
||||||
|
required this.batteryMv,
|
||||||
|
required this.queueLen,
|
||||||
|
required this.lastRssi,
|
||||||
|
required this.lastSnrRaw,
|
||||||
|
required this.uptimeSecs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PendingRepeaterOwnerRequest {
|
||||||
|
final Uint8List publicKey;
|
||||||
|
|
||||||
|
const _PendingRepeaterOwnerRequest({required this.publicKey});
|
||||||
|
}
|
||||||
|
|
||||||
/// Main App Provider - coordinates all other providers
|
/// Main App Provider - coordinates all other providers
|
||||||
class AppProvider with ChangeNotifier {
|
class AppProvider with ChangeNotifier {
|
||||||
static const int _maxDirectPayloadHops = 3;
|
static const int _maxDirectPayloadHops = 3;
|
||||||
|
static const int _rawPayloadTypeAdvert = 0x04;
|
||||||
|
static const int _routeTransportFlood = 0x00;
|
||||||
|
static const int _routeTransportDirect = 0x03;
|
||||||
|
static const int _anonReqTypeOwner = 0x02;
|
||||||
|
static const double _lowBatteryThresholdPercent = 30.0;
|
||||||
|
static const double _lowBatteryResetThresholdPercent = 35.0;
|
||||||
|
static const Duration _lowBatteryCheckInterval = Duration(minutes: 5);
|
||||||
|
static const Duration _repeaterOwnerInfoRequestCooldown = Duration(
|
||||||
|
minutes: 10,
|
||||||
|
);
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
static bool isDeletedChannelInfo(
|
static bool isDeletedChannelInfo(
|
||||||
int channelIdx,
|
int channelIdx,
|
||||||
@@ -88,6 +147,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
LocationTrackingService();
|
LocationTrackingService();
|
||||||
final PacketCaptureStorageService packetCaptureStorageService =
|
final PacketCaptureStorageService packetCaptureStorageService =
|
||||||
PacketCaptureStorageService();
|
PacketCaptureStorageService();
|
||||||
|
final NotificationService _notificationService = NotificationService();
|
||||||
|
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
bool get isInitialized => _isInitialized;
|
bool get isInitialized => _isInitialized;
|
||||||
@@ -117,8 +177,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled;
|
bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled;
|
||||||
double _messageFontScale = 1.0;
|
double _messageFontScale = 1.0;
|
||||||
double get messageFontScale => _messageFontScale;
|
double get messageFontScale => _messageFontScale;
|
||||||
bool _autoAddDiscoveredContacts = false;
|
|
||||||
bool get autoAddDiscoveredContacts => _autoAddDiscoveredContacts;
|
|
||||||
bool _autoRouteRotationEnabled =
|
bool _autoRouteRotationEnabled =
|
||||||
MessagingRoutePreferences.defaultAutoRouteRotationEnabled;
|
MessagingRoutePreferences.defaultAutoRouteRotationEnabled;
|
||||||
bool get autoRouteRotationEnabled => _autoRouteRotationEnabled;
|
bool get autoRouteRotationEnabled => _autoRouteRotationEnabled;
|
||||||
@@ -148,13 +206,19 @@ class AppProvider with ChangeNotifier {
|
|||||||
final Map<String, Future<bool>> _pendingMediaSwarmFetches = {};
|
final Map<String, Future<bool>> _pendingMediaSwarmFetches = {};
|
||||||
final Map<String, Map<String, MediaSwarmAvailability>>
|
final Map<String, Map<String, MediaSwarmAvailability>>
|
||||||
_pendingMediaSwarmResponses = {};
|
_pendingMediaSwarmResponses = {};
|
||||||
|
final Map<String, DateTime> _recentRepeaterStatusRequests = {};
|
||||||
|
final Map<String, DateTime> _recentRepeaterOwnerInfoRequests = {};
|
||||||
|
final Map<int, _PendingRepeaterOwnerRequest> _pendingRepeaterOwnerRequests =
|
||||||
|
{};
|
||||||
bool _fastLocationScreenActive = false;
|
bool _fastLocationScreenActive = false;
|
||||||
Timer? _packetCaptureFlushTimer;
|
Timer? _packetCaptureFlushTimer;
|
||||||
|
Timer? _lowBatteryCheckTimer;
|
||||||
String? _lastPersistedPacketSignature;
|
String? _lastPersistedPacketSignature;
|
||||||
bool _isPersistingPacketCapture = false;
|
bool _isPersistingPacketCapture = false;
|
||||||
bool _wasDeviceConnected = false;
|
bool _wasDeviceConnected = false;
|
||||||
bool _hasCompletedConnectionBootstrap = false;
|
bool _hasCompletedConnectionBootstrap = false;
|
||||||
bool _isReconnectSyncInProgress = false;
|
bool _isReconnectSyncInProgress = false;
|
||||||
|
final Set<String> _lowBatteryNotifiedNodeIds = <String>{};
|
||||||
|
|
||||||
AppProvider({
|
AppProvider({
|
||||||
required this.connectionProvider,
|
required this.connectionProvider,
|
||||||
@@ -181,10 +245,10 @@ class AppProvider with ChangeNotifier {
|
|||||||
_loadVoiceEchoCancellationEnabled();
|
_loadVoiceEchoCancellationEnabled();
|
||||||
_loadVoiceNoiseSuppressionEnabled();
|
_loadVoiceNoiseSuppressionEnabled();
|
||||||
_loadMessageFontScale();
|
_loadMessageFontScale();
|
||||||
_loadAutoAddDiscoveredContacts();
|
|
||||||
_loadMessagingRouteSettings();
|
_loadMessagingRouteSettings();
|
||||||
unawaited(_pathHistoryService.initialize());
|
unawaited(_pathHistoryService.initialize());
|
||||||
_startPacketCapturePersistence();
|
_startPacketCapturePersistence();
|
||||||
|
_startLowBatteryWatcher();
|
||||||
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load
|
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
}
|
}
|
||||||
@@ -197,6 +261,86 @@ class AppProvider with ChangeNotifier {
|
|||||||
unawaited(_flushPacketCaptureLogs());
|
unawaited(_flushPacketCaptureLogs());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _startLowBatteryWatcher() {
|
||||||
|
_lowBatteryCheckTimer?.cancel();
|
||||||
|
_lowBatteryCheckTimer = Timer.periodic(_lowBatteryCheckInterval, (_) {
|
||||||
|
unawaited(_checkLowBatteryAlerts());
|
||||||
|
});
|
||||||
|
unawaited(_checkLowBatteryAlerts());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkLowBatteryAlerts() async {
|
||||||
|
final recoveredIds = <String>{};
|
||||||
|
|
||||||
|
final deviceBattery = connectionProvider.deviceInfo.batteryPercent;
|
||||||
|
if (deviceBattery != null &&
|
||||||
|
deviceBattery > _lowBatteryResetThresholdPercent) {
|
||||||
|
recoveredIds.add('device');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final contact in contactsProvider.contacts) {
|
||||||
|
if (contact.isChannel) continue;
|
||||||
|
final battery = contact.displayBattery;
|
||||||
|
if (battery == null) continue;
|
||||||
|
if (battery > _lowBatteryResetThresholdPercent) {
|
||||||
|
recoveredIds.add(contact.publicKeyHex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recoveredIds.isNotEmpty) {
|
||||||
|
_lowBatteryNotifiedNodeIds.removeAll(recoveredIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectionProvider.deviceInfo.isConnected && deviceBattery != null) {
|
||||||
|
await _notifyLowBatteryIfNeeded(
|
||||||
|
nodeId: 'device',
|
||||||
|
nodeName:
|
||||||
|
connectionProvider.deviceInfo.selfName?.trim().isNotEmpty == true
|
||||||
|
? connectionProvider.deviceInfo.selfName!.trim()
|
||||||
|
: (connectionProvider.deviceInfo.displayName ?? 'Connected device'),
|
||||||
|
batteryPercent: deviceBattery,
|
||||||
|
isCurrentDevice: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final contact in contactsProvider.contacts) {
|
||||||
|
if (contact.isChannel) continue;
|
||||||
|
final battery = contact.displayBattery;
|
||||||
|
if (battery == null) continue;
|
||||||
|
|
||||||
|
await _notifyLowBatteryIfNeeded(
|
||||||
|
nodeId: contact.publicKeyHex,
|
||||||
|
nodeName: contact.displayName,
|
||||||
|
batteryPercent: battery,
|
||||||
|
isCurrentDevice: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _notifyLowBatteryIfNeeded({
|
||||||
|
required String nodeId,
|
||||||
|
required String nodeName,
|
||||||
|
required double batteryPercent,
|
||||||
|
required bool isCurrentDevice,
|
||||||
|
}) async {
|
||||||
|
if (batteryPercent >= _lowBatteryThresholdPercent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_lowBatteryNotifiedNodeIds.contains(nodeId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final shown = await _notificationService.showLowBatteryNotification(
|
||||||
|
nodeId: nodeId,
|
||||||
|
nodeName: nodeName,
|
||||||
|
batteryPercent: batteryPercent,
|
||||||
|
isCurrentDevice: isCurrentDevice,
|
||||||
|
);
|
||||||
|
if (shown) {
|
||||||
|
_lowBatteryNotifiedNodeIds.add(nodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String _packetLogSignature(BlePacketLog log) {
|
String _packetLogSignature(BlePacketLog log) {
|
||||||
final prefix = log.rawData.length <= 12
|
final prefix = log.rawData.length <= 12
|
||||||
? log.rawData
|
? log.rawData
|
||||||
@@ -249,17 +393,15 @@ class AppProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Sync drawings from messages on app startup (before BLE connection)
|
/// Sync drawings from messages on app startup (before BLE connection)
|
||||||
Future<void> _syncDrawingsOnStartup() async {
|
Future<void> _syncDrawingsOnStartup() async {
|
||||||
// Wait for MessagesProvider to finish initializing
|
// Wait for both MessagesProvider and DrawingProvider to finish initializing
|
||||||
// DrawingProvider loads around the same time
|
|
||||||
int attempts = 0;
|
int attempts = 0;
|
||||||
while (!messagesProvider.isInitialized && attempts < 20) {
|
while ((!messagesProvider.isInitialized ||
|
||||||
|
!drawingProvider.isInitialized) &&
|
||||||
|
attempts < 40) {
|
||||||
await Future.delayed(const Duration(milliseconds: 50));
|
await Future.delayed(const Duration(milliseconds: 50));
|
||||||
attempts++;
|
attempts++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give DrawingProvider a moment to finish loading too
|
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
|
||||||
|
|
||||||
_restoreSessionMetadataFromMessages();
|
_restoreSessionMetadataFromMessages();
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -652,30 +794,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load auto-add discovered contacts setting from shared preferences.
|
|
||||||
Future<void> _loadAutoAddDiscoveredContacts() async {
|
|
||||||
try {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
_autoAddDiscoveredContacts =
|
|
||||||
prefs.getBool('auto_add_discovered_contacts') ?? false;
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error loading auto-add discovered contacts setting: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Toggle auto-add discovered contacts on/off.
|
|
||||||
Future<void> toggleAutoAddDiscoveredContacts(bool enabled) async {
|
|
||||||
try {
|
|
||||||
_autoAddDiscoveredContacts = enabled;
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setBool('auto_add_discovered_contacts', enabled);
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error saving auto-add discovered contacts setting: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadMessagingRouteSettings() async {
|
Future<void> _loadMessagingRouteSettings() async {
|
||||||
try {
|
try {
|
||||||
_autoRouteRotationEnabled =
|
_autoRouteRotationEnabled =
|
||||||
@@ -805,11 +923,47 @@ class AppProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
// When a contact is received from BLE
|
// When a contact is received from BLE
|
||||||
connectionProvider.onContactReceived = (contact) {
|
connectionProvider.onContactReceivedDetailed = (contact, source) {
|
||||||
|
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
|
||||||
|
final existingContact = contactsProvider.findContactByKey(
|
||||||
|
contact.publicKey,
|
||||||
|
);
|
||||||
|
final isAdvertSource =
|
||||||
|
source == ContactReceiveSource.advert ||
|
||||||
|
source == ContactReceiveSource.preview;
|
||||||
|
|
||||||
|
if (isAdvertSource) {
|
||||||
|
final isNewPendingAdvert = contactsProvider
|
||||||
|
.addOrUpdatePendingAdvertContact(
|
||||||
|
contact,
|
||||||
|
devicePublicKey: devicePublicKey,
|
||||||
|
);
|
||||||
|
if (isNewPendingAdvert) {
|
||||||
|
unawaited(
|
||||||
|
_notificationService.showContactDiscoveredNotification(
|
||||||
|
contactKey: contact.publicKey
|
||||||
|
.take(6)
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join(),
|
||||||
|
contactName: contact.advName.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: contact.advName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (contact.type == ContactType.repeater &&
|
||||||
|
contact.advName.trim().isEmpty) {
|
||||||
|
unawaited(_maybeRequestRepeaterOwnerInfo(contact.publicKey));
|
||||||
|
}
|
||||||
|
if (existingContact == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pass device public key to filter out our own contact
|
// Pass device public key to filter out our own contact
|
||||||
contactsProvider.addOrUpdateContact(
|
contactsProvider.addOrUpdateContact(
|
||||||
contact,
|
contact,
|
||||||
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
devicePublicKey: devicePublicKey,
|
||||||
);
|
);
|
||||||
unawaited(_pathHistoryService.recordLearnedPath(contact));
|
unawaited(_pathHistoryService.recordLearnedPath(contact));
|
||||||
|
|
||||||
@@ -975,9 +1129,10 @@ class AppProvider with ChangeNotifier {
|
|||||||
if (AppProvider.shouldIgnoreSelfReplay(
|
if (AppProvider.shouldIgnoreSelfReplay(
|
||||||
message: message,
|
message: message,
|
||||||
ownPublicKey: connectionProvider.deviceInfo.publicKey,
|
ownPublicKey: connectionProvider.deviceInfo.publicKey,
|
||||||
ownName:
|
ownName: _preferredSelfDisplayName(
|
||||||
connectionProvider.deviceInfo.deviceName ??
|
deviceName: connectionProvider.deviceInfo.deviceName,
|
||||||
connectionProvider.deviceInfo.selfName,
|
selfName: connectionProvider.deviceInfo.selfName,
|
||||||
|
),
|
||||||
)) {
|
)) {
|
||||||
debugPrint('⏭️ [AppProvider] Ignoring self replay: ${message.id}');
|
debugPrint('⏭️ [AppProvider] Ignoring self replay: ${message.id}');
|
||||||
return;
|
return;
|
||||||
@@ -1180,6 +1335,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
// Used by newer firmware versions for telemetry and other binary data
|
// Used by newer firmware versions for telemetry and other binary data
|
||||||
// BOTH callbacks (0x8B and 0x8C) must be handled for device compatibility
|
// BOTH callbacks (0x8B and 0x8C) must be handled for device compatibility
|
||||||
connectionProvider.onBinaryResponse = (publicKeyPrefix, tag, responseData) {
|
connectionProvider.onBinaryResponse = (publicKeyPrefix, tag, responseData) {
|
||||||
|
if (_handlePendingRepeaterOwnerResponse(tag, responseData)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'📊 [AppProvider] Binary response (0x8C) received - updating contact telemetry',
|
'📊 [AppProvider] Binary response (0x8C) received - updating contact telemetry',
|
||||||
);
|
);
|
||||||
@@ -1193,6 +1351,43 @@ class AppProvider with ChangeNotifier {
|
|||||||
// Magic 0x69 'i' = image fetch request; 0x56 'V' = voice packet.
|
// Magic 0x69 'i' = image fetch request; 0x56 'V' = voice packet.
|
||||||
// Magic 0x49 'I' = image packet.
|
// Magic 0x49 'I' = image packet.
|
||||||
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
|
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
|
||||||
|
final parsedAdvert = _tryParseRawAdvert(payload);
|
||||||
|
if (parsedAdvert != null) {
|
||||||
|
final isNewPendingAdvert = contactsProvider.addOrUpdatePendingAdvertMetadata(
|
||||||
|
publicKey: parsedAdvert.publicKey,
|
||||||
|
typeValue: parsedAdvert.typeValue,
|
||||||
|
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||||
|
flags: parsedAdvert.flags,
|
||||||
|
advName: parsedAdvert.advName,
|
||||||
|
lastAdvert: parsedAdvert.lastAdvert,
|
||||||
|
advLat: parsedAdvert.advLat,
|
||||||
|
advLon: parsedAdvert.advLon,
|
||||||
|
signedEncodedPathLen: parsedAdvert.signedEncodedPathLen,
|
||||||
|
paddedPathBytes: parsedAdvert.paddedPathBytes,
|
||||||
|
rxRssiDbm: rssiDbm,
|
||||||
|
rxSnrRaw: snrRaw,
|
||||||
|
);
|
||||||
|
if (isNewPendingAdvert) {
|
||||||
|
final contactKey = parsedAdvert.publicKey
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
unawaited(
|
||||||
|
_notificationService.showContactDiscoveredNotification(
|
||||||
|
contactKey: contactKey,
|
||||||
|
contactName: parsedAdvert.advName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (parsedAdvert.typeValue == ContactType.repeater.value) {
|
||||||
|
unawaited(_requestRepeaterStatus(parsedAdvert.publicKey));
|
||||||
|
unawaited(_maybeRequestRepeaterOwnerInfo(parsedAdvert.publicKey));
|
||||||
|
}
|
||||||
|
if (contactsProvider.shouldEnrichPendingAdvert(parsedAdvert.publicKey)) {
|
||||||
|
unawaited(connectionProvider.previewContact(parsedAdvert.publicKey));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final fastGpsPacket = FastGpsPacket.tryParseBinary(payload);
|
final fastGpsPacket = FastGpsPacket.tryParseBinary(payload);
|
||||||
if (fastGpsPacket != null) {
|
if (fastGpsPacket != null) {
|
||||||
final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6);
|
final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6);
|
||||||
@@ -1398,6 +1593,31 @@ class AppProvider with ChangeNotifier {
|
|||||||
_handleIncomingVoicePacket(pkt, justComplete: justComplete);
|
_handleIncomingVoicePacket(pkt, justComplete: justComplete);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
connectionProvider.onControlDataReceived =
|
||||||
|
(payload, snrRaw, rssiDbm, pathLen) {
|
||||||
|
_handleControlDataDiscovery(
|
||||||
|
payload: payload,
|
||||||
|
snrRaw: snrRaw,
|
||||||
|
rssiDbm: rssiDbm,
|
||||||
|
pathLen: pathLen,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
connectionProvider.onStatusResponse = (publicKeyPrefix, statusData) {
|
||||||
|
final parsed = _tryParseRepeaterStatus(statusData);
|
||||||
|
if (parsed == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
contactsProvider.updatePendingAdvertStatusByPrefix(
|
||||||
|
publicKeyPrefix,
|
||||||
|
batteryMv: parsed.batteryMv,
|
||||||
|
queueLen: parsed.queueLen,
|
||||||
|
lastRssi: parsed.lastRssi,
|
||||||
|
lastSnrRaw: parsed.lastSnrRaw,
|
||||||
|
uptimeSecs: parsed.uptimeSecs,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// When a contact's routing path is updated in the mesh network
|
// When a contact's routing path is updated in the mesh network
|
||||||
connectionProvider.onPathUpdated = (publicKey) {
|
connectionProvider.onPathUpdated = (publicKey) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1434,22 +1654,28 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (_autoAddDiscoveredContacts) {
|
final isNewPendingAdvert = contactsProvider.addPendingAdvert(
|
||||||
debugPrint(' Unknown contact - auto-add enabled, fetching details');
|
publicKey,
|
||||||
Future.delayed(const Duration(milliseconds: 100), () {
|
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||||
if (connectionProvider.deviceInfo.isConnected) {
|
);
|
||||||
connectionProvider.getContact(publicKey);
|
debugPrint(
|
||||||
}
|
' Unknown contact - added to pending adverts list and waiting for manual resolution',
|
||||||
});
|
);
|
||||||
} else {
|
if (isNewPendingAdvert) {
|
||||||
contactsProvider.addPendingAdvert(
|
final keyHex = publicKey
|
||||||
publicKey,
|
.take(6)
|
||||||
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
);
|
.join();
|
||||||
debugPrint(
|
unawaited(
|
||||||
' Unknown contact - added to pending adverts list and waiting for details',
|
_notificationService.showContactDiscoveredNotification(
|
||||||
|
contactKey: keyHex,
|
||||||
|
contactName: contactsProvider.pendingAdvertByKey(publicKey)?.advName,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (contactsProvider.shouldEnrichPendingAdvert(publicKey)) {
|
||||||
|
unawaited(connectionProvider.previewContact(publicKey));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1861,6 +2087,314 @@ class AppProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _requestRepeaterStatus(Uint8List publicKey) async {
|
||||||
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final keyHex = publicKey
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
final now = DateTime.now();
|
||||||
|
final lastRequestAt = _recentRepeaterStatusRequests[keyHex];
|
||||||
|
if (lastRequestAt != null &&
|
||||||
|
now.difference(lastRequestAt) < const Duration(minutes: 2)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_recentRepeaterStatusRequests[keyHex] = now;
|
||||||
|
try {
|
||||||
|
await connectionProvider.requestStatus(publicKey);
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore unsupported or unreachable repeaters.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _maybeRequestRepeaterOwnerInfo(Uint8List publicKey) async {
|
||||||
|
final pendingAdvert = contactsProvider.pendingAdvertByKey(publicKey);
|
||||||
|
if (pendingAdvert == null ||
|
||||||
|
pendingAdvert.typeValue != ContactType.repeater.value ||
|
||||||
|
pendingAdvert.advName?.trim().isNotEmpty == true ||
|
||||||
|
!connectionProvider.deviceInfo.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final keyHex = pendingAdvert.publicKeyHex;
|
||||||
|
final now = DateTime.now();
|
||||||
|
final lastRequestAt = _recentRepeaterOwnerInfoRequests[keyHex];
|
||||||
|
if (lastRequestAt != null &&
|
||||||
|
now.difference(lastRequestAt) < _repeaterOwnerInfoRequestCooldown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact? existingContact;
|
||||||
|
for (final contact in contactsProvider.contacts) {
|
||||||
|
if (contact.publicKeyHex == keyHex) {
|
||||||
|
existingContact = contact;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var temporaryContactAdded = false;
|
||||||
|
final requestContact =
|
||||||
|
existingContact ?? _temporaryRepeaterContactForOwnerInfo(pendingAdvert);
|
||||||
|
if (requestContact == null || !requestContact.routeHasPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_recentRepeaterOwnerInfoRequests[keyHex] = now;
|
||||||
|
|
||||||
|
if (existingContact == null) {
|
||||||
|
connectionProvider.clearError();
|
||||||
|
await connectionProvider.addOrUpdateContact(requestContact);
|
||||||
|
if (connectionProvider.error != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connectionProvider.clearError();
|
||||||
|
temporaryContactAdded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final ticket = await connectionProvider.sendAnonRequest(
|
||||||
|
contactPublicKey: publicKey,
|
||||||
|
requestData: _buildRepeaterOwnerRequest(requestContact),
|
||||||
|
);
|
||||||
|
if (ticket == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_pendingRepeaterOwnerRequests[ticket.tag] = _PendingRepeaterOwnerRequest(
|
||||||
|
publicKey: Uint8List.fromList(publicKey),
|
||||||
|
);
|
||||||
|
Future.delayed(
|
||||||
|
Duration(milliseconds: ticket.suggestedTimeoutMs + 1500),
|
||||||
|
() => _pendingRepeaterOwnerRequests.remove(ticket.tag),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore unsupported or unreachable repeaters.
|
||||||
|
} finally {
|
||||||
|
if (temporaryContactAdded) {
|
||||||
|
Future.delayed(const Duration(milliseconds: 250), () async {
|
||||||
|
await connectionProvider.removeContact(publicKey);
|
||||||
|
connectionProvider.clearError();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact? _temporaryRepeaterContactForOwnerInfo(PendingAdvert advert) {
|
||||||
|
final signedEncodedPathLen = advert.signedEncodedPathLen;
|
||||||
|
if (signedEncodedPathLen == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final advName = advert.advName?.trim();
|
||||||
|
final lastAdvert =
|
||||||
|
advert.lastAdvert ?? (advert.receivedAt.millisecondsSinceEpoch ~/ 1000);
|
||||||
|
return Contact(
|
||||||
|
publicKey: Uint8List.fromList(advert.publicKey),
|
||||||
|
type: ContactType.repeater,
|
||||||
|
flags: advert.flags ?? 0,
|
||||||
|
outPathLen: signedEncodedPathLen,
|
||||||
|
outPath: advert.paddedPathBytes == null
|
||||||
|
? Uint8List(ContactRouteCodec.maxPathBytes)
|
||||||
|
: Uint8List.fromList(advert.paddedPathBytes!),
|
||||||
|
advName: advName?.isNotEmpty == true ? advName! : advert.shortDisplayKey,
|
||||||
|
lastAdvert: lastAdvert,
|
||||||
|
advLat: advert.advLat ?? 0,
|
||||||
|
advLon: advert.advLon ?? 0,
|
||||||
|
lastMod: lastAdvert,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List _buildRepeaterOwnerRequest(Contact contact) {
|
||||||
|
final replyPathDescriptor = contact.routeEncodedPathLen;
|
||||||
|
final replyPathBytes = contact.routeHopCount > 0
|
||||||
|
? Uint8List.fromList(
|
||||||
|
LogRxRouteDecoder.reverseHopBytes(
|
||||||
|
contact.routePathBytes,
|
||||||
|
hashSize: contact.routeHashSize,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Uint8List(0);
|
||||||
|
return Uint8List.fromList([
|
||||||
|
_anonReqTypeOwner,
|
||||||
|
replyPathDescriptor,
|
||||||
|
...replyPathBytes,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _handlePendingRepeaterOwnerResponse(int tag, Uint8List responseData) {
|
||||||
|
final request = _pendingRepeaterOwnerRequests.remove(tag);
|
||||||
|
if (request == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ownerName = _tryParseRepeaterOwnerName(responseData);
|
||||||
|
if (ownerName == null || ownerName.isEmpty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final existing = contactsProvider.pendingAdvertByKey(request.publicKey);
|
||||||
|
contactsProvider.addOrUpdatePendingAdvertMetadata(
|
||||||
|
publicKey: request.publicKey,
|
||||||
|
typeValue: existing?.typeValue ?? ContactType.repeater.value,
|
||||||
|
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||||
|
advName: ownerName,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _tryParseRepeaterOwnerName(Uint8List responseData) {
|
||||||
|
if (responseData.length <= 4) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final payload = utf8.decode(
|
||||||
|
responseData.sublist(4),
|
||||||
|
allowMalformed: true,
|
||||||
|
).trim();
|
||||||
|
if (payload.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final firstLine = payload.split(RegExp(r'[\r\n]+')).first.trim();
|
||||||
|
return firstLine.isEmpty ? null : firstLine;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ParsedRepeaterStatus? _tryParseRepeaterStatus(Uint8List statusData) {
|
||||||
|
if (statusData.length < 52) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final data = ByteData.sublistView(statusData);
|
||||||
|
var offset = 0;
|
||||||
|
final batteryMv = data.getUint16(offset, Endian.little);
|
||||||
|
offset += 2;
|
||||||
|
final queueLen = data.getUint16(offset, Endian.little);
|
||||||
|
offset += 2;
|
||||||
|
offset += 2; // noiseFloor
|
||||||
|
final lastRssi = data.getInt16(offset, Endian.little);
|
||||||
|
offset += 2;
|
||||||
|
offset += 4; // packetsRecv
|
||||||
|
offset += 4; // packetsSent
|
||||||
|
offset += 4; // txAirSecs
|
||||||
|
final uptimeSecs = data.getUint32(offset, Endian.little);
|
||||||
|
offset += 4;
|
||||||
|
offset += 4; // floodTx
|
||||||
|
offset += 4; // directTx
|
||||||
|
offset += 4; // floodRx
|
||||||
|
offset += 4; // directRx
|
||||||
|
offset += 2; // errEvents
|
||||||
|
final lastSnrRaw = data.getInt16(offset, Endian.little);
|
||||||
|
|
||||||
|
return _ParsedRepeaterStatus(
|
||||||
|
batteryMv: batteryMv,
|
||||||
|
queueLen: queueLen,
|
||||||
|
lastRssi: lastRssi,
|
||||||
|
lastSnrRaw: lastSnrRaw,
|
||||||
|
uptimeSecs: uptimeSecs,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ParsedRawAdvert? _tryParseRawAdvert(Uint8List rawPayload) {
|
||||||
|
if (rawPayload.length < 103) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final reader = BufferReader(rawPayload);
|
||||||
|
final header = reader.readByte();
|
||||||
|
final routeType = header & 0x03;
|
||||||
|
final payloadType = (header >> 2) & 0x0F;
|
||||||
|
if (payloadType != _rawPayloadTypeAdvert) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeType == _routeTransportFlood ||
|
||||||
|
routeType == _routeTransportDirect) {
|
||||||
|
if (reader.remainingBytesCount < 4) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
reader.skip(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.remainingBytesCount < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final pathByteLen = reader.readByte();
|
||||||
|
if (reader.remainingBytesCount < pathByteLen + 101) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final pathBytes = reader.readBytes(pathByteLen);
|
||||||
|
final publicKey = reader.readBytes(32);
|
||||||
|
final timestamp = reader.readInt32LE();
|
||||||
|
reader.skip(64);
|
||||||
|
final flags = reader.readByte();
|
||||||
|
final typeValue = flags & 0x0F;
|
||||||
|
final hasLocation = (flags & 0x10) != 0;
|
||||||
|
final hasName = (flags & 0x80) != 0;
|
||||||
|
|
||||||
|
int? advLat;
|
||||||
|
int? advLon;
|
||||||
|
if (hasLocation) {
|
||||||
|
if (reader.remainingBytesCount < 8) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
advLat = reader.readInt32LE();
|
||||||
|
advLon = reader.readInt32LE();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? advName;
|
||||||
|
if (hasName && reader.remainingBytesCount > 0) {
|
||||||
|
final decodedName = utf8.decode(
|
||||||
|
reader.readRemainingBytes(),
|
||||||
|
allowMalformed: true,
|
||||||
|
).trim();
|
||||||
|
if (decodedName.isNotEmpty) {
|
||||||
|
advName = decodedName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int? signedEncodedPathLen;
|
||||||
|
Uint8List? paddedPathBytes;
|
||||||
|
if (pathBytes.isNotEmpty) {
|
||||||
|
final hashSize = LogRxRouteDecoder.inferHashSize(pathBytes);
|
||||||
|
final reversedPathBytes = LogRxRouteDecoder.reverseHopBytes(
|
||||||
|
pathBytes,
|
||||||
|
hashSize: hashSize,
|
||||||
|
);
|
||||||
|
final padded = Uint8List(ContactRouteCodec.maxPathBytes)
|
||||||
|
..setRange(0, reversedPathBytes.length, reversedPathBytes);
|
||||||
|
final encodedPathLen =
|
||||||
|
((hashSize - 1) << 6) |
|
||||||
|
((reversedPathBytes.length ~/ hashSize) & 0x3F);
|
||||||
|
signedEncodedPathLen = ContactRouteCodec.toSignedDescriptor(
|
||||||
|
encodedPathLen,
|
||||||
|
);
|
||||||
|
paddedPathBytes = padded;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _ParsedRawAdvert(
|
||||||
|
publicKey: publicKey,
|
||||||
|
advName: advName,
|
||||||
|
typeValue: typeValue,
|
||||||
|
flags: flags,
|
||||||
|
lastAdvert: timestamp,
|
||||||
|
advLat: advLat,
|
||||||
|
advLon: advLon,
|
||||||
|
signedEncodedPathLen: signedEncodedPathLen,
|
||||||
|
paddedPathBytes: paddedPathBytes,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
int _inferReceivedPathHashSize(
|
int _inferReceivedPathHashSize(
|
||||||
List<int> pathBytes, {
|
List<int> pathBytes, {
|
||||||
required int preferredHashSize,
|
required int preferredHashSize,
|
||||||
@@ -1897,12 +2431,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
// Get battery and storage information
|
// Get battery and storage information
|
||||||
await connectionProvider.getBatteryAndStorage();
|
await connectionProvider.getBatteryAndStorage();
|
||||||
|
|
||||||
// Load contacts
|
// Load contacts (waits for device to finish sending all contacts)
|
||||||
await connectionProvider.getContacts();
|
await connectionProvider.getContacts();
|
||||||
|
|
||||||
// Small delay to ensure contacts are fully loaded
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
|
|
||||||
// Sync all channels so slot assignment and channel state mirror the device.
|
// Sync all channels so slot assignment and channel state mirror the device.
|
||||||
final channelsToSync = connectionProvider.deviceInfo.maxChannels;
|
final channelsToSync = connectionProvider.deviceInfo.maxChannels;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -3079,9 +3610,72 @@ class AppProvider with ChangeNotifier {
|
|||||||
_imageMissingRetryAttempts.clear();
|
_imageMissingRetryAttempts.clear();
|
||||||
_voiceSessionSenderKey6.clear();
|
_voiceSessionSenderKey6.clear();
|
||||||
_imageSessionSenderKey6.clear();
|
_imageSessionSenderKey6.clear();
|
||||||
|
_lowBatteryNotifiedNodeIds.clear();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleControlDataDiscovery({
|
||||||
|
required Uint8List payload,
|
||||||
|
required int snrRaw,
|
||||||
|
required int rssiDbm,
|
||||||
|
required int pathLen,
|
||||||
|
}) {
|
||||||
|
const int controlTypeMask = 0xF0;
|
||||||
|
const int controlTypeNodeDiscoverResp = 0x90;
|
||||||
|
const int minFullDiscoverResponseLength = 6 + 32;
|
||||||
|
|
||||||
|
if (payload.length < minFullDiscoverResponseLength) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final controlType = payload[0] & controlTypeMask;
|
||||||
|
if (controlType != controlTypeNodeDiscoverResp) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nodeType = payload[0] & 0x0F;
|
||||||
|
final publicKey = Uint8List.fromList(payload.sublist(6, 38));
|
||||||
|
final isNewPendingAdvert = contactsProvider
|
||||||
|
.addOrUpdatePendingAdvertMetadata(
|
||||||
|
publicKey: publicKey,
|
||||||
|
typeValue: nodeType,
|
||||||
|
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||||
|
rxRssiDbm: rssiDbm,
|
||||||
|
rxSnrRaw: snrRaw,
|
||||||
|
signedEncodedPathLen: pathLen == 0 ? 0 : null,
|
||||||
|
paddedPathBytes: pathLen == 0
|
||||||
|
? Uint8List(ContactRouteCodec.maxPathBytes)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'🛰️ [AppProvider] Control discovery response: type=$nodeType '
|
||||||
|
'pathLen=$pathLen snr=$snrRaw rssi=$rssiDbm new=$isNewPendingAdvert',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isNewPendingAdvert) {
|
||||||
|
final keyHex = publicKey
|
||||||
|
.take(6)
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
unawaited(
|
||||||
|
_notificationService.showContactDiscoveredNotification(
|
||||||
|
contactKey: keyHex,
|
||||||
|
contactName: contactsProvider.pendingAdvertByKey(publicKey)?.advName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeType == ContactType.repeater.value) {
|
||||||
|
unawaited(_requestRepeaterStatus(publicKey));
|
||||||
|
unawaited(_maybeRequestRepeaterOwnerInfo(publicKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contactsProvider.shouldEnrichPendingAdvert(publicKey)) {
|
||||||
|
unawaited(connectionProvider.previewContact(publicKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get app statistics
|
/// Get app statistics
|
||||||
Map<String, dynamic> get statistics {
|
Map<String, dynamic> get statistics {
|
||||||
return {
|
return {
|
||||||
@@ -3099,6 +3693,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_packetCaptureFlushTimer?.cancel();
|
_packetCaptureFlushTimer?.cancel();
|
||||||
|
_lowBatteryCheckTimer?.cancel();
|
||||||
unawaited(_flushPacketCaptureLogs());
|
unawaited(_flushPacketCaptureLogs());
|
||||||
// Remove connection state listener
|
// Remove connection state listener
|
||||||
connectionProvider.removeListener(_handleConnectionStateChange);
|
connectionProvider.removeListener(_handleConnectionStateChange);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||||
@@ -55,8 +57,21 @@ class ScannedDevice {
|
|||||||
ScannedDevice({required this.device, required this.rssi});
|
ScannedDevice({required this.device, required this.rssi});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ContactReceiveSource { sync, requestedSingle, preview, advert }
|
||||||
|
|
||||||
|
class _PendingContactRequest {
|
||||||
|
final ContactReceiveSource source;
|
||||||
|
final DateTime requestedAt;
|
||||||
|
|
||||||
|
const _PendingContactRequest({
|
||||||
|
required this.source,
|
||||||
|
required this.requestedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Connection Provider - manages MeshCore device connection (BLE or TCP/WiFi)
|
/// Connection Provider - manages MeshCore device connection (BLE or TCP/WiFi)
|
||||||
class ConnectionProvider with ChangeNotifier {
|
class ConnectionProvider with ChangeNotifier {
|
||||||
|
static const int _controlTypeNodeDiscoverReq = 0x80;
|
||||||
final MeshCoreBleService _bleService = MeshCoreBleService();
|
final MeshCoreBleService _bleService = MeshCoreBleService();
|
||||||
final SseServerService _sseServer = SseServerService();
|
final SseServerService _sseServer = SseServerService();
|
||||||
MeshCoreTcpService? _tcpService;
|
MeshCoreTcpService? _tcpService;
|
||||||
@@ -130,6 +145,9 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
// Completer to wait for response before sending next sync request
|
// Completer to wait for response before sending next sync request
|
||||||
Completer<bool>? _syncResponseCompleter;
|
Completer<bool>? _syncResponseCompleter;
|
||||||
|
|
||||||
|
// Completer to wait for contacts sync to finish
|
||||||
|
Completer<void>? _contactsSyncCompleter;
|
||||||
|
|
||||||
// Lightweight guards for other commands that can be double-tapped
|
// Lightweight guards for other commands that can be double-tapped
|
||||||
bool _isLoginInProgress = false;
|
bool _isLoginInProgress = false;
|
||||||
DateTime? _lastLoginRequestedAt;
|
DateTime? _lastLoginRequestedAt;
|
||||||
@@ -142,6 +160,13 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
bool _isAdvertInProgress = false;
|
bool _isAdvertInProgress = false;
|
||||||
DateTime? _lastAdvertRequestedAt;
|
DateTime? _lastAdvertRequestedAt;
|
||||||
static const Duration _minAdvertInterval = Duration(milliseconds: 500);
|
static const Duration _minAdvertInterval = Duration(milliseconds: 500);
|
||||||
|
bool _isContactsSyncInProgress = false;
|
||||||
|
final Map<String, _PendingContactRequest> _pendingSingleContactRequests = {};
|
||||||
|
final Map<String, DateTime> _previewContactMisses = {};
|
||||||
|
static const Duration _singleContactRequestWindow = Duration(seconds: 10);
|
||||||
|
static const Duration _previewContactMissTtl = Duration(minutes: 10);
|
||||||
|
bool _suppressNextPreviewNotFoundError = false;
|
||||||
|
bool? _supportsAutoaddConfig;
|
||||||
|
|
||||||
// Helper instances
|
// Helper instances
|
||||||
final RoomLoginManager _roomLoginManager = RoomLoginManager();
|
final RoomLoginManager _roomLoginManager = RoomLoginManager();
|
||||||
@@ -159,6 +184,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Callbacks for other providers
|
// Callbacks for other providers
|
||||||
Function(Contact)? onContactReceived;
|
Function(Contact)? onContactReceived;
|
||||||
|
Function(Contact, ContactReceiveSource)? onContactReceivedDetailed;
|
||||||
Function(List<Contact>)? onContactsComplete;
|
Function(List<Contact>)? onContactsComplete;
|
||||||
Function(Message)? onMessageReceived;
|
Function(Message)? onMessageReceived;
|
||||||
Function(Uint8List publicKey, Uint8List lppData)? onTelemetryReceived;
|
Function(Uint8List publicKey, Uint8List lppData)? onTelemetryReceived;
|
||||||
@@ -186,6 +212,8 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
onMessageEchoDetected;
|
onMessageEchoDetected;
|
||||||
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
|
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
|
||||||
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
|
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
|
||||||
|
Function(Uint8List payload, int snrRaw, int rssiDbm, int pathLen)?
|
||||||
|
onControlDataReceived;
|
||||||
Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback;
|
Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback;
|
||||||
bool Function()? canStartAutomaticMessageSyncCallback;
|
bool Function()? canStartAutomaticMessageSyncCallback;
|
||||||
|
|
||||||
@@ -238,6 +266,10 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
};
|
};
|
||||||
|
|
||||||
service.onError = (error, {int? errorCode}) {
|
service.onError = (error, {int? errorCode}) {
|
||||||
|
if (errorCode == 2 && _suppressNextPreviewNotFoundError) {
|
||||||
|
_suppressNextPreviewNotFoundError = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
debugPrint('⚠️ [Provider] Error received: $error');
|
debugPrint('⚠️ [Provider] Error received: $error');
|
||||||
_error = error;
|
_error = error;
|
||||||
if (_deviceInfo.connectionState != ConnectionState.connected) {
|
if (_deviceInfo.connectionState != ConnectionState.connected) {
|
||||||
@@ -249,9 +281,21 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
};
|
};
|
||||||
|
|
||||||
service.onContactNotFound = (contactPublicKey) async {
|
service.onContactNotFound = (contactPublicKey) async {
|
||||||
debugPrint('🔧 [Provider] Contact not found - initiating auto-recovery');
|
|
||||||
if (contactPublicKey == null) return;
|
if (contactPublicKey == null) return;
|
||||||
|
|
||||||
|
final keyHex = _publicKeyToHex(contactPublicKey);
|
||||||
|
final request = _pendingSingleContactRequests.remove(keyHex);
|
||||||
|
if (request?.source == ContactReceiveSource.preview) {
|
||||||
|
_previewContactMisses[keyHex] = DateTime.now();
|
||||||
|
_suppressNextPreviewNotFoundError = true;
|
||||||
|
debugPrint(
|
||||||
|
'🔧 [Provider] Preview contact not found, suppressing retries for $keyHex',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('🔧 [Provider] Contact not found - initiating auto-recovery');
|
||||||
|
|
||||||
final operationId = contactPublicKey
|
final operationId = contactPublicKey
|
||||||
.sublist(0, 6)
|
.sublist(0, 6)
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
@@ -261,7 +305,6 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await _activeService.addOrUpdateContact(pendingOp.contact!);
|
await _activeService.addOrUpdateContact(pendingOp.contact!);
|
||||||
await Future.delayed(const Duration(milliseconds: 300));
|
|
||||||
|
|
||||||
if (pendingOp.messageId != null) {
|
if (pendingOp.messageId != null) {
|
||||||
_messageDeliveryTracker.trackPendingDirectMessage(
|
_messageDeliveryTracker.trackPendingDirectMessage(
|
||||||
@@ -286,11 +329,18 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
service.onContactReceived = (contact) {
|
service.onContactReceived = (contact) {
|
||||||
debugPrint('📥 [Provider] Contact received: "${contact.advName}"');
|
debugPrint('📥 [Provider] Contact received: "${contact.advName}"');
|
||||||
|
final source = _classifyContactReceiveSource(contact.publicKey);
|
||||||
|
onContactReceivedDetailed?.call(contact, source);
|
||||||
onContactReceived?.call(contact);
|
onContactReceived?.call(contact);
|
||||||
};
|
};
|
||||||
|
|
||||||
service.onContactsComplete = (contacts) {
|
service.onContactsComplete = (contacts) {
|
||||||
debugPrint('📥 [Provider] Contacts sync complete: ${contacts.length}');
|
debugPrint('📥 [Provider] Contacts sync complete: ${contacts.length}');
|
||||||
|
_isContactsSyncInProgress = false;
|
||||||
|
if (_contactsSyncCompleter != null &&
|
||||||
|
!_contactsSyncCompleter!.isCompleted) {
|
||||||
|
_contactsSyncCompleter!.complete();
|
||||||
|
}
|
||||||
onContactsComplete?.call(contacts);
|
onContactsComplete?.call(contacts);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -402,6 +452,8 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
service.onRawDataReceived = (payload, snrRaw, rssiDbm) =>
|
service.onRawDataReceived = (payload, snrRaw, rssiDbm) =>
|
||||||
onRawDataReceived?.call(payload, snrRaw, rssiDbm);
|
onRawDataReceived?.call(payload, snrRaw, rssiDbm);
|
||||||
|
service.onControlDataReceived = (payload, snrRaw, rssiDbm, pathLen) =>
|
||||||
|
onControlDataReceived?.call(payload, snrRaw, rssiDbm, pathLen);
|
||||||
|
|
||||||
service.onDeviceInfoReceived = (deviceInfo) {
|
service.onDeviceInfoReceived = (deviceInfo) {
|
||||||
debugPrint('📥 [Provider] DeviceInfo received');
|
debugPrint('📥 [Provider] DeviceInfo received');
|
||||||
@@ -475,6 +527,17 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
service.onAutoaddConfigReceived = (config) {
|
||||||
|
_deviceInfo = _deviceInfo.copyWith(
|
||||||
|
autoAddUsers: config['autoAddUsers'] as bool?,
|
||||||
|
autoAddRepeaters: config['autoAddRepeaters'] as bool?,
|
||||||
|
autoAddRoomServers: config['autoAddRoomServers'] as bool?,
|
||||||
|
autoAddSensors: config['autoAddSensors'] as bool?,
|
||||||
|
autoAddOverwriteOldest: config['autoAddOverwriteOldest'] as bool?,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
};
|
||||||
|
|
||||||
service.onTxActivity = () {
|
service.onTxActivity = () {
|
||||||
_txActivity = true;
|
_txActivity = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -583,6 +646,9 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
await stopScan();
|
await stopScan();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure we route commands to BLE, not a stale TCP service.
|
||||||
|
_connectionMode = ConnectionMode.ble;
|
||||||
|
|
||||||
_deviceInfo = _deviceInfo.copyWith(
|
_deviceInfo = _deviceInfo.copyWith(
|
||||||
deviceId: device.remoteId.toString(),
|
deviceId: device.remoteId.toString(),
|
||||||
deviceName: device.platformName.isNotEmpty
|
deviceName: device.platformName.isNotEmpty
|
||||||
@@ -591,6 +657,8 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
connectionState: ConnectionState.connecting,
|
connectionState: ConnectionState.connecting,
|
||||||
);
|
);
|
||||||
_error = null;
|
_error = null;
|
||||||
|
_supportsAutoaddConfig = null;
|
||||||
|
_resetSyncState();
|
||||||
debugPrint('✅ [Provider] Device info updated to connecting state');
|
debugPrint('✅ [Provider] Device info updated to connecting state');
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
@@ -620,6 +688,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
connectionState: ConnectionState.connecting,
|
connectionState: ConnectionState.connecting,
|
||||||
);
|
);
|
||||||
_error = null;
|
_error = null;
|
||||||
|
_supportsAutoaddConfig = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
// Create fresh TCP service and wire its callbacks
|
// Create fresh TCP service and wire its callbacks
|
||||||
@@ -648,6 +717,8 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
_tcpHost = null;
|
_tcpHost = null;
|
||||||
_connectionMode = ConnectionMode.ble;
|
_connectionMode = ConnectionMode.ble;
|
||||||
|
_supportsAutoaddConfig = null;
|
||||||
|
_resetSyncState();
|
||||||
_deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected);
|
_deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected);
|
||||||
_roomLoginManager.clearRoomLoginStates();
|
_roomLoginManager.clearRoomLoginStates();
|
||||||
_pingTracker.clearAll();
|
_pingTracker.clearAll();
|
||||||
@@ -670,6 +741,8 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
await _bleService.disconnect();
|
await _bleService.disconnect();
|
||||||
|
|
||||||
|
_supportsAutoaddConfig = null;
|
||||||
|
_resetSyncState();
|
||||||
_deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected);
|
_deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected);
|
||||||
_roomLoginManager.clearRoomLoginStates();
|
_roomLoginManager.clearRoomLoginStates();
|
||||||
_pingTracker.clearAll();
|
_pingTracker.clearAll();
|
||||||
@@ -678,6 +751,24 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reset message sync state so the next connect/reconnect can sync cleanly.
|
||||||
|
void _resetSyncState() {
|
||||||
|
if (_syncResponseCompleter != null &&
|
||||||
|
!_syncResponseCompleter!.isCompleted) {
|
||||||
|
_syncResponseCompleter!.complete(false);
|
||||||
|
}
|
||||||
|
_syncResponseCompleter = null;
|
||||||
|
if (_contactsSyncCompleter != null &&
|
||||||
|
!_contactsSyncCompleter!.isCompleted) {
|
||||||
|
_contactsSyncCompleter!.complete();
|
||||||
|
}
|
||||||
|
_contactsSyncCompleter = null;
|
||||||
|
_isSyncingMessages = false;
|
||||||
|
_syncRequestedWhileBusy = false;
|
||||||
|
_noMoreMessages = false;
|
||||||
|
_pendingAutomaticMessageSync = false;
|
||||||
|
}
|
||||||
|
|
||||||
/// Cancel ongoing reconnection attempts
|
/// Cancel ongoing reconnection attempts
|
||||||
/// This is useful when the user wants to manually disconnect during reconnection
|
/// This is useful when the user wants to manually disconnect during reconnection
|
||||||
void cancelReconnection() {
|
void cancelReconnection() {
|
||||||
@@ -719,7 +810,10 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
return _messageDeliveryTracker.getDiagnostics();
|
return _messageDeliveryTracker.getDiagnostics();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get contacts from device
|
/// Get contacts from device.
|
||||||
|
///
|
||||||
|
/// Waits for the device to finish sending all contacts (up to 5 s timeout)
|
||||||
|
/// so callers don't need an arbitrary delay.
|
||||||
Future<void> getContacts() async {
|
Future<void> getContacts() async {
|
||||||
if (!_activeService.isConnected) {
|
if (!_activeService.isConnected) {
|
||||||
_error = 'Not connected to device';
|
_error = 'Not connected to device';
|
||||||
@@ -728,10 +822,24 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
_isContactsSyncInProgress = true;
|
||||||
|
_contactsSyncCompleter = Completer<void>();
|
||||||
await _activeService.getContacts();
|
await _activeService.getContacts();
|
||||||
|
await _contactsSyncCompleter!.future.timeout(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
onTimeout: () {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [Provider] Contacts sync timeout - proceeding without full list',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
_isContactsSyncInProgress = false;
|
||||||
_error = 'Failed to get contacts: $e';
|
_error = 'Failed to get contacts: $e';
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
} finally {
|
||||||
|
_isContactsSyncInProgress = false;
|
||||||
|
_contactsSyncCompleter = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,6 +857,10 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
_markSingleContactRequested(
|
||||||
|
publicKey,
|
||||||
|
source: ContactReceiveSource.requestedSingle,
|
||||||
|
);
|
||||||
await _activeService.getContactByKey(publicKey);
|
await _activeService.getContactByKey(publicKey);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = 'Failed to get contact: $e';
|
_error = 'Failed to get contact: $e';
|
||||||
@@ -756,11 +868,81 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
'⚠️ [Provider] Failed to get contact by key, falling back to full contact sync',
|
'⚠️ [Provider] Failed to get contact by key, falling back to full contact sync',
|
||||||
);
|
);
|
||||||
// Fallback to full contact sync if command not supported
|
// Fallback to full contact sync if command not supported
|
||||||
|
_isContactsSyncInProgress = true;
|
||||||
await _activeService.getContacts();
|
await _activeService.getContacts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> previewContact(Uint8List publicKey) async {
|
||||||
|
if (!_activeService.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_prunePreviewContactMisses();
|
||||||
|
final keyHex = _publicKeyToHex(publicKey);
|
||||||
|
if (_previewContactMisses.containsKey(keyHex)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
_markSingleContactRequested(
|
||||||
|
publicKey,
|
||||||
|
source: ContactReceiveSource.preview,
|
||||||
|
);
|
||||||
|
await _activeService.getContactByKey(publicKey);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [Provider] Preview contact fetch failed for ${_publicKeyToHex(publicKey)}: $e',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ContactReceiveSource _classifyContactReceiveSource(Uint8List publicKey) {
|
||||||
|
_prunePendingSingleContactRequests();
|
||||||
|
final keyHex = _publicKeyToHex(publicKey);
|
||||||
|
if (_isContactsSyncInProgress) {
|
||||||
|
return ContactReceiveSource.sync;
|
||||||
|
}
|
||||||
|
final request = _pendingSingleContactRequests.remove(keyHex);
|
||||||
|
if (request != null &&
|
||||||
|
DateTime.now().difference(request.requestedAt) <=
|
||||||
|
_singleContactRequestWindow) {
|
||||||
|
return request.source;
|
||||||
|
}
|
||||||
|
return ContactReceiveSource.advert;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markSingleContactRequested(
|
||||||
|
Uint8List publicKey, {
|
||||||
|
required ContactReceiveSource source,
|
||||||
|
}) {
|
||||||
|
_prunePendingSingleContactRequests();
|
||||||
|
_pendingSingleContactRequests[_publicKeyToHex(publicKey)] =
|
||||||
|
_PendingContactRequest(source: source, requestedAt: DateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _prunePendingSingleContactRequests() {
|
||||||
|
if (_pendingSingleContactRequests.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
_pendingSingleContactRequests.removeWhere(
|
||||||
|
(_, request) =>
|
||||||
|
now.difference(request.requestedAt) > _singleContactRequestWindow,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _prunePreviewContactMisses() {
|
||||||
|
if (_previewContactMisses.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final now = DateTime.now();
|
||||||
|
_previewContactMisses.removeWhere(
|
||||||
|
(_, timestamp) => now.difference(timestamp) > _previewContactMissTtl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Sync all channels from device
|
/// Sync all channels from device
|
||||||
Future<void> syncChannels({int? maxChannels}) async {
|
Future<void> syncChannels({int? maxChannels}) async {
|
||||||
if (!_activeService.isConnected) {
|
if (!_activeService.isConnected) {
|
||||||
@@ -890,9 +1072,8 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not cached, query the device
|
// If not cached, query the device (awaits the BLE response)
|
||||||
await _activeService.getChannel(channelIdx);
|
await _activeService.getChannel(channelIdx);
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
|
||||||
|
|
||||||
// Check again after query
|
// Check again after query
|
||||||
if (getChannelInfo != null) {
|
if (getChannelInfo != null) {
|
||||||
@@ -1137,11 +1318,9 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
// behavior, then reconcile with an on-device refresh below.
|
// behavior, then reconcile with an on-device refresh below.
|
||||||
onChannelInfoReceived?.call(channelIdx, '', Uint8List(16), null);
|
onChannelInfoReceived?.call(channelIdx, '', Uint8List(16), null);
|
||||||
|
|
||||||
// Small delay to allow the response to propagate
|
// Refresh channel from device to reconcile UI state.
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
// BLE commands are processed in order by firmware, so the
|
||||||
|
// getChannel response reflects the completed deletion.
|
||||||
// Refresh channels to update UI
|
|
||||||
// The empty channel will trigger removal via onChannelInfoReceived callback
|
|
||||||
await _activeService.getChannel(channelIdx);
|
await _activeService.getChannel(channelIdx);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_pendingDeletedChannelIndices.remove(channelIdx);
|
_pendingDeletedChannelIndices.remove(channelIdx);
|
||||||
@@ -1230,12 +1409,11 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
'⚠️ [ConnectionProvider] Rate limit hit: $pendingCount pending ACKs (max 7)',
|
'⚠️ [ConnectionProvider] Rate limit hit: $pendingCount pending ACKs (max 7)',
|
||||||
);
|
);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ Firmware only tracks 8 ACKs - waiting for delivery confirmations...',
|
'⚠️ Firmware only tracks 8 ACKs - waiting for delivery confirmation...',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait briefly for some ACKs to arrive, then proceed anyway
|
// Wait for a slot to free up (delivery/timeout), max 500ms
|
||||||
// (User action shouldn't be blocked forever)
|
await _messageDeliveryTracker.waitForSlot();
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
|
|
||||||
if (_messageDeliveryTracker.shouldRateLimit) {
|
if (_messageDeliveryTracker.shouldRateLimit) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1311,30 +1489,14 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
attempt: retryAttempt,
|
attempt: retryAttempt,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (messageId != null) {
|
// Clear pending operation — any ERR_CODE_NOT_FOUND has already been
|
||||||
Future.delayed(const Duration(milliseconds: 350), () {
|
// handled synchronously by onContactNotFound before sendTextMessage returns.
|
||||||
if (_messageDeliveryTracker.hasAckForMessage(messageId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint(
|
|
||||||
'ℹ️ [ConnectionProvider] Missing RESP_CODE_SENT for $messageId; promoting to sent via fallback',
|
|
||||||
);
|
|
||||||
onMessageSent?.call(messageId, 0, 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear pending operation after successful send (no error)
|
|
||||||
// If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically
|
|
||||||
if (effectiveContact != null) {
|
if (effectiveContact != null) {
|
||||||
final operationId = contactPublicKey
|
final operationId = contactPublicKey
|
||||||
.sublist(0, 6)
|
.sublist(0, 6)
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
.join(':');
|
.join(':');
|
||||||
// Use a small delay to allow error response to arrive before clearing
|
_pendingSendOperations.remove(operationId);
|
||||||
Future.delayed(const Duration(milliseconds: 500), () {
|
|
||||||
_pendingSendOperations.remove(operationId);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -1394,10 +1556,6 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
debugPrint(' trackSentChannelMessage completed');
|
debugPrint(' trackSentChannelMessage completed');
|
||||||
|
|
||||||
// Small delay to ensure the message is in the MessagesProvider list
|
|
||||||
// before we try to mark it as sent
|
|
||||||
await Future.delayed(const Duration(milliseconds: 50));
|
|
||||||
|
|
||||||
// Use a dummy ACK tag (0) and timeout (0) for channel messages
|
// Use a dummy ACK tag (0) and timeout (0) for channel messages
|
||||||
// This will trigger the callback to mark the message as "sent"
|
// This will trigger the callback to mark the message as "sent"
|
||||||
debugPrint(' Calling onMessageSent callback...');
|
debugPrint(' Calling onMessageSent callback...');
|
||||||
@@ -1636,6 +1794,40 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> discoverNodeType({
|
||||||
|
required int advertType,
|
||||||
|
bool prefixOnly = false,
|
||||||
|
int since = 0,
|
||||||
|
}) async {
|
||||||
|
if (!_activeService.isConnected) {
|
||||||
|
_error = 'Not connected to device';
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final random = Random.secure();
|
||||||
|
final tagBytes = Uint8List.fromList(
|
||||||
|
List<int>.generate(4, (_) => random.nextInt(256)),
|
||||||
|
);
|
||||||
|
final payload = BytesBuilder(copy: false)
|
||||||
|
..addByte(_controlTypeNodeDiscoverReq | (prefixOnly ? 0x01 : 0x00))
|
||||||
|
..addByte(1 << advertType)
|
||||||
|
..add(tagBytes)
|
||||||
|
..add([
|
||||||
|
since & 0xFF,
|
||||||
|
(since >> 8) & 0xFF,
|
||||||
|
(since >> 16) & 0xFF,
|
||||||
|
(since >> 24) & 0xFF,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _activeService.sendControlData(payload.toBytes());
|
||||||
|
} catch (e) {
|
||||||
|
_error = 'Failed to send node discovery request: $e';
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get device time from companion radio to detect clock drift
|
/// Get device time from companion radio to detect clock drift
|
||||||
Future<void> getDeviceTime() async {
|
Future<void> getDeviceTime() async {
|
||||||
if (!_activeService.isConnected) {
|
if (!_activeService.isConnected) {
|
||||||
@@ -1866,12 +2058,96 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
advertLocationPolicy: advertLocationPolicy,
|
advertLocationPolicy: advertLocationPolicy,
|
||||||
multiAcks: multiAcks,
|
multiAcks: multiAcks,
|
||||||
);
|
);
|
||||||
|
_deviceInfo = _deviceInfo.copyWith(
|
||||||
|
telemetryModes: telemetryModes,
|
||||||
|
advertLocPolicy: advertLocationPolicy,
|
||||||
|
multiAcks: multiAcks,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = 'Failed to set other params: $e';
|
_error = 'Failed to set other params: $e';
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> getAutoaddConfig() async {
|
||||||
|
if (_supportsAutoaddConfig == false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_activeService.isConnected) {
|
||||||
|
_error = 'Not connected to device';
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final config = await _activeService.getAutoaddConfig();
|
||||||
|
_supportsAutoaddConfig = true;
|
||||||
|
_deviceInfo = _deviceInfo.copyWith(
|
||||||
|
autoAddUsers: config['autoAddUsers'] as bool?,
|
||||||
|
autoAddRepeaters: config['autoAddRepeaters'] as bool?,
|
||||||
|
autoAddRoomServers: config['autoAddRoomServers'] as bool?,
|
||||||
|
autoAddSensors: config['autoAddSensors'] as bool?,
|
||||||
|
autoAddOverwriteOldest: config['autoAddOverwriteOldest'] as bool?,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
if (_isUnsupportedAutoaddConfigError(e)) {
|
||||||
|
_supportsAutoaddConfig = false;
|
||||||
|
_deviceInfo = _deviceInfo.copyWith(
|
||||||
|
autoAddUsers: null,
|
||||||
|
autoAddRepeaters: null,
|
||||||
|
autoAddRoomServers: null,
|
||||||
|
autoAddSensors: null,
|
||||||
|
autoAddOverwriteOldest: null,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_isTransientAutoaddConfigError(e)) {
|
||||||
|
debugPrint('Ignoring transient auto-add config error: $e');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_error = 'Failed to get auto-add config: $e';
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setAutoaddConfig({
|
||||||
|
required bool autoAddUsers,
|
||||||
|
required bool autoAddRepeaters,
|
||||||
|
required bool autoAddRoomServers,
|
||||||
|
required bool autoAddSensors,
|
||||||
|
required bool overwriteOldest,
|
||||||
|
}) async {
|
||||||
|
if (!_activeService.isConnected) {
|
||||||
|
_error = 'Not connected to device';
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _activeService.setAutoaddConfig(
|
||||||
|
autoAddUsers: autoAddUsers,
|
||||||
|
autoAddRepeaters: autoAddRepeaters,
|
||||||
|
autoAddRoomServers: autoAddRoomServers,
|
||||||
|
autoAddSensors: autoAddSensors,
|
||||||
|
overwriteOldest: overwriteOldest,
|
||||||
|
);
|
||||||
|
_deviceInfo = _deviceInfo.copyWith(
|
||||||
|
autoAddUsers: autoAddUsers,
|
||||||
|
autoAddRepeaters: autoAddRepeaters,
|
||||||
|
autoAddRoomServers: autoAddRoomServers,
|
||||||
|
autoAddSensors: autoAddSensors,
|
||||||
|
autoAddOverwriteOldest: overwriteOldest,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
_error = 'Failed to set auto-add config: $e';
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Request fresh device info (triggers SelfInfo response)
|
/// Request fresh device info (triggers SelfInfo response)
|
||||||
Future<void> refreshDeviceInfo() async {
|
Future<void> refreshDeviceInfo() async {
|
||||||
if (_isSpectrumScanActive) return;
|
if (_isSpectrumScanActive) return;
|
||||||
@@ -1884,14 +2160,31 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
// The device query command triggers a SelfInfo response
|
// The device query command triggers a SelfInfo response
|
||||||
await _activeService.refreshDeviceInfo();
|
await _activeService.refreshDeviceInfo();
|
||||||
// Also request allowed repeat frequencies (firmware v9+, no-op on older firmware)
|
if (_supportsAutoaddConfig != false) {
|
||||||
await _activeService.getAllowedRepeatFreq();
|
unawaited(getAutoaddConfig());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await _activeService.getAllowedRepeatFreq();
|
||||||
|
} catch (_) {
|
||||||
|
// Older firmware may not expose repeat frequency ranges.
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = 'Failed to refresh device info: $e';
|
_error = 'Failed to refresh device info: $e';
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isUnsupportedAutoaddConfigError(Object error) {
|
||||||
|
final message = error.toString().toLowerCase();
|
||||||
|
return message.contains('unsupported command') ||
|
||||||
|
message.contains('unsupported');
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isTransientAutoaddConfigError(Object error) {
|
||||||
|
final message = error.toString().toLowerCase();
|
||||||
|
return message.contains('illegal argument');
|
||||||
|
}
|
||||||
|
|
||||||
/// Request battery and storage information
|
/// Request battery and storage information
|
||||||
///
|
///
|
||||||
/// Queries the companion radio for:
|
/// Queries the companion radio for:
|
||||||
@@ -2175,6 +2468,28 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<({int tag, int suggestedTimeoutMs})?> sendAnonRequest({
|
||||||
|
required Uint8List contactPublicKey,
|
||||||
|
required Uint8List requestData,
|
||||||
|
}) async {
|
||||||
|
if (!_activeService.isConnected) {
|
||||||
|
_error = 'Not connected to device';
|
||||||
|
notifyListeners();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await _activeService.sendAnonRequest(
|
||||||
|
contactPublicKey: contactPublicKey,
|
||||||
|
requestData: requestData,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_error = 'Failed to send anonymous request: $e';
|
||||||
|
notifyListeners();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Reset routing path for a contact
|
/// Reset routing path for a contact
|
||||||
///
|
///
|
||||||
/// Clears the learned path to a contact, forcing the next message to use
|
/// Clears the learned path to a contact, forcing the next message to use
|
||||||
@@ -2235,7 +2550,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
if (!_activeService.isConnected) {
|
if (!_activeService.isConnected) {
|
||||||
_error = 'Not connected to device';
|
_error = 'Not connected to device';
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return;
|
throw Exception(_error);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2243,6 +2558,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = 'Failed to remove contact: $e';
|
_error = 'Failed to remove contact: $e';
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2370,6 +2686,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_rxActivityTimer?.cancel();
|
_rxActivityTimer?.cancel();
|
||||||
_txActivityTimer?.cancel();
|
_txActivityTimer?.cancel();
|
||||||
|
_stopAckCleanupTimer();
|
||||||
_bleService.dispose();
|
_bleService.dispose();
|
||||||
_tcpService?.dispose();
|
_tcpService?.dispose();
|
||||||
_sseServer.stopServer();
|
_sseServer.stopServer();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
@@ -12,14 +13,40 @@ import '../utils/key_comparison.dart';
|
|||||||
class PendingAdvert {
|
class PendingAdvert {
|
||||||
final Uint8List publicKey;
|
final Uint8List publicKey;
|
||||||
final DateTime receivedAt;
|
final DateTime receivedAt;
|
||||||
|
final String? advName;
|
||||||
|
final int? typeValue;
|
||||||
|
final int? flags;
|
||||||
|
final int? lastAdvert;
|
||||||
|
final int? advLat;
|
||||||
|
final int? advLon;
|
||||||
final int? signedEncodedPathLen;
|
final int? signedEncodedPathLen;
|
||||||
final Uint8List? paddedPathBytes;
|
final Uint8List? paddedPathBytes;
|
||||||
|
final int? rxRssiDbm;
|
||||||
|
final int? rxSnrRaw;
|
||||||
|
final int? repeaterBatteryMv;
|
||||||
|
final int? repeaterQueueLen;
|
||||||
|
final int? repeaterLastRssi;
|
||||||
|
final int? repeaterLastSnrRaw;
|
||||||
|
final int? repeaterUptimeSecs;
|
||||||
|
|
||||||
const PendingAdvert({
|
const PendingAdvert({
|
||||||
required this.publicKey,
|
required this.publicKey,
|
||||||
required this.receivedAt,
|
required this.receivedAt,
|
||||||
|
this.advName,
|
||||||
|
this.typeValue,
|
||||||
|
this.flags,
|
||||||
|
this.lastAdvert,
|
||||||
|
this.advLat,
|
||||||
|
this.advLon,
|
||||||
this.signedEncodedPathLen,
|
this.signedEncodedPathLen,
|
||||||
this.paddedPathBytes,
|
this.paddedPathBytes,
|
||||||
|
this.rxRssiDbm,
|
||||||
|
this.rxSnrRaw,
|
||||||
|
this.repeaterBatteryMv,
|
||||||
|
this.repeaterQueueLen,
|
||||||
|
this.repeaterLastRssi,
|
||||||
|
this.repeaterLastSnrRaw,
|
||||||
|
this.repeaterUptimeSecs,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get publicKeyHex =>
|
String get publicKeyHex =>
|
||||||
@@ -33,16 +60,55 @@ class PendingAdvert {
|
|||||||
PendingAdvert copyWith({
|
PendingAdvert copyWith({
|
||||||
Uint8List? publicKey,
|
Uint8List? publicKey,
|
||||||
DateTime? receivedAt,
|
DateTime? receivedAt,
|
||||||
|
String? advName,
|
||||||
|
int? typeValue,
|
||||||
|
int? flags,
|
||||||
|
int? lastAdvert,
|
||||||
|
int? advLat,
|
||||||
|
int? advLon,
|
||||||
int? signedEncodedPathLen,
|
int? signedEncodedPathLen,
|
||||||
Uint8List? paddedPathBytes,
|
Uint8List? paddedPathBytes,
|
||||||
|
int? rxRssiDbm,
|
||||||
|
int? rxSnrRaw,
|
||||||
|
int? repeaterBatteryMv,
|
||||||
|
int? repeaterQueueLen,
|
||||||
|
int? repeaterLastRssi,
|
||||||
|
int? repeaterLastSnrRaw,
|
||||||
|
int? repeaterUptimeSecs,
|
||||||
}) {
|
}) {
|
||||||
return PendingAdvert(
|
return PendingAdvert(
|
||||||
publicKey: publicKey ?? this.publicKey,
|
publicKey: publicKey ?? this.publicKey,
|
||||||
receivedAt: receivedAt ?? this.receivedAt,
|
receivedAt: receivedAt ?? this.receivedAt,
|
||||||
|
advName: advName ?? this.advName,
|
||||||
|
typeValue: typeValue ?? this.typeValue,
|
||||||
|
flags: flags ?? this.flags,
|
||||||
|
lastAdvert: lastAdvert ?? this.lastAdvert,
|
||||||
|
advLat: advLat ?? this.advLat,
|
||||||
|
advLon: advLon ?? this.advLon,
|
||||||
signedEncodedPathLen: signedEncodedPathLen ?? this.signedEncodedPathLen,
|
signedEncodedPathLen: signedEncodedPathLen ?? this.signedEncodedPathLen,
|
||||||
paddedPathBytes: paddedPathBytes ?? this.paddedPathBytes,
|
paddedPathBytes: paddedPathBytes ?? this.paddedPathBytes,
|
||||||
|
rxRssiDbm: rxRssiDbm ?? this.rxRssiDbm,
|
||||||
|
rxSnrRaw: rxSnrRaw ?? this.rxSnrRaw,
|
||||||
|
repeaterBatteryMv: repeaterBatteryMv ?? this.repeaterBatteryMv,
|
||||||
|
repeaterQueueLen: repeaterQueueLen ?? this.repeaterQueueLen,
|
||||||
|
repeaterLastRssi: repeaterLastRssi ?? this.repeaterLastRssi,
|
||||||
|
repeaterLastSnrRaw: repeaterLastSnrRaw ?? this.repeaterLastSnrRaw,
|
||||||
|
repeaterUptimeSecs: repeaterUptimeSecs ?? this.repeaterUptimeSecs,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double? get repeaterBatteryPercent {
|
||||||
|
if (repeaterBatteryMv == null) return null;
|
||||||
|
final voltage = repeaterBatteryMv! / 1000.0;
|
||||||
|
if (voltage <= 3.0) return 0.0;
|
||||||
|
if (voltage >= 4.2) return 100.0;
|
||||||
|
return ((voltage - 3.0) / 1.2) * 100.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
double? get repeaterLastSnr =>
|
||||||
|
repeaterLastSnrRaw == null ? null : repeaterLastSnrRaw! / 4.0;
|
||||||
|
|
||||||
|
double? get rxSnr => rxSnrRaw == null ? null : rxSnrRaw! / 4.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RetainedRoute {
|
class _RetainedRoute {
|
||||||
@@ -64,6 +130,10 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
final Map<String, PendingAdvert> _pendingAdverts = {};
|
final Map<String, PendingAdvert> _pendingAdverts = {};
|
||||||
final ContactStorageService _storageService = ContactStorageService();
|
final ContactStorageService _storageService = ContactStorageService();
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
|
bool _isPersisting = false;
|
||||||
|
bool _persistRequested = false;
|
||||||
|
bool _isPersistingPendingAdverts = false;
|
||||||
|
bool _persistPendingAdvertsRequested = false;
|
||||||
|
|
||||||
// Add default public channel on initialization
|
// Add default public channel on initialization
|
||||||
ContactsProvider() {
|
ContactsProvider() {
|
||||||
@@ -83,6 +153,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
final storedContacts = await _storageService.loadContacts();
|
final storedContacts = await _storageService.loadContacts();
|
||||||
final storedGroups = await _storageService.loadContactGroups();
|
final storedGroups = await _storageService.loadContactGroups();
|
||||||
|
final storedPendingAdverts = await _storageService.loadPendingAdverts();
|
||||||
|
|
||||||
// Add stored contacts (excluding any with all-zeros public key)
|
// Add stored contacts (excluding any with all-zeros public key)
|
||||||
const publicChannelKey =
|
const publicChannelKey =
|
||||||
@@ -99,6 +170,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
_savedContactGroups
|
_savedContactGroups
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(storedGroups);
|
..addAll(storedGroups);
|
||||||
|
_restorePendingAdverts(storedPendingAdverts);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
|
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
|
||||||
);
|
);
|
||||||
@@ -131,6 +203,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
excludePublicKey: devicePublicKey,
|
excludePublicKey: devicePublicKey,
|
||||||
);
|
);
|
||||||
final storedGroups = await _storageService.loadContactGroups();
|
final storedGroups = await _storageService.loadContactGroups();
|
||||||
|
final storedPendingAdverts = await _storageService.loadPendingAdverts();
|
||||||
|
|
||||||
// Add stored contacts (excluding any with all-zeros public key)
|
// Add stored contacts (excluding any with all-zeros public key)
|
||||||
const publicChannelKey =
|
const publicChannelKey =
|
||||||
@@ -147,6 +220,10 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
_savedContactGroups
|
_savedContactGroups
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(storedGroups);
|
..addAll(storedGroups);
|
||||||
|
_restorePendingAdverts(
|
||||||
|
storedPendingAdverts,
|
||||||
|
devicePublicKey: devicePublicKey,
|
||||||
|
);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
|
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
|
||||||
);
|
);
|
||||||
@@ -202,19 +279,38 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist contacts to storage (async, non-blocking)
|
/// Persist contacts to storage (async, non-blocking, coalescing).
|
||||||
Future<void> _persistContacts() async {
|
Future<void> _persistContacts() async {
|
||||||
|
_persistRequested = true;
|
||||||
|
if (_isPersisting) return;
|
||||||
|
_isPersisting = true;
|
||||||
try {
|
try {
|
||||||
// Don't persist the public channel pseudo-contact (all zeros key)
|
while (_persistRequested) {
|
||||||
const publicChannelKey =
|
_persistRequested = false;
|
||||||
'0000000000000000000000000000000000000000000000000000000000000000';
|
await _storageService.saveContacts(_contactsForStorage());
|
||||||
final contactsToSave = _contacts.entries
|
}
|
||||||
.where((entry) => entry.key != publicChannelKey)
|
|
||||||
.map((entry) => entry.value)
|
|
||||||
.toList();
|
|
||||||
await _storageService.saveContacts(contactsToSave);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [ContactsProvider] Error persisting contacts: $e');
|
debugPrint('❌ [ContactsProvider] Error persisting contacts: $e');
|
||||||
|
} finally {
|
||||||
|
_isPersisting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistPendingAdverts() async {
|
||||||
|
_persistPendingAdvertsRequested = true;
|
||||||
|
if (_isPersistingPendingAdverts) return;
|
||||||
|
_isPersistingPendingAdverts = true;
|
||||||
|
try {
|
||||||
|
while (_persistPendingAdvertsRequested) {
|
||||||
|
_persistPendingAdvertsRequested = false;
|
||||||
|
await _storageService.savePendingAdverts(
|
||||||
|
_pendingAdverts.values.map(_pendingAdvertToJson).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactsProvider] Error persisting pending adverts: $e');
|
||||||
|
} finally {
|
||||||
|
_isPersistingPendingAdverts = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +322,28 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
_pendingAdverts.values.toList()
|
_pendingAdverts.values.toList()
|
||||||
..sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
..sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
||||||
|
|
||||||
|
PendingAdvert? pendingAdvertByKey(Uint8List publicKey) {
|
||||||
|
final keyHex = publicKey
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join('');
|
||||||
|
return _pendingAdverts[keyHex];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool shouldEnrichPendingAdvert(Uint8List publicKey) {
|
||||||
|
final advert = pendingAdvertByKey(publicKey);
|
||||||
|
if (advert == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final hasName = advert.advName?.trim().isNotEmpty ?? false;
|
||||||
|
final hasType = advert.typeValue != null && advert.typeValue != 0;
|
||||||
|
final hasLocation =
|
||||||
|
advert.advLat != null &&
|
||||||
|
advert.advLon != null &&
|
||||||
|
(advert.advLat != 0 || advert.advLon != 0);
|
||||||
|
return !(hasName && hasType && hasLocation);
|
||||||
|
}
|
||||||
|
|
||||||
List<SavedContactGroup> savedGroupsForSection(String sectionKey) {
|
List<SavedContactGroup> savedGroupsForSection(String sectionKey) {
|
||||||
return savedContactGroups
|
return savedContactGroups
|
||||||
.where((group) => group.sectionKey == sectionKey)
|
.where((group) => group.sectionKey == sectionKey)
|
||||||
@@ -435,11 +553,11 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
_contacts[contact.publicKeyHex] = updatedContact;
|
_contacts[contact.publicKeyHex] = updatedContact;
|
||||||
_pendingAdverts.remove(contact.publicKeyHex);
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}',
|
' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}',
|
||||||
);
|
);
|
||||||
_persistContacts();
|
_persistContacts();
|
||||||
|
_persistPendingAdverts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
debugPrint(' 🔔 notifyListeners() called');
|
debugPrint(' 🔔 notifyListeners() called');
|
||||||
}
|
}
|
||||||
@@ -463,7 +581,6 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
incomingContact: contact,
|
incomingContact: contact,
|
||||||
existingContact: existingContact,
|
existingContact: existingContact,
|
||||||
);
|
);
|
||||||
_pendingAdverts.remove(contact.publicKeyHex);
|
|
||||||
}
|
}
|
||||||
if (excluded > 0) {
|
if (excluded > 0) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -471,6 +588,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
_persistContacts();
|
_persistContacts();
|
||||||
|
_persistPendingAdverts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,7 +621,6 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
if (existingContact == null) {
|
if (existingContact == null) {
|
||||||
var newContact = incomingContact.copyWith(
|
var newContact = incomingContact.copyWith(
|
||||||
isNew: true,
|
isNew: true,
|
||||||
nameOverride: existingContact?.nameOverride,
|
|
||||||
telemetry: mergedTelemetry,
|
telemetry: mergedTelemetry,
|
||||||
outPathLen:
|
outPathLen:
|
||||||
retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen,
|
retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen,
|
||||||
@@ -908,7 +1025,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
for (final contact in contacts) {
|
for (final contact in _contacts.values) {
|
||||||
if (contact.publicKeyHex.startsWith(prefixHex)) {
|
if (contact.publicKeyHex.startsWith(prefixHex)) {
|
||||||
return contact;
|
return contact;
|
||||||
}
|
}
|
||||||
@@ -1063,39 +1180,132 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
|
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
|
||||||
/// Excludes self key and existing contacts.
|
/// Excludes only self key; known contacts still keep a discovery entry.
|
||||||
void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
|
bool addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
|
||||||
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
|
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final keyHex = publicKey
|
final keyHex = publicKey
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
.join('');
|
.join('');
|
||||||
if (_contacts.containsKey(keyHex)) {
|
|
||||||
_pendingAdverts.remove(keyHex);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final existing = _pendingAdverts[keyHex];
|
final existing = _pendingAdverts[keyHex];
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
_pendingAdverts[keyHex] = existing.copyWith(receivedAt: now);
|
_pendingAdverts[keyHex] = existing.copyWith(receivedAt: now);
|
||||||
|
_persistPendingAdverts();
|
||||||
|
notifyListeners();
|
||||||
|
return false;
|
||||||
} else {
|
} else {
|
||||||
_pendingAdverts[keyHex] = PendingAdvert(
|
_pendingAdverts[keyHex] = PendingAdvert(
|
||||||
publicKey: Uint8List.fromList(publicKey),
|
publicKey: Uint8List.fromList(publicKey),
|
||||||
receivedAt: now,
|
receivedAt: now,
|
||||||
);
|
);
|
||||||
|
_persistPendingAdverts();
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool addOrUpdatePendingAdvertContact(
|
||||||
|
Contact contact, {
|
||||||
|
Uint8List? devicePublicKey,
|
||||||
|
}) {
|
||||||
|
if (devicePublicKey != null && contact.publicKey.matches(devicePublicKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final keyHex = contact.publicKeyHex;
|
||||||
|
final route = ContactRouteCodec.fromContact(contact);
|
||||||
|
final existing = _pendingAdverts[keyHex];
|
||||||
|
final now = DateTime.now();
|
||||||
|
final updated =
|
||||||
|
(existing ??
|
||||||
|
PendingAdvert(
|
||||||
|
publicKey: Uint8List.fromList(contact.publicKey),
|
||||||
|
receivedAt: now,
|
||||||
|
))
|
||||||
|
.copyWith(
|
||||||
|
receivedAt: now,
|
||||||
|
advName: contact.advName.trim().isEmpty ? null : contact.advName,
|
||||||
|
typeValue: contact.type.value,
|
||||||
|
flags: contact.flags,
|
||||||
|
lastAdvert: contact.lastAdvert,
|
||||||
|
advLat: contact.advLat,
|
||||||
|
advLon: contact.advLon,
|
||||||
|
signedEncodedPathLen:
|
||||||
|
route?.signedEncodedPathLen ?? existing?.signedEncodedPathLen,
|
||||||
|
paddedPathBytes: route?.paddedPathBytes == null
|
||||||
|
? existing?.paddedPathBytes
|
||||||
|
: Uint8List.fromList(route!.paddedPathBytes),
|
||||||
|
);
|
||||||
|
|
||||||
|
_pendingAdverts[keyHex] = updated;
|
||||||
|
_persistPendingAdverts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
return existing == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool addOrUpdatePendingAdvertMetadata({
|
||||||
|
required Uint8List publicKey,
|
||||||
|
required int typeValue,
|
||||||
|
Uint8List? devicePublicKey,
|
||||||
|
int? flags,
|
||||||
|
String? advName,
|
||||||
|
int? lastAdvert,
|
||||||
|
int? advLat,
|
||||||
|
int? advLon,
|
||||||
|
int? signedEncodedPathLen,
|
||||||
|
Uint8List? paddedPathBytes,
|
||||||
|
int? rxRssiDbm,
|
||||||
|
int? rxSnrRaw,
|
||||||
|
DateTime? receivedAt,
|
||||||
|
}) {
|
||||||
|
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final keyHex = publicKey
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join('');
|
||||||
|
final existing = _pendingAdverts[keyHex];
|
||||||
|
final nextReceivedAt = receivedAt ?? DateTime.now();
|
||||||
|
_pendingAdverts[keyHex] =
|
||||||
|
(existing ??
|
||||||
|
PendingAdvert(
|
||||||
|
publicKey: Uint8List.fromList(publicKey),
|
||||||
|
receivedAt: nextReceivedAt,
|
||||||
|
))
|
||||||
|
.copyWith(
|
||||||
|
receivedAt: nextReceivedAt,
|
||||||
|
typeValue: typeValue,
|
||||||
|
advName: advName?.trim().isNotEmpty == true
|
||||||
|
? advName!.trim()
|
||||||
|
: existing?.advName,
|
||||||
|
flags: flags ?? existing?.flags,
|
||||||
|
lastAdvert: lastAdvert ?? existing?.lastAdvert,
|
||||||
|
advLat: advLat ?? existing?.advLat,
|
||||||
|
advLon: advLon ?? existing?.advLon,
|
||||||
|
signedEncodedPathLen:
|
||||||
|
signedEncodedPathLen ?? existing?.signedEncodedPathLen,
|
||||||
|
paddedPathBytes: paddedPathBytes == null
|
||||||
|
? existing?.paddedPathBytes
|
||||||
|
: Uint8List.fromList(paddedPathBytes),
|
||||||
|
rxRssiDbm: rxRssiDbm ?? existing?.rxRssiDbm,
|
||||||
|
rxSnrRaw: rxSnrRaw ?? existing?.rxSnrRaw,
|
||||||
|
);
|
||||||
|
|
||||||
|
_persistPendingAdverts();
|
||||||
|
notifyListeners();
|
||||||
|
return existing == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find contact by name
|
/// Find contact by name
|
||||||
Contact? findContactByName(String name) {
|
Contact? findContactByName(String name) {
|
||||||
return contacts.firstWhere(
|
for (final contact in _contacts.values) {
|
||||||
(c) => c.advName == name,
|
if (contact.advName == name) return contact;
|
||||||
orElse: () => contacts.first,
|
}
|
||||||
);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get contacts with low battery
|
/// Get contacts with low battery
|
||||||
@@ -1144,7 +1354,9 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
void clearContacts() {
|
void clearContacts() {
|
||||||
_contacts.clear();
|
_contacts.clear();
|
||||||
_pendingAdverts.clear();
|
_pendingAdverts.clear();
|
||||||
|
_ensurePublicChannelExists();
|
||||||
_persistContacts();
|
_persistContacts();
|
||||||
|
_persistPendingAdverts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1167,6 +1379,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
_contacts.remove(publicKeyHex);
|
_contacts.remove(publicKeyHex);
|
||||||
_pendingAdverts.remove(publicKeyHex);
|
_pendingAdverts.remove(publicKeyHex);
|
||||||
_persistContacts();
|
_persistContacts();
|
||||||
|
_persistPendingAdverts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1175,6 +1388,139 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
return await _storageService.getStorageStats();
|
return await _storageService.getStorageStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> persistNow() async {
|
||||||
|
await _storageService.saveContacts(_contactsForStorage());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearPendingAdverts() async {
|
||||||
|
if (_pendingAdverts.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_pendingAdverts.clear();
|
||||||
|
await _storageService.clearPendingAdverts();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void updatePendingAdvertStatusByPrefix(
|
||||||
|
Uint8List publicKeyPrefix, {
|
||||||
|
int? batteryMv,
|
||||||
|
int? queueLen,
|
||||||
|
int? lastRssi,
|
||||||
|
int? lastSnrRaw,
|
||||||
|
int? uptimeSecs,
|
||||||
|
}) {
|
||||||
|
final prefixHex = publicKeyPrefix
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join('');
|
||||||
|
final match = _pendingAdverts.entries.where(
|
||||||
|
(entry) => entry.key.startsWith(prefixHex),
|
||||||
|
);
|
||||||
|
if (match.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final key = match.first.key;
|
||||||
|
final existing = _pendingAdverts[key]!;
|
||||||
|
_pendingAdverts[key] = existing.copyWith(
|
||||||
|
repeaterBatteryMv: batteryMv,
|
||||||
|
repeaterQueueLen: queueLen,
|
||||||
|
repeaterLastRssi: lastRssi,
|
||||||
|
repeaterLastSnrRaw: lastSnrRaw,
|
||||||
|
repeaterUptimeSecs: uptimeSecs,
|
||||||
|
);
|
||||||
|
_persistPendingAdverts();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Contact> _contactsForStorage() {
|
||||||
|
// Don't persist the public channel pseudo-contact (all zeros key)
|
||||||
|
const publicChannelKey =
|
||||||
|
'0000000000000000000000000000000000000000000000000000000000000000';
|
||||||
|
return _contacts.entries
|
||||||
|
.where((entry) => entry.key != publicChannelKey)
|
||||||
|
.map((entry) => entry.value)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _restorePendingAdverts(
|
||||||
|
List<Map<String, dynamic>> storedPendingAdverts, {
|
||||||
|
Uint8List? devicePublicKey,
|
||||||
|
}) {
|
||||||
|
_pendingAdverts.clear();
|
||||||
|
for (final json in storedPendingAdverts) {
|
||||||
|
final advert = _pendingAdvertFromJson(json);
|
||||||
|
if (advert == null) continue;
|
||||||
|
if (devicePublicKey != null &&
|
||||||
|
advert.publicKey.matches(devicePublicKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (_contacts.containsKey(advert.publicKeyHex)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_pendingAdverts[advert.publicKeyHex] = advert;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _pendingAdvertToJson(PendingAdvert advert) {
|
||||||
|
return {
|
||||||
|
'publicKey': base64Encode(advert.publicKey),
|
||||||
|
'receivedAtMillis': advert.receivedAt.millisecondsSinceEpoch,
|
||||||
|
'advName': advert.advName,
|
||||||
|
'typeValue': advert.typeValue,
|
||||||
|
'flags': advert.flags,
|
||||||
|
'lastAdvert': advert.lastAdvert,
|
||||||
|
'advLat': advert.advLat,
|
||||||
|
'advLon': advert.advLon,
|
||||||
|
'signedEncodedPathLen': advert.signedEncodedPathLen,
|
||||||
|
'paddedPathBytes': advert.paddedPathBytes == null
|
||||||
|
? null
|
||||||
|
: base64Encode(advert.paddedPathBytes!),
|
||||||
|
'rxRssiDbm': advert.rxRssiDbm,
|
||||||
|
'rxSnrRaw': advert.rxSnrRaw,
|
||||||
|
'repeaterBatteryMv': advert.repeaterBatteryMv,
|
||||||
|
'repeaterQueueLen': advert.repeaterQueueLen,
|
||||||
|
'repeaterLastRssi': advert.repeaterLastRssi,
|
||||||
|
'repeaterLastSnrRaw': advert.repeaterLastSnrRaw,
|
||||||
|
'repeaterUptimeSecs': advert.repeaterUptimeSecs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
PendingAdvert? _pendingAdvertFromJson(Map<String, dynamic> json) {
|
||||||
|
try {
|
||||||
|
return PendingAdvert(
|
||||||
|
publicKey: Uint8List.fromList(
|
||||||
|
base64Decode(json['publicKey'] as String),
|
||||||
|
),
|
||||||
|
receivedAt: DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
json['receivedAtMillis'] as int,
|
||||||
|
),
|
||||||
|
advName: json['advName'] as String?,
|
||||||
|
typeValue: json['typeValue'] as int?,
|
||||||
|
flags: json['flags'] as int?,
|
||||||
|
lastAdvert: json['lastAdvert'] as int?,
|
||||||
|
advLat: json['advLat'] as int?,
|
||||||
|
advLon: json['advLon'] as int?,
|
||||||
|
signedEncodedPathLen: json['signedEncodedPathLen'] as int?,
|
||||||
|
paddedPathBytes: json['paddedPathBytes'] == null
|
||||||
|
? null
|
||||||
|
: Uint8List.fromList(
|
||||||
|
base64Decode(json['paddedPathBytes'] as String),
|
||||||
|
),
|
||||||
|
rxRssiDbm: json['rxRssiDbm'] as int?,
|
||||||
|
rxSnrRaw: json['rxSnrRaw'] as int?,
|
||||||
|
repeaterBatteryMv: json['repeaterBatteryMv'] as int?,
|
||||||
|
repeaterQueueLen: json['repeaterQueueLen'] as int?,
|
||||||
|
repeaterLastRssi: json['repeaterLastRssi'] as int?,
|
||||||
|
repeaterLastSnrRaw: json['repeaterLastSnrRaw'] as int?,
|
||||||
|
repeaterUptimeSecs: json['repeaterUptimeSecs'] as int?,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'❌ [ContactsProvider] Error parsing pending advert from JSON: $e',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get contact count by type
|
/// Get contact count by type
|
||||||
Map<String, int> get contactCounts {
|
Map<String, int> get contactCounts {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Completed drawings
|
// Completed drawings
|
||||||
final List<MapDrawing> _drawings = [];
|
final List<MapDrawing> _drawings = [];
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
// In-progress drawing
|
// In-progress drawing
|
||||||
MapDrawing? _currentDrawing;
|
MapDrawing? _currentDrawing;
|
||||||
@@ -53,6 +54,7 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
List<LatLng> get currentLinePoints => List.unmodifiable(_currentLinePoints);
|
List<LatLng> get currentLinePoints => List.unmodifiable(_currentLinePoints);
|
||||||
LatLng? get rectangleStartPoint => _rectangleStartPoint;
|
LatLng? get rectangleStartPoint => _rectangleStartPoint;
|
||||||
bool get isDrawing => _drawingMode != DrawingMode.none;
|
bool get isDrawing => _drawingMode != DrawingMode.none;
|
||||||
|
bool get isInitialized => _isInitialized;
|
||||||
LatLng? get measurementPoint1 => _measurementPoint1;
|
LatLng? get measurementPoint1 => _measurementPoint1;
|
||||||
LatLng? get measurementPoint2 => _measurementPoint2;
|
LatLng? get measurementPoint2 => _measurementPoint2;
|
||||||
double? get measuredDistance => _measuredDistance;
|
double? get measuredDistance => _measuredDistance;
|
||||||
@@ -61,6 +63,7 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
await _loadPreferences();
|
await _loadPreferences();
|
||||||
await _loadDrawings();
|
await _loadDrawings();
|
||||||
|
_isInitialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set drawing mode
|
/// Set drawing mode
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
/// Message delivery tracking helper
|
/// Message delivery tracking helper
|
||||||
@@ -30,6 +31,9 @@ class MessageDeliveryTracker {
|
|||||||
/// Map of ACK tag to timestamp for timeout cleanup
|
/// Map of ACK tag to timestamp for timeout cleanup
|
||||||
final Map<int, DateTime> _ackTagTimestamps = {};
|
final Map<int, DateTime> _ackTagTimestamps = {};
|
||||||
|
|
||||||
|
/// Completer signalled when a pending ACK slot is freed (delivery or removal).
|
||||||
|
Completer<void>? _slotFreedCompleter;
|
||||||
|
|
||||||
/// Track a pending message ID before sending
|
/// Track a pending message ID before sending
|
||||||
///
|
///
|
||||||
/// This is called BEFORE sending the message. When RESP_CODE_SENT
|
/// This is called BEFORE sending the message. When RESP_CODE_SENT
|
||||||
@@ -112,6 +116,7 @@ class MessageDeliveryTracker {
|
|||||||
_messageIdToAckTag.remove(messageId);
|
_messageIdToAckTag.remove(messageId);
|
||||||
}
|
}
|
||||||
_ackTagTimestamps.remove(ackCode);
|
_ackTagTimestamps.remove(ackCode);
|
||||||
|
_notifySlotFreed();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove ACK tag mapping by message ID
|
/// Remove ACK tag mapping by message ID
|
||||||
@@ -122,6 +127,7 @@ class MessageDeliveryTracker {
|
|||||||
if (ackTag != null) {
|
if (ackTag != null) {
|
||||||
_ackTagToMessageId.remove(ackTag);
|
_ackTagToMessageId.remove(ackTag);
|
||||||
_ackTagTimestamps.remove(ackTag);
|
_ackTagTimestamps.remove(ackTag);
|
||||||
|
_notifySlotFreed();
|
||||||
}
|
}
|
||||||
_pendingMessageIds.remove(messageId);
|
_pendingMessageIds.remove(messageId);
|
||||||
final emptyKeys = <String>[];
|
final emptyKeys = <String>[];
|
||||||
@@ -166,6 +172,7 @@ class MessageDeliveryTracker {
|
|||||||
_ackTagToMessageId.clear();
|
_ackTagToMessageId.clear();
|
||||||
_messageIdToAckTag.clear();
|
_messageIdToAckTag.clear();
|
||||||
_ackTagTimestamps.clear();
|
_ackTagTimestamps.clear();
|
||||||
|
_notifySlotFreed();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get count of pending ACK tags
|
/// Get count of pending ACK tags
|
||||||
@@ -179,6 +186,27 @@ class MessageDeliveryTracker {
|
|||||||
/// Returns true if >= 7 pending ACKs (stay under firmware limit of 8)
|
/// Returns true if >= 7 pending ACKs (stay under firmware limit of 8)
|
||||||
bool get shouldRateLimit => pendingCount >= 7;
|
bool get shouldRateLimit => pendingCount >= 7;
|
||||||
|
|
||||||
|
/// Wait until a pending ACK slot is freed, or [timeout] elapses.
|
||||||
|
///
|
||||||
|
/// Returns immediately if not at the rate limit.
|
||||||
|
Future<void> waitForSlot({
|
||||||
|
Duration timeout = const Duration(milliseconds: 500),
|
||||||
|
}) async {
|
||||||
|
if (!shouldRateLimit) return;
|
||||||
|
_slotFreedCompleter ??= Completer<void>();
|
||||||
|
await _slotFreedCompleter!.future.timeout(
|
||||||
|
timeout,
|
||||||
|
onTimeout: () {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notifySlotFreed() {
|
||||||
|
if (_slotFreedCompleter != null && !_slotFreedCompleter!.isCompleted) {
|
||||||
|
_slotFreedCompleter!.complete();
|
||||||
|
}
|
||||||
|
_slotFreedCompleter = null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Get oldest pending ACK timestamp (for debugging)
|
/// Get oldest pending ACK timestamp (for debugging)
|
||||||
DateTime? get oldestPendingTimestamp {
|
DateTime? get oldestPendingTimestamp {
|
||||||
if (_ackTagTimestamps.isEmpty) return null;
|
if (_ackTagTimestamps.isEmpty) return null;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import '../utils/image_message_parser.dart';
|
|||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import 'helpers/message_retry_manager.dart';
|
import 'helpers/message_retry_manager.dart';
|
||||||
|
|
||||||
|
typedef DisplayMessageEntry = ({Message message, int occurrenceCount});
|
||||||
|
|
||||||
/// Messages Provider - manages message history and SAR markers
|
/// Messages Provider - manages message history and SAR markers
|
||||||
class MessagesProvider with ChangeNotifier {
|
class MessagesProvider with ChangeNotifier {
|
||||||
static const Duration _channelEchoWarningDelay = Duration(seconds: 12);
|
static const Duration _channelEchoWarningDelay = Duration(seconds: 12);
|
||||||
@@ -28,6 +30,8 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final MessageStorageService _storageService = MessageStorageService();
|
final MessageStorageService _storageService = MessageStorageService();
|
||||||
final NotificationService _notificationService = NotificationService();
|
final NotificationService _notificationService = NotificationService();
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
|
bool _isPersisting = false;
|
||||||
|
bool _persistRequested = false;
|
||||||
AppLocalizations? _localizations;
|
AppLocalizations? _localizations;
|
||||||
final Map<String, MessageContactLocation> _messageContactLocations = {};
|
final Map<String, MessageContactLocation> _messageContactLocations = {};
|
||||||
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
|
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
|
||||||
@@ -549,6 +553,15 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final incomingPathBytes = receptionDetailsSnapshot?.pathBytes;
|
||||||
|
if (incomingPathBytes != null &&
|
||||||
|
incomingPathBytes.isNotEmpty &&
|
||||||
|
finalMessage.pathBytes == null) {
|
||||||
|
finalMessage = finalMessage.copyWith(
|
||||||
|
pathBytes: Uint8List.fromList(incomingPathBytes),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Debug: Check if message is SAR
|
// Debug: Check if message is SAR
|
||||||
if (message.text.startsWith('S:')) {
|
if (message.text.startsWith('S:')) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -580,8 +593,21 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
if (contactLocationSnapshot != null) {
|
if (contactLocationSnapshot != null) {
|
||||||
_messageContactLocations[existingId] = contactLocationSnapshot;
|
_messageContactLocations[existingId] = contactLocationSnapshot;
|
||||||
}
|
}
|
||||||
|
_messageReceptionDetails[existingId] =
|
||||||
|
MessageReceptionDetails.mergeDuplicate(
|
||||||
|
existing: _messageReceptionDetails[existingId],
|
||||||
|
incoming: receptionDetailsSnapshot,
|
||||||
|
);
|
||||||
if (receptionDetailsSnapshot != null) {
|
if (receptionDetailsSnapshot != null) {
|
||||||
_messageReceptionDetails[existingId] = receptionDetailsSnapshot;
|
final existingMessage = _messages[duplicateIndex];
|
||||||
|
final duplicatePathBytes = receptionDetailsSnapshot.pathBytes;
|
||||||
|
if (existingMessage.pathBytes == null &&
|
||||||
|
duplicatePathBytes != null &&
|
||||||
|
duplicatePathBytes.isNotEmpty) {
|
||||||
|
_messages[duplicateIndex] = existingMessage.copyWith(
|
||||||
|
pathBytes: Uint8List.fromList(duplicatePathBytes),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
return; // Skip duplicate
|
return; // Skip duplicate
|
||||||
@@ -692,7 +718,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
incomingSenderName != null &&
|
incomingSenderName != null &&
|
||||||
existingSenderName == incomingSenderName) {
|
existingSenderName == incomingSenderName) {
|
||||||
if (!existing.isSentMessage && !message.isSentMessage) {
|
if (!existing.isSentMessage && !message.isSentMessage) {
|
||||||
return true;
|
return withinChannelRepeatWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!withinChannelRepeatWindow) {
|
if (!withinChannelRepeatWindow) {
|
||||||
@@ -715,7 +741,8 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
// System messages and other types: never deduplicate by scope alone.
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add multiple messages
|
/// Add multiple messages
|
||||||
@@ -725,10 +752,42 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
for (final message in messages) {
|
for (final message in messages) {
|
||||||
// Always enhance message with SAR parser to detect SAR markers
|
// Always enhance message with SAR parser to detect SAR markers
|
||||||
final enhancedMessage = SarMessageParser.enhanceMessage(message);
|
var enhancedMessage = SarMessageParser.enhanceMessage(message);
|
||||||
|
|
||||||
|
// Check if it's a drawing message (D:...) and not already marked
|
||||||
|
if (DrawingMessageParser.isDrawingMessage(enhancedMessage.text) &&
|
||||||
|
!enhancedMessage.isDrawing) {
|
||||||
|
final drawing = DrawingMessageParser.parseDrawingMessage(
|
||||||
|
enhancedMessage.text,
|
||||||
|
senderName: enhancedMessage.senderName,
|
||||||
|
messageId: enhancedMessage.id,
|
||||||
|
);
|
||||||
|
enhancedMessage = enhancedMessage.copyWith(
|
||||||
|
isDrawing: true,
|
||||||
|
drawingId: drawing?.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a voice message and not already marked
|
||||||
|
if (!enhancedMessage.isVoice) {
|
||||||
|
final envelope = VoiceEnvelope.tryParseText(enhancedMessage.text);
|
||||||
|
if (envelope != null) {
|
||||||
|
enhancedMessage = enhancedMessage.copyWith(
|
||||||
|
isVoice: true,
|
||||||
|
voiceId: envelope.sessionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage);
|
||||||
|
|
||||||
// Check for duplicates
|
// Check for duplicates
|
||||||
if (_findDuplicateMessageIndex(enhancedMessage) != -1) {
|
final duplicateIndex = _findDuplicateMessageIndex(enhancedMessage);
|
||||||
|
if (duplicateIndex != -1) {
|
||||||
|
final existingId = _messages[duplicateIndex].id;
|
||||||
|
_messageReceptionDetails[existingId] =
|
||||||
|
MessageReceptionDetails.mergeDuplicate(
|
||||||
|
existing: _messageReceptionDetails[existingId],
|
||||||
|
);
|
||||||
duplicateCount++;
|
duplicateCount++;
|
||||||
continue; // Skip duplicate
|
continue; // Skip duplicate
|
||||||
}
|
}
|
||||||
@@ -973,18 +1032,31 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
return '${mib.toStringAsFixed(mib >= 10 ? 0 : 1)} MB';
|
return '${mib.toStringAsFixed(mib >= 10 ? 0 : 1)} MB';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist messages to storage (async, non-blocking)
|
/// Persist messages to storage (async, non-blocking, coalescing).
|
||||||
|
///
|
||||||
|
/// Multiple rapid calls are coalesced into a single write to avoid
|
||||||
|
/// overlapping serialization and redundant SharedPreferences writes.
|
||||||
Future<void> _persistMessages() async {
|
Future<void> _persistMessages() async {
|
||||||
|
_persistRequested = true;
|
||||||
|
if (_isPersisting) {
|
||||||
|
return; // A write is in flight; it will pick up our changes.
|
||||||
|
}
|
||||||
|
_isPersisting = true;
|
||||||
try {
|
try {
|
||||||
await _storageService.saveMessages(
|
while (_persistRequested) {
|
||||||
_messages,
|
_persistRequested = false;
|
||||||
messageContactLocations: _messageContactLocations,
|
await _storageService.saveMessages(
|
||||||
messageReceptionDetails: _messageReceptionDetails,
|
_messages,
|
||||||
messageTransferDetails: _messageTransferDetails,
|
messageContactLocations: _messageContactLocations,
|
||||||
messageRouteMetadata: _messageRouteMetadata,
|
messageReceptionDetails: _messageReceptionDetails,
|
||||||
);
|
messageTransferDetails: _messageTransferDetails,
|
||||||
|
messageRouteMetadata: _messageRouteMetadata,
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
|
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
|
||||||
|
} finally {
|
||||||
|
_isPersisting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1028,6 +1100,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
for (final messageId in messageIdsToRemove) {
|
for (final messageId in messageIdsToRemove) {
|
||||||
_timeoutTimers[messageId]?.cancel();
|
_timeoutTimers[messageId]?.cancel();
|
||||||
_timeoutTimers.remove(messageId);
|
_timeoutTimers.remove(messageId);
|
||||||
|
_clearChannelSendWarning(messageId);
|
||||||
_messageContactMap.remove(messageId);
|
_messageContactMap.remove(messageId);
|
||||||
_groupedMessageMapping.remove(messageId);
|
_groupedMessageMapping.remove(messageId);
|
||||||
_messageContactLocations.remove(messageId);
|
_messageContactLocations.remove(messageId);
|
||||||
@@ -1062,6 +1135,35 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
return sorted.take(count).toList();
|
return sorted.take(count).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<DisplayMessageEntry> buildDisplayMessages(Iterable<Message> messages) {
|
||||||
|
final entries = <DisplayMessageEntry>[];
|
||||||
|
|
||||||
|
for (final message in messages) {
|
||||||
|
final occurrenceCount = _messageOccurrenceCount(message);
|
||||||
|
final existingIndex = entries.indexWhere(
|
||||||
|
(entry) =>
|
||||||
|
entry.message.text == message.text &&
|
||||||
|
_matchesDuplicateScope(entry.message, message),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex == -1) {
|
||||||
|
entries.add((message: message, occurrenceCount: occurrenceCount));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final existingEntry = entries[existingIndex];
|
||||||
|
entries[existingIndex] = (
|
||||||
|
message: existingEntry.message,
|
||||||
|
occurrenceCount: existingEntry.occurrenceCount + occurrenceCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _messageOccurrenceCount(Message message) =>
|
||||||
|
_messageReceptionDetails[message.id]?.receivedCopies ?? 1;
|
||||||
|
|
||||||
/// Get messages from last N hours
|
/// Get messages from last N hours
|
||||||
List<Message> getMessagesSince(Duration duration) {
|
List<Message> getMessagesSince(Duration duration) {
|
||||||
final cutoff = DateTime.now().subtract(duration);
|
final cutoff = DateTime.now().subtract(duration);
|
||||||
@@ -1245,11 +1347,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Clear all messages
|
/// Clear all messages
|
||||||
void clearMessages() {
|
void clearMessages() {
|
||||||
for (final timer in _channelEchoWarningTimers.values) {
|
_cancelAllTimers();
|
||||||
timer.cancel();
|
|
||||||
}
|
|
||||||
_channelEchoWarningTimers.clear();
|
|
||||||
_channelEchoWarningMessageIds.clear();
|
|
||||||
_messages.clear();
|
_messages.clear();
|
||||||
_sarMarkers.clear();
|
_sarMarkers.clear();
|
||||||
_removedSarMarkerIds.clear();
|
_removedSarMarkerIds.clear();
|
||||||
@@ -1257,6 +1355,14 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_messageReceptionDetails.clear();
|
_messageReceptionDetails.clear();
|
||||||
_messageTransferDetails.clear();
|
_messageTransferDetails.clear();
|
||||||
_messageRouteMetadata.clear();
|
_messageRouteMetadata.clear();
|
||||||
|
_pendingSentMessages.clear();
|
||||||
|
_messageContactMap.clear();
|
||||||
|
_groupedMessageMapping.clear();
|
||||||
|
_ackTagToRecipients.clear();
|
||||||
|
_messageAckHistory.clear();
|
||||||
|
_ackHistoryLookup.clear();
|
||||||
|
_completedAckHistory.clear();
|
||||||
|
_retryManager.clearAll();
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
|
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -1272,11 +1378,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Clear all data
|
/// Clear all data
|
||||||
void clearAll() {
|
void clearAll() {
|
||||||
for (final timer in _channelEchoWarningTimers.values) {
|
_cancelAllTimers();
|
||||||
timer.cancel();
|
|
||||||
}
|
|
||||||
_channelEchoWarningTimers.clear();
|
|
||||||
_channelEchoWarningMessageIds.clear();
|
|
||||||
_messages.clear();
|
_messages.clear();
|
||||||
_sarMarkers.clear();
|
_sarMarkers.clear();
|
||||||
_removedSarMarkerIds.clear();
|
_removedSarMarkerIds.clear();
|
||||||
@@ -1284,6 +1386,14 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_messageReceptionDetails.clear();
|
_messageReceptionDetails.clear();
|
||||||
_messageTransferDetails.clear();
|
_messageTransferDetails.clear();
|
||||||
_messageRouteMetadata.clear();
|
_messageRouteMetadata.clear();
|
||||||
|
_pendingSentMessages.clear();
|
||||||
|
_messageContactMap.clear();
|
||||||
|
_groupedMessageMapping.clear();
|
||||||
|
_ackTagToRecipients.clear();
|
||||||
|
_messageAckHistory.clear();
|
||||||
|
_ackHistoryLookup.clear();
|
||||||
|
_completedAckHistory.clear();
|
||||||
|
_retryManager.clearAll();
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
|
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -1964,7 +2074,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
debugPrint(' Message index in list: $index');
|
debugPrint(' Message index in list: $index');
|
||||||
|
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
final updatedMessage = message.copyWith(
|
final updatedMessage = _messages[index].copyWith(
|
||||||
deliveryStatus: MessageDeliveryStatus.delivered,
|
deliveryStatus: MessageDeliveryStatus.delivered,
|
||||||
roundTripTimeMs: roundTripTimeMs,
|
roundTripTimeMs: roundTripTimeMs,
|
||||||
deliveredAt: DateTime.now(),
|
deliveredAt: DateTime.now(),
|
||||||
@@ -2323,6 +2433,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
senderPublicKeyPrefix: message.senderPublicKeyPrefix,
|
senderPublicKeyPrefix: message.senderPublicKeyPrefix,
|
||||||
channelIdx: message.channelIdx,
|
channelIdx: message.channelIdx,
|
||||||
pathLen: message.pathLen,
|
pathLen: message.pathLen,
|
||||||
|
pathBytes: message.pathBytes,
|
||||||
textType: message.textType,
|
textType: message.textType,
|
||||||
senderTimestamp: message.senderTimestamp,
|
senderTimestamp: message.senderTimestamp,
|
||||||
text: message.text,
|
text: message.text,
|
||||||
@@ -2410,7 +2521,19 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
// Cancel all pending timeout timers
|
_cancelAllTimers();
|
||||||
|
_completedAckHistory.clear();
|
||||||
|
_messageAckHistory.clear();
|
||||||
|
_ackHistoryLookup.clear();
|
||||||
|
|
||||||
|
// Clear retry manager
|
||||||
|
_retryManager.clearAll();
|
||||||
|
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel all pending timers (timeout + echo warning).
|
||||||
|
void _cancelAllTimers() {
|
||||||
for (final timer in _timeoutTimers.values) {
|
for (final timer in _timeoutTimers.values) {
|
||||||
timer.cancel();
|
timer.cancel();
|
||||||
}
|
}
|
||||||
@@ -2420,14 +2543,6 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
_channelEchoWarningTimers.clear();
|
_channelEchoWarningTimers.clear();
|
||||||
_channelEchoWarningMessageIds.clear();
|
_channelEchoWarningMessageIds.clear();
|
||||||
_completedAckHistory.clear();
|
|
||||||
_messageAckHistory.clear();
|
|
||||||
_ackHistoryLookup.clear();
|
|
||||||
|
|
||||||
// Clear retry manager
|
|
||||||
_retryManager.clearAll();
|
|
||||||
|
|
||||||
super.dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _rememberCompletedAck(int ackCode) {
|
void _rememberCompletedAck(int ackCode) {
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ class ContactsTab extends StatefulWidget {
|
|||||||
|
|
||||||
class _ContactsTabState extends State<ContactsTab> {
|
class _ContactsTabState extends State<ContactsTab> {
|
||||||
Position? _currentPosition;
|
Position? _currentPosition;
|
||||||
final Set<String> _resolvingAdvertKeys = <String>{};
|
|
||||||
bool _isResolvingPendingBatch = false;
|
|
||||||
final Map<ContactSection, String> _sectionFilters = {
|
final Map<ContactSection, String> _sectionFilters = {
|
||||||
ContactSection.teamMembers: '',
|
ContactSection.teamMembers: '',
|
||||||
ContactSection.repeaters: '',
|
ContactSection.repeaters: '',
|
||||||
@@ -99,57 +97,6 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
await _getCurrentLocation();
|
await _getCurrentLocation();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleResolveAdvert(PendingAdvert advert) async {
|
|
||||||
final keyHex = advert.publicKeyHex;
|
|
||||||
if (_resolvingAdvertKeys.contains(keyHex)) return;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_resolvingAdvertKeys.add(keyHex);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await context.read<ConnectionProvider>().getContact(advert.publicKey);
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_resolvingAdvertKeys.remove(keyHex);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _schedulePendingAdvertResolution(
|
|
||||||
List<PendingAdvert> pendingAdverts,
|
|
||||||
ConnectionProvider connectionProvider,
|
|
||||||
) {
|
|
||||||
if (_isResolvingPendingBatch ||
|
|
||||||
!connectionProvider.deviceInfo.isConnected ||
|
|
||||||
pendingAdverts.isEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final advertsToResolve = pendingAdverts
|
|
||||||
.where((advert) => !_resolvingAdvertKeys.contains(advert.publicKeyHex))
|
|
||||||
.toList();
|
|
||||||
if (advertsToResolve.isEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
||||||
if (!mounted || _isResolvingPendingBatch) return;
|
|
||||||
|
|
||||||
_isResolvingPendingBatch = true;
|
|
||||||
try {
|
|
||||||
for (final advert in advertsToResolve) {
|
|
||||||
if (!mounted) break;
|
|
||||||
await _handleResolveAdvert(advert);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
_isResolvingPendingBatch = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate distance between two points in meters
|
/// Calculate distance between two points in meters
|
||||||
double _calculateDistanceInMeters(
|
double _calculateDistanceInMeters(
|
||||||
double lat1,
|
double lat1,
|
||||||
@@ -181,15 +128,6 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatRelativeTime(BuildContext context, DateTime when) {
|
|
||||||
final l10n = AppLocalizations.of(context)!;
|
|
||||||
final diff = DateTime.now().difference(when);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Contact> _filterContactsForSection(
|
List<Contact> _filterContactsForSection(
|
||||||
List<Contact> contacts,
|
List<Contact> contacts,
|
||||||
ContactSection section,
|
ContactSection section,
|
||||||
@@ -593,7 +531,6 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
body: Consumer<ContactsProvider>(
|
body: Consumer<ContactsProvider>(
|
||||||
builder: (context, contactsProvider, child) {
|
builder: (context, contactsProvider, child) {
|
||||||
final messagesProvider = context.watch<MessagesProvider>();
|
final messagesProvider = context.watch<MessagesProvider>();
|
||||||
final connectionProvider = context.watch<ConnectionProvider>();
|
|
||||||
final allChatContacts = _sortContacts(
|
final allChatContacts = _sortContacts(
|
||||||
contactsProvider.chatContacts,
|
contactsProvider.chatContacts,
|
||||||
ContactSection.teamMembers,
|
ContactSection.teamMembers,
|
||||||
@@ -669,36 +606,16 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
_showSavedGroupsForSection(ContactSection.channels)
|
_showSavedGroupsForSection(ContactSection.channels)
|
||||||
? savedChannelGroups
|
? savedChannelGroups
|
||||||
: const <_RenderedSavedGroup>[];
|
: const <_RenderedSavedGroup>[];
|
||||||
final showTeamMembersSection =
|
final showTeamMembersSection = allChatContacts.isNotEmpty;
|
||||||
allChatContacts.isNotEmpty &&
|
final showRepeatersSection = allRepeaters.isNotEmpty;
|
||||||
(!_sectionHasActiveFilter(ContactSection.teamMembers) ||
|
final showRoomsSection = allRooms.isNotEmpty;
|
||||||
chatContacts.isNotEmpty ||
|
final showChannelsSection = allChannels.isNotEmpty;
|
||||||
visibleSavedTeamGroups.isNotEmpty);
|
|
||||||
final showRepeatersSection =
|
|
||||||
allRepeaters.isNotEmpty &&
|
|
||||||
(!_sectionHasActiveFilter(ContactSection.repeaters) ||
|
|
||||||
repeaters.isNotEmpty ||
|
|
||||||
visibleSavedRepeaterGroups.isNotEmpty);
|
|
||||||
final showRoomsSection =
|
|
||||||
allRooms.isNotEmpty &&
|
|
||||||
(!_sectionHasActiveFilter(ContactSection.rooms) ||
|
|
||||||
rooms.isNotEmpty ||
|
|
||||||
visibleSavedRoomGroups.isNotEmpty);
|
|
||||||
final showChannelsSection =
|
|
||||||
!_sectionHasActiveFilter(ContactSection.channels) ||
|
|
||||||
filteredChannels.isNotEmpty ||
|
|
||||||
visibleSavedChannelGroups.isNotEmpty;
|
|
||||||
final pendingAdverts = contactsProvider.pendingAdverts;
|
|
||||||
|
|
||||||
_schedulePendingAdvertResolution(pendingAdverts, connectionProvider);
|
|
||||||
|
|
||||||
// Check if there are any displayable contacts
|
// Check if there are any displayable contacts
|
||||||
final hasDisplayableContacts =
|
final hasDisplayableContacts =
|
||||||
allChatContacts.isNotEmpty ||
|
allChatContacts.isNotEmpty ||
|
||||||
allRepeaters.isNotEmpty ||
|
allRepeaters.isNotEmpty ||
|
||||||
allRooms.isNotEmpty ||
|
allRooms.isNotEmpty ||
|
||||||
allChannels.isNotEmpty ||
|
allChannels.isNotEmpty;
|
||||||
pendingAdverts.isNotEmpty;
|
|
||||||
|
|
||||||
if (!hasDisplayableContacts) {
|
if (!hasDisplayableContacts) {
|
||||||
return Center(
|
return Center(
|
||||||
@@ -761,12 +678,16 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
visibleSavedTeamGroups,
|
visibleSavedTeamGroups,
|
||||||
ContactSection.teamMembers,
|
ContactSection.teamMembers,
|
||||||
),
|
),
|
||||||
..._buildContactSectionItems(
|
if (chatContacts.isEmpty &&
|
||||||
_excludeGroupedContacts(
|
_sectionHasActiveFilter(ContactSection.teamMembers))
|
||||||
chatContacts,
|
_buildNoFilterResults(context)
|
||||||
visibleSavedTeamGroups,
|
else
|
||||||
|
..._buildContactSectionItems(
|
||||||
|
_excludeGroupedContacts(
|
||||||
|
chatContacts,
|
||||||
|
visibleSavedTeamGroups,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -799,7 +720,10 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
visibleSavedRepeaterGroups,
|
visibleSavedRepeaterGroups,
|
||||||
ContactSection.repeaters,
|
ContactSection.repeaters,
|
||||||
),
|
),
|
||||||
if (showRepeatersOthersGroup)
|
if (repeaters.isEmpty &&
|
||||||
|
_sectionHasActiveFilter(ContactSection.repeaters))
|
||||||
|
_buildNoFilterResults(context)
|
||||||
|
else if (showRepeatersOthersGroup)
|
||||||
_InferredContactGroupCard(
|
_InferredContactGroupCard(
|
||||||
label: 'Others',
|
label: 'Others',
|
||||||
contacts: ungroupedRepeaters,
|
contacts: ungroupedRepeaters,
|
||||||
@@ -836,30 +760,13 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
visibleSavedRoomGroups,
|
visibleSavedRoomGroups,
|
||||||
ContactSection.rooms,
|
ContactSection.rooms,
|
||||||
),
|
),
|
||||||
..._buildContactSectionItems(
|
if (rooms.isEmpty &&
|
||||||
_excludeGroupedContacts(rooms, visibleSavedRoomGroups),
|
_sectionHasActiveFilter(ContactSection.rooms))
|
||||||
),
|
_buildNoFilterResults(context)
|
||||||
const Divider(height: 32),
|
else
|
||||||
],
|
..._buildContactSectionItems(
|
||||||
|
_excludeGroupedContacts(rooms, visibleSavedRoomGroups),
|
||||||
// Pending adverts are kept below resolved sections while we load details.
|
|
||||||
if (pendingAdverts.isNotEmpty) ...[
|
|
||||||
_SectionHeader(
|
|
||||||
title: l10n.pending,
|
|
||||||
count: pendingAdverts.length,
|
|
||||||
icon: Icons.person_search,
|
|
||||||
),
|
|
||||||
...pendingAdverts.map(
|
|
||||||
(advert) => _PendingAdvertTile(
|
|
||||||
advert: advert,
|
|
||||||
subtitle:
|
|
||||||
'${l10n.publicKey}: ${advert.shortDisplayKey}\n${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
|
|
||||||
isResolving: _resolvingAdvertKeys.contains(
|
|
||||||
advert.publicKeyHex,
|
|
||||||
),
|
|
||||||
onResolve: () => _handleResolveAdvert(advert),
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -879,7 +786,10 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
visibleSavedChannelGroups,
|
visibleSavedChannelGroups,
|
||||||
ContactSection.channels,
|
ContactSection.channels,
|
||||||
),
|
),
|
||||||
if (filteredChannels.isNotEmpty) ...[
|
if (filteredChannels.isEmpty &&
|
||||||
|
_sectionHasActiveFilter(ContactSection.channels))
|
||||||
|
_buildNoFilterResults(context)
|
||||||
|
else ...[
|
||||||
..._excludeGroupedContacts(
|
..._excludeGroupedContacts(
|
||||||
filteredChannels,
|
filteredChannels,
|
||||||
visibleSavedChannelGroups,
|
visibleSavedChannelGroups,
|
||||||
@@ -921,6 +831,18 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildNoFilterResults(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
|
||||||
|
child: Text(
|
||||||
|
'No matches',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
List<Widget> _buildContactSectionItems(
|
List<Widget> _buildContactSectionItems(
|
||||||
List<Contact> contacts, {
|
List<Contact> contacts, {
|
||||||
bool compact = false,
|
bool compact = false,
|
||||||
@@ -1252,46 +1174,6 @@ enum ContactSortMode { lastSeen, distance }
|
|||||||
|
|
||||||
enum ContactSection { teamMembers, repeaters, rooms, channels }
|
enum ContactSection { teamMembers, repeaters, rooms, channels }
|
||||||
|
|
||||||
class _PendingAdvertTile extends StatelessWidget {
|
|
||||||
final PendingAdvert advert;
|
|
||||||
final String subtitle;
|
|
||||||
final bool isResolving;
|
|
||||||
final VoidCallback onResolve;
|
|
||||||
|
|
||||||
const _PendingAdvertTile({
|
|
||||||
required this.advert,
|
|
||||||
required this.subtitle,
|
|
||||||
required this.isResolving,
|
|
||||||
required this.onResolve,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: ListTile(
|
|
||||||
leading: const CircleAvatar(child: Icon(Icons.campaign_outlined)),
|
|
||||||
title: Text(
|
|
||||||
advert.shortDisplayKey,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
subtitle: Text(subtitle),
|
|
||||||
trailing: isResolving
|
|
||||||
? const SizedBox(
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: IconButton(
|
|
||||||
icon: const Icon(Icons.person_add_alt_1),
|
|
||||||
tooltip: 'Resolve contact',
|
|
||||||
onPressed: onResolve,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RenderedSavedGroup {
|
class _RenderedSavedGroup {
|
||||||
final SavedContactGroup group;
|
final SavedContactGroup group;
|
||||||
final List<Contact> contacts;
|
final List<Contact> contacts;
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/device_info.dart';
|
import '../models/device_info.dart';
|
||||||
|
import '../models/channel.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import '../providers/channels_provider.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
|
import '../providers/contacts_provider.dart';
|
||||||
import '../services/validation_service.dart';
|
import '../services/validation_service.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -14,6 +21,11 @@ class DeviceConfigScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||||
|
static const int _bulkDeleteBatchSize = 8;
|
||||||
|
static const Duration _bulkDeleteInterItemDelay = Duration(milliseconds: 120);
|
||||||
|
static const Duration _bulkDeleteBatchDelay = Duration(milliseconds: 700);
|
||||||
|
static const Duration _bulkDeleteFinalSyncDelay = Duration(milliseconds: 900);
|
||||||
|
static const int _autoAddFilterModeFlag = 1;
|
||||||
static const List<_RadioPreset> _radioPresets = [
|
static const List<_RadioPreset> _radioPresets = [
|
||||||
_RadioPreset(
|
_RadioPreset(
|
||||||
id: 'australia',
|
id: 'australia',
|
||||||
@@ -166,16 +178,30 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
late TextEditingController _lonController;
|
late TextEditingController _lonController;
|
||||||
late TextEditingController _freqController;
|
late TextEditingController _freqController;
|
||||||
late TextEditingController _txPowerController;
|
late TextEditingController _txPowerController;
|
||||||
|
late final ConnectionProvider _connectionProvider;
|
||||||
|
|
||||||
bool _telemetryEnabled = false;
|
bool _telemetryEnabled = false;
|
||||||
bool _repeatEnabled = false;
|
bool _repeatEnabled = false;
|
||||||
|
bool _autoAddDiscoveredContactsEnabled = true;
|
||||||
|
bool _autoAddUsersEnabled = true;
|
||||||
|
bool _autoAddRepeatersEnabled = true;
|
||||||
|
bool _autoAddRoomServersEnabled = true;
|
||||||
|
bool _autoAddSensorsEnabled = true;
|
||||||
|
bool _overwriteOldestAutoAddEnabled = false;
|
||||||
bool _showCustomRadioSettings = false;
|
bool _showCustomRadioSettings = false;
|
||||||
bool _isSavingPublicInfo = false;
|
bool _isSavingPublicInfo = false;
|
||||||
bool _isSavingRadioSettings = false;
|
bool _isSavingRadioSettings = false;
|
||||||
|
bool _isSavingAutoDiscoverySettings = false;
|
||||||
|
bool _isClearingContacts = false;
|
||||||
|
bool _isClearingChannels = false;
|
||||||
bool _publicInfoSaved = false;
|
bool _publicInfoSaved = false;
|
||||||
bool _radioSettingsSaved = false;
|
bool _radioSettingsSaved = false;
|
||||||
|
bool _autoDiscoverySettingsSaved = false;
|
||||||
|
bool _autoDiscoverySettingsDirty = false;
|
||||||
|
String? _lastAutoDiscoverySignature;
|
||||||
String? _publicInfoError;
|
String? _publicInfoError;
|
||||||
String? _radioSettingsError;
|
String? _radioSettingsError;
|
||||||
|
String? _autoDiscoverySettingsError;
|
||||||
String _selectedBandwidth = '62.5 kHz';
|
String _selectedBandwidth = '62.5 kHz';
|
||||||
int _selectedSpreadingFactor = 8;
|
int _selectedSpreadingFactor = 8;
|
||||||
int _selectedCodingRate = 8;
|
int _selectedCodingRate = 8;
|
||||||
@@ -197,7 +223,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final deviceInfo = context.read<ConnectionProvider>().deviceInfo;
|
_connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
_connectionProvider.addListener(_handleConnectionProviderChanged);
|
||||||
|
final deviceInfo = _connectionProvider.deviceInfo;
|
||||||
|
|
||||||
_nameController = TextEditingController(
|
_nameController = TextEditingController(
|
||||||
text: deviceInfo.selfName ?? deviceInfo.deviceName ?? '',
|
text: deviceInfo.selfName ?? deviceInfo.deviceName ?? '',
|
||||||
@@ -252,6 +280,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
|
|
||||||
// Initialize repeat mode from device info (firmware v9+)
|
// Initialize repeat mode from device info (firmware v9+)
|
||||||
_repeatEnabled = deviceInfo.clientRepeat ?? false;
|
_repeatEnabled = deviceInfo.clientRepeat ?? false;
|
||||||
|
_syncAutoDiscoveryState(deviceInfo);
|
||||||
|
_lastAutoDiscoverySignature = _autoDiscoverySignature(deviceInfo);
|
||||||
|
|
||||||
// Fetch allowed repeat frequencies on open if device supports repeat mode
|
// Fetch allowed repeat frequencies on open if device supports repeat mode
|
||||||
if (deviceInfo.clientRepeat != null &&
|
if (deviceInfo.clientRepeat != null &&
|
||||||
@@ -262,12 +292,14 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.read<ConnectionProvider>().getBatteryAndStorage();
|
_connectionProvider.getBatteryAndStorage();
|
||||||
|
unawaited(_connectionProvider.getAutoaddConfig());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_connectionProvider.removeListener(_handleConnectionProviderChanged);
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
_latController.dispose();
|
_latController.dispose();
|
||||||
_lonController.dispose();
|
_lonController.dispose();
|
||||||
@@ -375,9 +407,83 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _markAutoDiscoverySettingsDirty() {
|
||||||
|
_autoDiscoverySettingsDirty = true;
|
||||||
|
if (_autoDiscoverySettingsSaved || _autoDiscoverySettingsError != null) {
|
||||||
|
setState(() {
|
||||||
|
_autoDiscoverySettingsSaved = false;
|
||||||
|
_autoDiscoverySettingsError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _autoDiscoverySignature(DeviceInfo deviceInfo) {
|
||||||
|
return [
|
||||||
|
deviceInfo.manualAddContacts,
|
||||||
|
deviceInfo.autoAddUsers,
|
||||||
|
deviceInfo.autoAddRepeaters,
|
||||||
|
deviceInfo.autoAddRoomServers,
|
||||||
|
deviceInfo.autoAddSensors,
|
||||||
|
deviceInfo.autoAddOverwriteOldest,
|
||||||
|
].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleConnectionProviderChanged() {
|
||||||
|
if (!mounted || _autoDiscoverySettingsDirty) return;
|
||||||
|
|
||||||
|
final deviceInfo = _connectionProvider.deviceInfo;
|
||||||
|
final nextSignature = _autoDiscoverySignature(deviceInfo);
|
||||||
|
if (nextSignature == _lastAutoDiscoverySignature) return;
|
||||||
|
|
||||||
|
_lastAutoDiscoverySignature = nextSignature;
|
||||||
|
setState(() {
|
||||||
|
_syncAutoDiscoveryState(deviceInfo);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _hasAutoAddTargetsEnabled(DeviceInfo deviceInfo) {
|
||||||
|
return (deviceInfo.autoAddUsers ?? false) ||
|
||||||
|
(deviceInfo.autoAddRepeaters ?? false) ||
|
||||||
|
(deviceInfo.autoAddRoomServers ?? false) ||
|
||||||
|
(deviceInfo.autoAddSensors ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncAutoDiscoveryState(DeviceInfo deviceInfo) {
|
||||||
|
final hasFetchedAutoAddConfig =
|
||||||
|
deviceInfo.autoAddUsers != null ||
|
||||||
|
deviceInfo.autoAddRepeaters != null ||
|
||||||
|
deviceInfo.autoAddRoomServers != null ||
|
||||||
|
deviceInfo.autoAddSensors != null ||
|
||||||
|
deviceInfo.autoAddOverwriteOldest != null;
|
||||||
|
|
||||||
|
_autoAddDiscoveredContactsEnabled = hasFetchedAutoAddConfig
|
||||||
|
? _hasAutoAddTargetsEnabled(deviceInfo)
|
||||||
|
: !(deviceInfo.manualAddContacts ?? false);
|
||||||
|
_autoAddUsersEnabled = deviceInfo.autoAddUsers ?? true;
|
||||||
|
_autoAddRepeatersEnabled = deviceInfo.autoAddRepeaters ?? true;
|
||||||
|
_autoAddRoomServersEnabled = deviceInfo.autoAddRoomServers ?? true;
|
||||||
|
_autoAddSensorsEnabled = deviceInfo.autoAddSensors ?? true;
|
||||||
|
_overwriteOldestAutoAddEnabled = deviceInfo.autoAddOverwriteOldest ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _telemetryModesForSave(ConnectionProvider connectionProvider) {
|
||||||
|
final deviceInfo = connectionProvider.deviceInfo;
|
||||||
|
final telemetryEnabled =
|
||||||
|
(deviceInfo.advLat != null && deviceInfo.advLat != 0) ||
|
||||||
|
(deviceInfo.advLon != null && deviceInfo.advLon != 0);
|
||||||
|
return deviceInfo.telemetryModes ?? (telemetryEnabled ? 0x0A : 0x00);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _advertLocationPolicyForSave(ConnectionProvider connectionProvider) {
|
||||||
|
final deviceInfo = connectionProvider.deviceInfo;
|
||||||
|
final telemetryEnabled =
|
||||||
|
(deviceInfo.advLat != null && deviceInfo.advLat != 0) ||
|
||||||
|
(deviceInfo.advLon != null && deviceInfo.advLon != 0);
|
||||||
|
return deviceInfo.advertLocPolicy ?? (telemetryEnabled ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _savePublicInfo() async {
|
Future<void> _savePublicInfo() async {
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
final deviceInfo = connectionProvider.deviceInfo;
|
|
||||||
final validator = ValidationService();
|
final validator = ValidationService();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -425,7 +531,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
// Set telemetry modes to "Allow All" (mode 2 for both base and location)
|
// Set telemetry modes to "Allow All" (mode 2 for both base and location)
|
||||||
final telemetryModes = 0x0A; // binary: 00001010 (base=2, location=2)
|
final telemetryModes = 0x0A; // binary: 00001010 (base=2, location=2)
|
||||||
await connectionProvider.setOtherParams(
|
await connectionProvider.setOtherParams(
|
||||||
manualAddContacts: deviceInfo.manualAddContacts == true ? 1 : 0,
|
manualAddContacts: _autoAddFilterModeFlag,
|
||||||
telemetryModes: telemetryModes,
|
telemetryModes: telemetryModes,
|
||||||
advertLocationPolicy: 1,
|
advertLocationPolicy: 1,
|
||||||
);
|
);
|
||||||
@@ -436,7 +542,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
// Set telemetry modes to "Deny" (mode 0)
|
// Set telemetry modes to "Deny" (mode 0)
|
||||||
final telemetryModes = 0x00;
|
final telemetryModes = 0x00;
|
||||||
await connectionProvider.setOtherParams(
|
await connectionProvider.setOtherParams(
|
||||||
manualAddContacts: deviceInfo.manualAddContacts == true ? 1 : 0,
|
manualAddContacts: _autoAddFilterModeFlag,
|
||||||
telemetryModes: telemetryModes,
|
telemetryModes: telemetryModes,
|
||||||
advertLocationPolicy: 0,
|
advertLocationPolicy: 0,
|
||||||
);
|
);
|
||||||
@@ -537,6 +643,64 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _saveAutoDiscoverySettings() async {
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final autoAddUsers =
|
||||||
|
_autoAddDiscoveredContactsEnabled && _autoAddUsersEnabled;
|
||||||
|
final autoAddRepeaters =
|
||||||
|
_autoAddDiscoveredContactsEnabled && _autoAddRepeatersEnabled;
|
||||||
|
final autoAddRoomServers =
|
||||||
|
_autoAddDiscoveredContactsEnabled && _autoAddRoomServersEnabled;
|
||||||
|
final autoAddSensors =
|
||||||
|
_autoAddDiscoveredContactsEnabled && _autoAddSensorsEnabled;
|
||||||
|
final overwriteOldest =
|
||||||
|
_autoAddDiscoveredContactsEnabled && _overwriteOldestAutoAddEnabled;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSavingAutoDiscoverySettings = true;
|
||||||
|
_autoDiscoverySettingsSaved = false;
|
||||||
|
_autoDiscoverySettingsError = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connectionProvider.setAutoaddConfig(
|
||||||
|
autoAddUsers: autoAddUsers,
|
||||||
|
autoAddRepeaters: autoAddRepeaters,
|
||||||
|
autoAddRoomServers: autoAddRoomServers,
|
||||||
|
autoAddSensors: autoAddSensors,
|
||||||
|
overwriteOldest: overwriteOldest,
|
||||||
|
);
|
||||||
|
await connectionProvider.setOtherParams(
|
||||||
|
manualAddContacts: _autoAddFilterModeFlag,
|
||||||
|
telemetryModes: _telemetryModesForSave(connectionProvider),
|
||||||
|
advertLocationPolicy: _advertLocationPolicyForSave(connectionProvider),
|
||||||
|
multiAcks: connectionProvider.deviceInfo.multiAcks ?? 0,
|
||||||
|
);
|
||||||
|
await connectionProvider.getAutoaddConfig();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_syncAutoDiscoveryState(connectionProvider.deviceInfo);
|
||||||
|
_lastAutoDiscoverySignature = _autoDiscoverySignature(
|
||||||
|
connectionProvider.deviceInfo,
|
||||||
|
);
|
||||||
|
_isSavingAutoDiscoverySettings = false;
|
||||||
|
_autoDiscoverySettingsSaved = true;
|
||||||
|
_autoDiscoverySettingsDirty = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isSavingAutoDiscoverySettings = false;
|
||||||
|
_autoDiscoverySettingsError = AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.failedToSave(e.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _useCurrentLocation() async {
|
Future<void> _useCurrentLocation() async {
|
||||||
try {
|
try {
|
||||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||||
@@ -687,6 +851,205 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmClearAllContacts() async {
|
||||||
|
final contacts = context
|
||||||
|
.read<ContactsProvider>()
|
||||||
|
.contacts
|
||||||
|
.where((contact) => !contact.isChannel)
|
||||||
|
.toList();
|
||||||
|
if (contacts.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('No device contacts to clear.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('Clear all contacts'),
|
||||||
|
content: Text(
|
||||||
|
'This will remove ${contacts.length} contact${contacts.length == 1 ? '' : 's'} from the connected device. Channels and radio settings will not be changed.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: Text(AppLocalizations.of(context)!.cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
child: const Text('Clear contacts'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
connectionProvider.clearError();
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isClearingContacts = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
var processed = 0;
|
||||||
|
for (final contact in List<Contact>.from(contacts)) {
|
||||||
|
await contactsProvider.removeContact(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
onRemoveFromDevice: connectionProvider.removeContact,
|
||||||
|
);
|
||||||
|
processed++;
|
||||||
|
await Future.delayed(_bulkDeleteInterItemDelay);
|
||||||
|
if (processed % _bulkDeleteBatchSize == 0) {
|
||||||
|
await Future.delayed(_bulkDeleteBatchDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush the updated local contact set to storage immediately.
|
||||||
|
await contactsProvider.persistNow();
|
||||||
|
await Future.delayed(_bulkDeleteFinalSyncDelay);
|
||||||
|
await connectionProvider.getContacts();
|
||||||
|
if (connectionProvider.error != null) {
|
||||||
|
throw Exception(connectionProvider.error!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Cleared ${contacts.length} contact${contacts.length == 1 ? '' : 's'} from the device.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to clear contacts: $e'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isClearingContacts = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmClearAllChannels() async {
|
||||||
|
final channels = context
|
||||||
|
.read<ChannelsProvider>()
|
||||||
|
.channels
|
||||||
|
.where((channel) => !channel.isPublicChannel && channel.name.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (channels.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('No custom channels to clear.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('Clear all channels'),
|
||||||
|
content: Text(
|
||||||
|
'This will remove ${channels.length} custom channel${channels.length == 1 ? '' : 's'} from the connected device. Contacts and radio settings will not be changed.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: Text(AppLocalizations.of(context)!.cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
child: const Text('Clear channels'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final channelsProvider = context.read<ChannelsProvider>();
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
connectionProvider.clearError();
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isClearingChannels = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
var processed = 0;
|
||||||
|
for (final channel in List<Channel>.from(channels)) {
|
||||||
|
await connectionProvider.deleteChannel(channel.index);
|
||||||
|
processed++;
|
||||||
|
await Future.delayed(_bulkDeleteInterItemDelay);
|
||||||
|
if (processed % _bulkDeleteBatchSize == 0) {
|
||||||
|
await Future.delayed(_bulkDeleteBatchDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force local channel/contact cache cleanup before the device resync.
|
||||||
|
for (final channel in channels) {
|
||||||
|
channelsProvider.removeChannel(channel.index);
|
||||||
|
final publicKeyBytes = Uint8List(32);
|
||||||
|
publicKeyBytes[0] = 0xFF;
|
||||||
|
publicKeyBytes[1] = channel.index;
|
||||||
|
final publicKeyHex = publicKeyBytes
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join('');
|
||||||
|
await contactsProvider.removeContact(publicKeyHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
await contactsProvider.persistNow();
|
||||||
|
await Future.delayed(_bulkDeleteFinalSyncDelay);
|
||||||
|
await connectionProvider.syncChannels();
|
||||||
|
if (connectionProvider.error != null) {
|
||||||
|
throw Exception(connectionProvider.error!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Cleared ${channels.length} custom channel${channels.length == 1 ? '' : 's'} from the device.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to clear channels: $e'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isClearingChannels = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
|
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
|
||||||
@@ -781,6 +1144,164 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
_ConfigSectionCard(
|
||||||
|
title: 'Auto discovery',
|
||||||
|
subtitle:
|
||||||
|
'Control how the radio auto-adds discovered nodes to its contacts table.',
|
||||||
|
icon: Icons.person_search_rounded,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_SettingHighlightCard(
|
||||||
|
icon: _autoAddDiscoveredContactsEnabled
|
||||||
|
? Icons.person_add_alt_1
|
||||||
|
: Icons.person_add_disabled,
|
||||||
|
title: 'Enable automatic adding',
|
||||||
|
description:
|
||||||
|
'Turn this off to keep discoveries manual-only on the radio.',
|
||||||
|
accentColor: _autoAddDiscoveredContactsEnabled
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
trailing: Switch(
|
||||||
|
value: _autoAddDiscoveredContactsEnabled,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_autoAddDiscoveredContactsEnabled = value;
|
||||||
|
_markAutoDiscoverySettingsDirty();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_SettingHighlightCard(
|
||||||
|
icon: Icons.person_outline_rounded,
|
||||||
|
title: 'Auto-add users',
|
||||||
|
description:
|
||||||
|
'Automatically store discovered user/chat nodes.',
|
||||||
|
accentColor: _autoAddUsersEnabled
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
trailing: Switch(
|
||||||
|
value: _autoAddUsersEnabled,
|
||||||
|
onChanged: _autoAddDiscoveredContactsEnabled
|
||||||
|
? (value) {
|
||||||
|
setState(() {
|
||||||
|
_autoAddUsersEnabled = value;
|
||||||
|
_markAutoDiscoverySettingsDirty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_SettingHighlightCard(
|
||||||
|
icon: Icons.router_outlined,
|
||||||
|
title: 'Auto-add repeaters',
|
||||||
|
description:
|
||||||
|
'Automatically store discovered repeater nodes.',
|
||||||
|
accentColor: _autoAddRepeatersEnabled
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
trailing: Switch(
|
||||||
|
value: _autoAddRepeatersEnabled,
|
||||||
|
onChanged: _autoAddDiscoveredContactsEnabled
|
||||||
|
? (value) {
|
||||||
|
setState(() {
|
||||||
|
_autoAddRepeatersEnabled = value;
|
||||||
|
_markAutoDiscoverySettingsDirty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_SettingHighlightCard(
|
||||||
|
icon: Icons.meeting_room_outlined,
|
||||||
|
title: 'Auto-add room servers',
|
||||||
|
description:
|
||||||
|
'Automatically store discovered room/server nodes.',
|
||||||
|
accentColor: _autoAddRoomServersEnabled
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
trailing: Switch(
|
||||||
|
value: _autoAddRoomServersEnabled,
|
||||||
|
onChanged: _autoAddDiscoveredContactsEnabled
|
||||||
|
? (value) {
|
||||||
|
setState(() {
|
||||||
|
_autoAddRoomServersEnabled = value;
|
||||||
|
_markAutoDiscoverySettingsDirty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_SettingHighlightCard(
|
||||||
|
icon: Icons.sensors_outlined,
|
||||||
|
title: 'Auto-add sensors',
|
||||||
|
description:
|
||||||
|
'Automatically store discovered sensor nodes.',
|
||||||
|
accentColor: _autoAddSensorsEnabled
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
trailing: Switch(
|
||||||
|
value: _autoAddSensorsEnabled,
|
||||||
|
onChanged: _autoAddDiscoveredContactsEnabled
|
||||||
|
? (value) {
|
||||||
|
setState(() {
|
||||||
|
_autoAddSensorsEnabled = value;
|
||||||
|
_markAutoDiscoverySettingsDirty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_SettingHighlightCard(
|
||||||
|
icon: Icons.history_toggle_off_rounded,
|
||||||
|
title: 'Overwrite oldest when full',
|
||||||
|
description:
|
||||||
|
'Allow the radio to replace the oldest contact when storage is full.',
|
||||||
|
accentColor: _overwriteOldestAutoAddEnabled
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
trailing: Switch(
|
||||||
|
value: _overwriteOldestAutoAddEnabled,
|
||||||
|
onChanged: _autoAddDiscoveredContactsEnabled
|
||||||
|
? (value) {
|
||||||
|
setState(() {
|
||||||
|
_overwriteOldestAutoAddEnabled = value;
|
||||||
|
_markAutoDiscoverySettingsDirty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
if (_autoDiscoverySettingsError != null) ...[
|
||||||
|
Text(
|
||||||
|
_autoDiscoverySettingsError!,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: _SaveActionButton(
|
||||||
|
onPressed: _isSavingAutoDiscoverySettings
|
||||||
|
? null
|
||||||
|
: _saveAutoDiscoverySettings,
|
||||||
|
isSaving: _isSavingAutoDiscoverySettings,
|
||||||
|
isSaved: _autoDiscoverySettingsSaved,
|
||||||
|
label: 'Save discovery settings',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
_ConfigSectionCard(
|
_ConfigSectionCard(
|
||||||
title: AppLocalizations.of(context)!.publicInfo,
|
title: AppLocalizations.of(context)!.publicInfo,
|
||||||
subtitle: 'Choose the name and location this device shares.',
|
subtitle: 'Choose the name and location this device shares.',
|
||||||
@@ -1230,10 +1751,60 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _isClearingContacts || _isClearingChannels
|
||||||
|
? null
|
||||||
|
: _confirmClearAllContacts,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: colorScheme.error,
|
||||||
|
side: BorderSide(color: colorScheme.error),
|
||||||
|
minimumSize: const Size.fromHeight(52),
|
||||||
|
),
|
||||||
|
icon: _isClearingContacts
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.people_alt_outlined),
|
||||||
|
label: const Text('Clear all contacts'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _isClearingContacts || _isClearingChannels
|
||||||
|
? null
|
||||||
|
: _confirmClearAllChannels,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: colorScheme.error,
|
||||||
|
side: BorderSide(color: colorScheme.error),
|
||||||
|
minimumSize: const Size.fromHeight(52),
|
||||||
|
),
|
||||||
|
icon: _isClearingChannels
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.forum_outlined),
|
||||||
|
label: const Text('Clear all channels'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: _confirmFactoryReset,
|
onPressed: _isClearingContacts || _isClearingChannels
|
||||||
|
? null
|
||||||
|
: _confirmFactoryReset,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: colorScheme.error,
|
backgroundColor: colorScheme.error,
|
||||||
foregroundColor: colorScheme.onError,
|
foregroundColor: colorScheme.onError,
|
||||||
|
|||||||
660
lib/screens/discovery_screen.dart
Normal file
660
lib/screens/discovery_screen.dart
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import '../providers/connection_provider.dart';
|
||||||
|
import '../providers/contacts_provider.dart';
|
||||||
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
|
import '../widgets/compact_signal_indicator.dart' show SignalMetric;
|
||||||
|
|
||||||
|
enum _DiscoveryMenuAction { repeaters, sensors }
|
||||||
|
|
||||||
|
class DiscoveryScreen extends StatefulWidget {
|
||||||
|
const DiscoveryScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DiscoveryScreen> createState() => _DiscoveryScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||||
|
static const int _repeaterAdvertType = 2;
|
||||||
|
static const int _sensorAdvertType = 4;
|
||||||
|
final Set<String> _resolvingAdvertKeys = <String>{};
|
||||||
|
final Set<int> _runningDiscoveryTypes = <int>{};
|
||||||
|
bool _isResolvingAll = false;
|
||||||
|
late final Future<List<MeshMapNode>> _cachedNodesFuture;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_cachedNodesFuture = MeshMapNodesService.loadCachedNodes(
|
||||||
|
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleMenuAction(_DiscoveryMenuAction action) async {
|
||||||
|
switch (action) {
|
||||||
|
case _DiscoveryMenuAction.repeaters:
|
||||||
|
await _discoverNodeType(_repeaterAdvertType);
|
||||||
|
break;
|
||||||
|
case _DiscoveryMenuAction.sensors:
|
||||||
|
await _discoverNodeType(_sensorAdvertType);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clearAllDiscoveries() async {
|
||||||
|
final pendingCount = context.read<ContactsProvider>().pendingAdverts.length;
|
||||||
|
if (pendingCount == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('Clear discoveries'),
|
||||||
|
content: Text(
|
||||||
|
'Remove all $pendingCount pending discover${pendingCount == 1 ? 'y' : 'ies'} from this device?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
child: const Text('Clear all'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.read<ContactsProvider>().clearPendingAdverts();
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Cleared pending discoveries.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _discoverNodeType(int advertType) async {
|
||||||
|
if (_runningDiscoveryTypes.contains(advertType)) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_runningDiscoveryTypes.add(advertType);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await context.read<ConnectionProvider>().discoverNodeType(
|
||||||
|
advertType: advertType,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
final label = switch (advertType) {
|
||||||
|
_repeaterAdvertType => 'Repeater discovery sent',
|
||||||
|
_sensorAdvertType => 'Sensor discovery sent',
|
||||||
|
_ => 'Discovery sent',
|
||||||
|
};
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(label)));
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_runningDiscoveryTypes.remove(advertType);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _resolveAdvert(PendingAdvert advert) async {
|
||||||
|
final keyHex = advert.publicKeyHex;
|
||||||
|
if (_resolvingAdvertKeys.contains(keyHex)) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_resolvingAdvertKeys.add(keyHex);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
|
||||||
|
await connectionProvider.getContact(advert.publicKey);
|
||||||
|
if (connectionProvider.error == 'Not found') {
|
||||||
|
connectionProvider.clearError();
|
||||||
|
final fallbackContact = _contactFromPendingAdvert(advert);
|
||||||
|
await connectionProvider.addOrUpdateContact(fallbackContact);
|
||||||
|
contactsProvider.addOrUpdateContact(
|
||||||
|
fallbackContact,
|
||||||
|
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ((advert.typeValue ?? 0) == _sensorAdvertType) {
|
||||||
|
await connectionProvider.requestTelemetry(advert.publicKey);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_resolvingAdvertKeys.remove(keyHex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _resolveAll(List<PendingAdvert> adverts) async {
|
||||||
|
if (_isResolvingAll || adverts.isEmpty) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isResolvingAll = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (final advert in adverts) {
|
||||||
|
if (!mounted) break;
|
||||||
|
await _resolveAdvert(advert);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isResolvingAll = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatRelativeTime(BuildContext context, DateTime when) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final diff = DateTime.now().difference(when);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact _contactFromPendingAdvert(PendingAdvert advert) {
|
||||||
|
final lastAdvert =
|
||||||
|
advert.lastAdvert ?? (advert.receivedAt.millisecondsSinceEpoch ~/ 1000);
|
||||||
|
final advName = advert.advName?.trim().isNotEmpty == true
|
||||||
|
? advert.advName!.trim()
|
||||||
|
: advert.shortDisplayKey;
|
||||||
|
|
||||||
|
return Contact(
|
||||||
|
publicKey: Uint8List.fromList(advert.publicKey),
|
||||||
|
type: ContactType.fromValue(advert.typeValue ?? 0),
|
||||||
|
flags: advert.flags ?? 0,
|
||||||
|
outPathLen: advert.signedEncodedPathLen ?? -1,
|
||||||
|
outPath: advert.paddedPathBytes == null
|
||||||
|
? Uint8List(64)
|
||||||
|
: Uint8List.fromList(advert.paddedPathBytes!),
|
||||||
|
advName: advName,
|
||||||
|
lastAdvert: lastAdvert,
|
||||||
|
advLat: advert.advLat ?? 0,
|
||||||
|
advLon: advert.advLon ?? 0,
|
||||||
|
lastMod: lastAdvert,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _displayNameForAdvert(
|
||||||
|
PendingAdvert advert,
|
||||||
|
ContactsProvider contactsProvider,
|
||||||
|
List<MeshMapNode> cachedNodes,
|
||||||
|
) {
|
||||||
|
final advertisedName = advert.advName?.trim();
|
||||||
|
if (advertisedName != null && advertisedName.isNotEmpty) {
|
||||||
|
return advertisedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact? existingMatch;
|
||||||
|
for (final contact in contactsProvider.contacts) {
|
||||||
|
if (contact.publicKeyHex == advert.publicKeyHex) {
|
||||||
|
existingMatch = contact;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (existingMatch != null && existingMatch.displayName.trim().isNotEmpty) {
|
||||||
|
return existingMatch.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
MeshMapNode? cachedMatch;
|
||||||
|
for (final node in cachedNodes) {
|
||||||
|
if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
|
||||||
|
cachedMatch = node;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cachedMatch != null && cachedMatch.name.trim().isNotEmpty) {
|
||||||
|
return cachedMatch.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return advert.shortDisplayKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _iconForAdvert(PendingAdvert advert) {
|
||||||
|
return switch (advert.typeValue) {
|
||||||
|
_repeaterAdvertType => Icons.router_outlined,
|
||||||
|
_sensorAdvertType => Icons.sensors_outlined,
|
||||||
|
_ => Icons.campaign_outlined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _typeLabelForAdvert(PendingAdvert advert) {
|
||||||
|
return switch (advert.typeValue) {
|
||||||
|
_repeaterAdvertType => 'Repeater',
|
||||||
|
_sensorAdvertType => 'Sensor',
|
||||||
|
3 => 'Room',
|
||||||
|
1 => 'Chat',
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _resolvedTypeLabelForAdvert(
|
||||||
|
PendingAdvert advert,
|
||||||
|
List<MeshMapNode> cachedNodes,
|
||||||
|
) {
|
||||||
|
final directType = _typeLabelForAdvert(advert);
|
||||||
|
if (directType != null) {
|
||||||
|
return directType;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final node in cachedNodes) {
|
||||||
|
if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
|
||||||
|
return switch (node.type) {
|
||||||
|
1 => 'Repeater',
|
||||||
|
4 => 'Sensor',
|
||||||
|
3 => 'Room',
|
||||||
|
2 => 'Chat',
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAdvertTitle(
|
||||||
|
BuildContext context, {
|
||||||
|
required String displayName,
|
||||||
|
String? subtitle,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
displayName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
if (subtitle != null && subtitle.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Discovery'),
|
||||||
|
actions: [
|
||||||
|
Consumer<ConnectionProvider>(
|
||||||
|
builder: (context, connectionProvider, child) {
|
||||||
|
final isConnected = connectionProvider.deviceInfo.isConnected;
|
||||||
|
final repeatersBusy = _runningDiscoveryTypes.contains(
|
||||||
|
_repeaterAdvertType,
|
||||||
|
);
|
||||||
|
final sensorsBusy = _runningDiscoveryTypes.contains(
|
||||||
|
_sensorAdvertType,
|
||||||
|
);
|
||||||
|
|
||||||
|
return PopupMenuButton<_DiscoveryMenuAction>(
|
||||||
|
tooltip: 'Discovery tools',
|
||||||
|
onSelected: _handleMenuAction,
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem<_DiscoveryMenuAction>(
|
||||||
|
value: _DiscoveryMenuAction.repeaters,
|
||||||
|
enabled: isConnected && !repeatersBusy,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
repeatersBusy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.router_outlined),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text('Discover repeaters'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem<_DiscoveryMenuAction>(
|
||||||
|
value: _DiscoveryMenuAction.sensors,
|
||||||
|
enabled: isConnected && !sensorsBusy,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
sensorsBusy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.sensors_outlined),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text('Discover sensors'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: FutureBuilder<List<MeshMapNode>>(
|
||||||
|
future: _cachedNodesFuture,
|
||||||
|
builder: (context, nodesSnapshot) =>
|
||||||
|
Consumer2<ContactsProvider, ConnectionProvider>(
|
||||||
|
builder: (context, contactsProvider, connectionProvider, child) {
|
||||||
|
final pendingAdverts = contactsProvider.pendingAdverts;
|
||||||
|
final isConnected = connectionProvider.deviceInfo.isConnected;
|
||||||
|
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.person_search),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Pending discoveries (${pendingAdverts.length})',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Resolve entries manually so they do not auto-populate contacts.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: isConnected &&
|
||||||
|
pendingAdverts.isNotEmpty &&
|
||||||
|
!_isResolvingAll
|
||||||
|
? () => _resolveAll(pendingAdverts)
|
||||||
|
: null,
|
||||||
|
icon: _isResolvingAll
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(
|
||||||
|
Icons.download_for_offline_outlined,
|
||||||
|
),
|
||||||
|
label: const Text('Resolve all'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: pendingAdverts.isNotEmpty
|
||||||
|
? _clearAllDiscoveries
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.clear_all_rounded),
|
||||||
|
label: const Text('Clear all'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (pendingAdverts.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.person_search_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Theme.of(context).disabledColor,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No pending discoveries',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Unknown adverts will appear here until you choose to resolve them.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...pendingAdverts.map((advert) {
|
||||||
|
final isResolving = _resolvingAdvertKeys.contains(
|
||||||
|
advert.publicKeyHex,
|
||||||
|
);
|
||||||
|
final displayName = _displayNameForAdvert(
|
||||||
|
advert,
|
||||||
|
contactsProvider,
|
||||||
|
cachedNodes,
|
||||||
|
);
|
||||||
|
final typeLabel = _resolvedTypeLabelForAdvert(
|
||||||
|
advert,
|
||||||
|
cachedNodes,
|
||||||
|
);
|
||||||
|
final downMetric = SignalMetric.fromValues(
|
||||||
|
rssiDbm: advert.rxRssiDbm,
|
||||||
|
snrDb: advert.rxSnr,
|
||||||
|
);
|
||||||
|
final upMetric = SignalMetric.fromValues(
|
||||||
|
rssiDbm: advert.repeaterLastRssi,
|
||||||
|
snrDb: advert.repeaterLastSnr,
|
||||||
|
);
|
||||||
|
final detailLines = <String>[
|
||||||
|
'${l10n.publicKey}: ${advert.shortDisplayKey}',
|
||||||
|
];
|
||||||
|
final summaryParts = <String>[];
|
||||||
|
final battery = advert.repeaterBatteryPercent;
|
||||||
|
if (battery != null) {
|
||||||
|
summaryParts.add('Battery ${battery.round()}%');
|
||||||
|
}
|
||||||
|
if (advert.repeaterQueueLen != null) {
|
||||||
|
summaryParts.add('Queue ${advert.repeaterQueueLen}');
|
||||||
|
}
|
||||||
|
if (summaryParts.isNotEmpty) {
|
||||||
|
detailLines.add(summaryParts.join(' • '));
|
||||||
|
}
|
||||||
|
detailLines.add(
|
||||||
|
'${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
|
||||||
|
);
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
child: Icon(_iconForAdvert(advert)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildAdvertTitle(
|
||||||
|
context,
|
||||||
|
displayName: displayName,
|
||||||
|
subtitle: typeLabel,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (downMetric != null || upMetric != null) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_buildSignalSummary(
|
||||||
|
context,
|
||||||
|
downMetric: downMetric,
|
||||||
|
upMetric: upMetric,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
isResolving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.person_add_alt_1,
|
||||||
|
),
|
||||||
|
tooltip: 'Resolve contact',
|
||||||
|
onPressed: isConnected
|
||||||
|
? () => _resolveAdvert(advert)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
detailLines.join('\n'),
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSignalSummary(
|
||||||
|
BuildContext context, {
|
||||||
|
SignalMetric? downMetric,
|
||||||
|
SignalMetric? upMetric,
|
||||||
|
}) {
|
||||||
|
if (downMetric == null && upMetric == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
if (downMetric != null)
|
||||||
|
_buildDirectionalSignal(
|
||||||
|
context,
|
||||||
|
icon: Icons.south_west_rounded,
|
||||||
|
metric: downMetric,
|
||||||
|
),
|
||||||
|
if (downMetric != null && upMetric != null) const SizedBox(height: 6),
|
||||||
|
if (upMetric != null)
|
||||||
|
_buildDirectionalSignal(
|
||||||
|
context,
|
||||||
|
icon: Icons.north_east_rounded,
|
||||||
|
metric: upMetric,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDirectionalSignal(
|
||||||
|
BuildContext context, {
|
||||||
|
required IconData icon,
|
||||||
|
required SignalMetric metric,
|
||||||
|
}) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 11,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
_buildMiniSignalBars(context, metric),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
metric.valueLabel,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMiniSignalBars(BuildContext context, SignalMetric metric) {
|
||||||
|
final inactive = Theme.of(context).colorScheme.outlineVariant;
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
for (var index = 0; index < 3; index++) ...[
|
||||||
|
if (index > 0) const SizedBox(width: 2),
|
||||||
|
Container(
|
||||||
|
width: 3,
|
||||||
|
height: 6.0 + (index * 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: index < metric.activeBars ? metric.color : inactive,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import '../providers/contacts_provider.dart';
|
|||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import 'messages_tab.dart';
|
import 'messages_tab.dart';
|
||||||
import 'contacts_tab.dart';
|
import 'contacts_tab.dart';
|
||||||
|
import 'discovery_screen.dart';
|
||||||
import 'sensors_tab.dart';
|
import 'sensors_tab.dart';
|
||||||
import 'map_tab.dart';
|
import 'map_tab.dart';
|
||||||
import 'repeaters_map_screen.dart';
|
import 'repeaters_map_screen.dart';
|
||||||
@@ -712,6 +713,39 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
PopupMenuItem(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.person_search),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Consumer<ContactsProvider>(
|
||||||
|
builder: (context, contactsProvider, child) {
|
||||||
|
final pendingCount =
|
||||||
|
contactsProvider.pendingAdverts.length;
|
||||||
|
return Text(
|
||||||
|
pendingCount > 0
|
||||||
|
? 'Discovery ($pendingCount)'
|
||||||
|
: 'Discovery',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
Future.delayed(Duration.zero, () {
|
||||||
|
if (!mounted) return;
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const DiscoveryScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import '../services/live_traffic_summary.dart';
|
|||||||
import '../services/location_tracking_service.dart';
|
import '../services/location_tracking_service.dart';
|
||||||
import '../services/route_hash_preferences.dart';
|
import '../services/route_hash_preferences.dart';
|
||||||
import '../utils/log_rx_route_decoder.dart';
|
import '../utils/log_rx_route_decoder.dart';
|
||||||
|
import '../widgets/compact_signal_indicator.dart';
|
||||||
|
import '../widgets/messages/message_trace_sheet.dart';
|
||||||
import 'packet_log_screen.dart';
|
import 'packet_log_screen.dart';
|
||||||
|
|
||||||
T? _maybeProvider<T>(BuildContext context) {
|
T? _maybeProvider<T>(BuildContext context) {
|
||||||
@@ -134,11 +136,12 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
|
|||||||
final routeHashCounts = _RouteHashCounts.fromContacts(
|
final routeHashCounts = _RouteHashCounts.fromContacts(
|
||||||
contactsProvider?.contacts ?? const <Contact>[],
|
contactsProvider?.contacts ?? const <Contact>[],
|
||||||
);
|
);
|
||||||
final packetTypes = unfilteredSnapshot.visibleEntries
|
final packetTypes =
|
||||||
.map((entry) => entry.payloadLabel)
|
unfilteredSnapshot.visibleEntries
|
||||||
.toSet()
|
.map((entry) => entry.payloadLabel)
|
||||||
.toList()
|
.toSet()
|
||||||
..sort();
|
.toList()
|
||||||
|
..sort();
|
||||||
final filteredEntries = snapshot.visibleEntries;
|
final filteredEntries = snapshot.visibleEntries;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -202,7 +205,9 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.radar_rounded,
|
Icons.radar_rounded,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: theme.colorScheme.primary.withValues(alpha: 0.45),
|
color: theme.colorScheme.primary.withValues(
|
||||||
|
alpha: 0.45,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
const Text(
|
||||||
@@ -218,7 +223,9 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
|
|||||||
? 'This view only shows in-memory traffic while it is active.'
|
? 'This view only shows in-memory traffic while it is active.'
|
||||||
: 'Try a different packet type or switch back to All.',
|
: 'Try a different packet type or switch back to All.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -488,7 +495,9 @@ class _FilterChip extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color.withValues(alpha: selected ? 0.14 : 0.08),
|
color: color.withValues(alpha: selected ? 0.14 : 0.08),
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
border: Border.all(color: color.withValues(alpha: selected ? 0.45 : 0.22)),
|
border: Border.all(
|
||||||
|
color: color.withValues(alpha: selected ? 0.45 : 0.22),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
@@ -641,103 +650,103 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
final isRx = log.direction == PacketDirection.rx;
|
final isRx = log.direction == PacketDirection.rx;
|
||||||
final accent = isRx ? Colors.green : Colors.blue;
|
final accent = isRx ? Colors.green : Colors.blue;
|
||||||
final rxInfo = log.logRxDataInfo;
|
final rxInfo = log.logRxDataInfo;
|
||||||
final routePreview = _resolvedRoutePreview(context, entry);
|
|
||||||
final originDistance = _originDistanceLabel(context, entry);
|
final originDistance = _originDistanceLabel(context, entry);
|
||||||
|
final packetDetails = _LiveTrafficPacketDetails.fromEntry(entry);
|
||||||
|
final signalMetric = SignalMetric.fromRxInfo(rxInfo);
|
||||||
|
|
||||||
return Container(
|
return Material(
|
||||||
padding: const EdgeInsets.all(14),
|
color: Colors.transparent,
|
||||||
decoration: BoxDecoration(
|
child: InkWell(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
onTap: () => _showPacketBytesSheet(context, log.rawData),
|
||||||
|
onLongPress: () => _showTraceSheet(context, entry),
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
border: Border.all(color: accent.withValues(alpha: 0.25)),
|
child: Container(
|
||||||
),
|
padding: const EdgeInsets.all(14),
|
||||||
child: Column(
|
decoration: BoxDecoration(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||||
children: [
|
borderRadius: BorderRadius.circular(18),
|
||||||
Row(
|
border: Border.all(color: accent.withValues(alpha: 0.25)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Row(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
children: [
|
||||||
decoration: BoxDecoration(
|
Container(
|
||||||
color: accent.withValues(alpha: 0.14),
|
padding: const EdgeInsets.symmetric(
|
||||||
borderRadius: BorderRadius.circular(999),
|
horizontal: 10,
|
||||||
),
|
vertical: 6,
|
||||||
child: Text(
|
),
|
||||||
isRx ? 'RX' : 'TX',
|
decoration: BoxDecoration(
|
||||||
style: TextStyle(
|
color: accent.withValues(alpha: 0.14),
|
||||||
color: accent,
|
borderRadius: BorderRadius.circular(999),
|
||||||
fontWeight: FontWeight.w800,
|
),
|
||||||
fontSize: 12,
|
child: Text(
|
||||||
),
|
isRx ? 'RX' : 'TX',
|
||||||
),
|
style: TextStyle(
|
||||||
),
|
color: accent,
|
||||||
const SizedBox(width: 8),
|
fontWeight: FontWeight.w800,
|
||||||
Expanded(
|
fontSize: 12,
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
entry.payloadLabel,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (entry.payloadMeaning != null)
|
),
|
||||||
Text(
|
const SizedBox(width: 8),
|
||||||
entry.payloadMeaning!,
|
Expanded(
|
||||||
maxLines: 1,
|
child: Column(
|
||||||
overflow: TextOverflow.ellipsis,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: TextStyle(
|
children: [
|
||||||
fontSize: 11,
|
Text(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
packetDetails.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
if (entry.payloadMeaning != null)
|
||||||
|
Text(
|
||||||
|
entry.payloadMeaning!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (signalMetric != null) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
CompactSignalIndicator(metric: signalMetric),
|
||||||
|
] else
|
||||||
|
Text(
|
||||||
|
_timeAgo(log.timestamp, now),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
Text(
|
const SizedBox(height: 10),
|
||||||
_timeAgo(log.timestamp, now),
|
_PacketInfoLine(
|
||||||
style: TextStyle(
|
text:
|
||||||
fontSize: 12,
|
'${_formatClock(log.timestamp)} • Size: ${log.rawData.length} bytes',
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
_PacketInfoLine(text: 'Hash: ${packetDetails.packetHashHex}'),
|
||||||
),
|
if (packetDetails.pathLine != null)
|
||||||
const SizedBox(height: 10),
|
_PacketInfoLine(text: packetDetails.pathLine!),
|
||||||
Wrap(
|
if (packetDetails.pathHashLine != null)
|
||||||
spacing: 8,
|
_PacketInfoLine(text: packetDetails.pathHashLine!),
|
||||||
runSpacing: 8,
|
if (packetDetails.endpointLine != null)
|
||||||
children: [
|
_PacketInfoLine(text: packetDetails.endpointLine!),
|
||||||
_PacketMetaChip(
|
|
||||||
label: '${log.rawData.length} bytes',
|
|
||||||
onTap: () => _showPacketBytesSheet(context, log.rawData),
|
|
||||||
),
|
|
||||||
if (rxInfo?.rssiDbm != null)
|
|
||||||
_PacketMetaChip(label: 'RSSI ${rxInfo!.rssiDbm} dBm'),
|
|
||||||
if (rxInfo?.snrDb != null)
|
|
||||||
_PacketMetaChip(
|
|
||||||
label: 'SNR ${rxInfo!.snrDb!.toStringAsFixed(1)} dB',
|
|
||||||
),
|
|
||||||
if (entry.hopCount != null)
|
|
||||||
_PacketMetaChip(label: '${entry.hopCount} hops'),
|
|
||||||
if (entry.isMultiHop)
|
|
||||||
const _PacketMetaChip(label: 'MULTI-HOP', emphasized: true),
|
|
||||||
if (originDistance != null)
|
if (originDistance != null)
|
||||||
_PacketMetaChip(label: 'Origin $originDistance'),
|
_PacketInfoLine(text: 'Origin: $originDistance'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
),
|
||||||
Text(
|
|
||||||
routePreview,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -748,36 +757,11 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
return '${diff.inMinutes}m ago';
|
return '${diff.inMinutes}m ago';
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _resolvedRoutePreview(
|
static String _formatClock(DateTime timestamp) {
|
||||||
BuildContext context,
|
final hour = timestamp.hour.toString().padLeft(2, '0');
|
||||||
LiveTrafficEntry entry,
|
final minute = timestamp.minute.toString().padLeft(2, '0');
|
||||||
) {
|
final second = timestamp.second.toString().padLeft(2, '0');
|
||||||
final route = entry.route;
|
return '$hour:$minute:$second';
|
||||||
if (route == null || route.hopHashes.isEmpty) {
|
|
||||||
return entry.routePreview;
|
|
||||||
}
|
|
||||||
|
|
||||||
final contactsProvider = _maybeProvider<ContactsProvider>(context);
|
|
||||||
final connectionProvider = _maybeProvider<ConnectionProvider>(context);
|
|
||||||
if (contactsProvider == null && connectionProvider == null) {
|
|
||||||
return entry.routePreview;
|
|
||||||
}
|
|
||||||
final ownLatLng = _ownLatLng(connectionProvider);
|
|
||||||
|
|
||||||
final resolvedLabels = route.hopHashes.map((hashHex) {
|
|
||||||
final resolved = LogRxRouteDecoder.resolveHash(
|
|
||||||
hashHex,
|
|
||||||
contacts: contactsProvider?.contacts ?? const <Contact>[],
|
|
||||||
ownPublicKey: connectionProvider?.deviceInfo.publicKey,
|
|
||||||
ownName:
|
|
||||||
connectionProvider?.deviceInfo.selfName ??
|
|
||||||
connectionProvider?.deviceInfo.displayName,
|
|
||||||
ownLatitude: ownLatLng?.latitude,
|
|
||||||
ownLongitude: ownLatLng?.longitude,
|
|
||||||
);
|
|
||||||
return _compactNodeLabel(resolved);
|
|
||||||
}).toList();
|
|
||||||
return resolvedLabels.join(' -> ');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static String? _originDistanceLabel(
|
static String? _originDistanceLabel(
|
||||||
@@ -856,11 +840,16 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
final hex = chunk
|
final hex = chunk
|
||||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase())
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||||
.join(' ');
|
.join(' ');
|
||||||
hexLines.add('${offset.toRadixString(16).padLeft(4, '0').toUpperCase()}: $hex');
|
hexLines.add(
|
||||||
|
'${offset.toRadixString(16).padLeft(4, '0').toUpperCase()}: $hex',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final ascii = data
|
final ascii = data
|
||||||
.map((byte) => (byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : '.')
|
.map(
|
||||||
|
(byte) =>
|
||||||
|
(byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : '.',
|
||||||
|
)
|
||||||
.join();
|
.join();
|
||||||
|
|
||||||
return showModalBottomSheet<void>(
|
return showModalBottomSheet<void>(
|
||||||
@@ -886,10 +875,7 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
style: TextStyle(color: scheme.onSurfaceVariant),
|
style: TextStyle(color: scheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text('Hex', style: Theme.of(context).textTheme.titleSmall),
|
||||||
'Hex',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -909,10 +895,7 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text('ASCII', style: Theme.of(context).textTheme.titleSmall),
|
||||||
'ASCII',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -940,17 +923,190 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _compactNodeLabel(ResolvedNodeHash node) {
|
static Future<void> _showTraceSheet(
|
||||||
if (node.isOwnNode) {
|
BuildContext context,
|
||||||
return node.label;
|
LiveTrafficEntry entry,
|
||||||
|
) {
|
||||||
|
final route = entry.route;
|
||||||
|
if (route == null || route.pathBytes.isEmpty) {
|
||||||
|
return Future.value();
|
||||||
}
|
}
|
||||||
if (node.matchCount > 0) {
|
return showModalBottomSheet<void>(
|
||||||
return node.label;
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
builder: (context) => MessageTraceSheet.packetPath(
|
||||||
|
packetPath: route.pathBytes,
|
||||||
|
descriptionOverride:
|
||||||
|
'Relay path from packet path bytes (${route.hopHashes.length} hop${route.hopHashes.length == 1 ? '' : 's'})',
|
||||||
|
noRelayMatchTextOverride:
|
||||||
|
'No named nodes could be matched for this packet path.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LiveTrafficPacketDetails {
|
||||||
|
final String title;
|
||||||
|
final String packetHashHex;
|
||||||
|
final String? pathLine;
|
||||||
|
final String? pathHashLine;
|
||||||
|
final String? endpointLine;
|
||||||
|
|
||||||
|
const _LiveTrafficPacketDetails({
|
||||||
|
required this.title,
|
||||||
|
required this.packetHashHex,
|
||||||
|
required this.pathLine,
|
||||||
|
required this.pathHashLine,
|
||||||
|
required this.endpointLine,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory _LiveTrafficPacketDetails.fromEntry(LiveTrafficEntry entry) {
|
||||||
|
final route = entry.route;
|
||||||
|
final payloadType = route?.payloadType;
|
||||||
|
final parsedPayload = _ParsedTrafficPayload.tryParse(
|
||||||
|
entry.log.rawData,
|
||||||
|
route,
|
||||||
|
);
|
||||||
|
final title = switch (payloadType) {
|
||||||
|
0x00 => 'FLOOD REQUEST',
|
||||||
|
0x01 => 'FLOOD RESPONSE',
|
||||||
|
0x02 => 'FLOOD TEXT',
|
||||||
|
0x03 => 'FLOOD ACK',
|
||||||
|
0x04 => 'FLOOD ADVERTISEMENT',
|
||||||
|
0x05 => 'FLOOD GROUP_TEXT',
|
||||||
|
0x06 => 'FLOOD GROUP_DATA',
|
||||||
|
0x07 => 'FLOOD ANON_REQUEST',
|
||||||
|
0x08 => 'FLOOD RETURNED_PATH',
|
||||||
|
0x09 => 'FLOOD TRACE_PATH',
|
||||||
|
0x0A => 'FLOOD MULTIPART',
|
||||||
|
0x0B => 'FLOOD CONTROL',
|
||||||
|
_ => entry.payloadLabel.toUpperCase(),
|
||||||
|
};
|
||||||
|
|
||||||
|
final hopHashes = route?.hopHashes ?? const <String>[];
|
||||||
|
final pathLine = hopHashes.isEmpty
|
||||||
|
? null
|
||||||
|
: 'Path: ${hopHashes.length} hop${hopHashes.length == 1 ? '' : 's'} [${hopHashes.join(',')}]';
|
||||||
|
final pathHashLine = route == null
|
||||||
|
? null
|
||||||
|
: 'Path Hashes: ${route.hashSize}-byte per hop';
|
||||||
|
|
||||||
|
return _LiveTrafficPacketDetails(
|
||||||
|
title: title,
|
||||||
|
packetHashHex: _packetHash(entry.log.rawData),
|
||||||
|
pathLine: pathLine,
|
||||||
|
pathHashLine: pathHashLine,
|
||||||
|
endpointLine: parsedPayload?.endpointLine,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _packetHash(List<int> bytes) {
|
||||||
|
const fnvOffset = 0xcbf29ce484222325;
|
||||||
|
const fnvPrime = 0x100000001b3;
|
||||||
|
const mask = 0xFFFFFFFFFFFFFFFF;
|
||||||
|
var hash = fnvOffset;
|
||||||
|
for (final byte in bytes) {
|
||||||
|
hash ^= byte & 0xFF;
|
||||||
|
hash = (hash * fnvPrime) & mask;
|
||||||
}
|
}
|
||||||
return node.hexLabel;
|
return hash.toRadixString(16).padLeft(16, '0').toUpperCase();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ParsedTrafficPayload {
|
||||||
|
final String? endpointLine;
|
||||||
|
|
||||||
|
const _ParsedTrafficPayload({this.endpointLine});
|
||||||
|
|
||||||
|
static _ParsedTrafficPayload? tryParse(
|
||||||
|
List<int> rawData,
|
||||||
|
DecodedLogRxRoute? route,
|
||||||
|
) {
|
||||||
|
if (rawData.length < 5 ||
|
||||||
|
rawData.first != LiveTrafficSummary.logRxDataResponseCode) {
|
||||||
|
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 pathByteLen =
|
||||||
|
LogRxRouteDecoder.descriptorByteLength(pathDescriptor) ??
|
||||||
|
(pathDescriptor == 0xFF ? 0 : null);
|
||||||
|
if (pathByteLen == null || rawPacketData.length < index + pathByteLen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
index += pathByteLen;
|
||||||
|
final payload = rawPacketData.sublist(index);
|
||||||
|
final senderHash = route?.hopHashes.isNotEmpty == true
|
||||||
|
? route!.hopHashes.first
|
||||||
|
: null;
|
||||||
|
|
||||||
|
switch (payloadType) {
|
||||||
|
case 0x05:
|
||||||
|
case 0x06:
|
||||||
|
if (payload.isEmpty) return const _ParsedTrafficPayload();
|
||||||
|
return _ParsedTrafficPayload(
|
||||||
|
endpointLine:
|
||||||
|
'Channel Hash: ${payload.first.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
||||||
|
);
|
||||||
|
case 0x00:
|
||||||
|
case 0x01:
|
||||||
|
case 0x07:
|
||||||
|
if (payload.isEmpty) return const _ParsedTrafficPayload();
|
||||||
|
final destinationHash = payload.first
|
||||||
|
.toRadixString(16)
|
||||||
|
.padLeft(2, '0')
|
||||||
|
.toUpperCase();
|
||||||
|
return _ParsedTrafficPayload(
|
||||||
|
endpointLine: senderHash == null
|
||||||
|
? 'To: <$destinationHash>'
|
||||||
|
: 'From: <${senderHash.toUpperCase()}> To: <$destinationHash>',
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return const _ParsedTrafficPayload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PacketInfoLine extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
const _PacketInfoLine({required this.text});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class _GeoPoint {
|
class _GeoPoint {
|
||||||
final double latitude;
|
final double latitude;
|
||||||
final double longitude;
|
final double longitude;
|
||||||
@@ -958,44 +1114,6 @@ class _GeoPoint {
|
|||||||
const _GeoPoint(this.latitude, this.longitude);
|
const _GeoPoint(this.latitude, this.longitude);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PacketMetaChip extends StatelessWidget {
|
|
||||||
final String label;
|
|
||||||
final bool emphasized;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
|
|
||||||
const _PacketMetaChip({
|
|
||||||
required this.label,
|
|
||||||
this.emphasized = false,
|
|
||||||
this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
final color = emphasized ? scheme.primary : scheme.outline;
|
|
||||||
return InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color.withValues(alpha: emphasized ? 0.12 : 0.08),
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
border: Border.all(color: color.withValues(alpha: 0.22)),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: color,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void openLiveTrafficScreen(BuildContext context, ConnectionProvider provider) {
|
void openLiveTrafficScreen(BuildContext context, ConnectionProvider provider) {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
|
|||||||
@@ -61,9 +61,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
Timer? _highlightTimer; // Timer for clearing message highlight
|
Timer? _highlightTimer; // Timer for clearing message highlight
|
||||||
Timer? _channelReadTimer;
|
Timer? _channelReadTimer;
|
||||||
String? _pendingChannelReadKey;
|
String? _pendingChannelReadKey;
|
||||||
TextEditingValue _lastComposerValue = const TextEditingValue();
|
|
||||||
bool _isMentionPickerOpen = false;
|
|
||||||
bool _suppressMentionTrigger = false;
|
bool _suppressMentionTrigger = false;
|
||||||
|
TextRange? _activeMentionRange;
|
||||||
|
String _mentionQuery = '';
|
||||||
|
List<Contact> _mentionSuggestions = const [];
|
||||||
|
|
||||||
// Message destination state
|
// Message destination state
|
||||||
String _destinationType =
|
String _destinationType =
|
||||||
@@ -92,8 +93,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_lastComposerValue = _textController.value;
|
|
||||||
_textController.addListener(_handleComposerChanged);
|
_textController.addListener(_handleComposerChanged);
|
||||||
|
_focusNode.addListener(_handleFocusChanged);
|
||||||
// Load saved message destination
|
// Load saved message destination
|
||||||
_loadSavedDestination();
|
_loadSavedDestination();
|
||||||
_loadVoiceSettings();
|
_loadVoiceSettings();
|
||||||
@@ -126,6 +127,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
_channelReadTimer?.cancel();
|
_channelReadTimer?.cancel();
|
||||||
_voiceStreamSub?.cancel();
|
_voiceStreamSub?.cancel();
|
||||||
_voiceRecorder.dispose();
|
_voiceRecorder.dispose();
|
||||||
|
_focusNode.removeListener(_handleFocusChanged);
|
||||||
_textController.dispose();
|
_textController.dispose();
|
||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
@@ -161,9 +163,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
|
|
||||||
void _scrollToMessage(String messageId) {
|
void _scrollToMessage(String messageId) {
|
||||||
final messagesProvider = context.read<MessagesProvider>();
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
final messages = _getFilteredMessages(messagesProvider);
|
final messages = messagesProvider.buildDisplayMessages(
|
||||||
|
_getFilteredMessages(messagesProvider),
|
||||||
|
);
|
||||||
|
|
||||||
final messageIndex = messages.indexWhere((m) => m.id == messageId);
|
final messageIndex = messages.indexWhere(
|
||||||
|
(entry) => entry.message.id == messageId,
|
||||||
|
);
|
||||||
|
|
||||||
if (messageIndex != -1 && _scrollController.hasClients) {
|
if (messageIndex != -1 && _scrollController.hasClients) {
|
||||||
// Calculate position - accounting for reverse list
|
// Calculate position - accounting for reverse list
|
||||||
@@ -195,25 +201,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleComposerChanged() {
|
void _handleComposerChanged() {
|
||||||
final previousValue = _lastComposerValue;
|
|
||||||
final currentValue = _textController.value;
|
|
||||||
_lastComposerValue = currentValue;
|
|
||||||
|
|
||||||
_updateCharacterCount();
|
_updateCharacterCount();
|
||||||
|
|
||||||
if (_suppressMentionTrigger || _isMentionPickerOpen) {
|
if (_suppressMentionTrigger) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final mentionTriggerRange = _getMentionTriggerRange(
|
_updateMentionSuggestions(_textController.value);
|
||||||
previousValue: previousValue,
|
|
||||||
currentValue: currentValue,
|
|
||||||
);
|
|
||||||
if (mentionTriggerRange == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
unawaited(_showMentionSelectorForRange(mentionTriggerRange));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateCharacterCount() {
|
void _updateCharacterCount() {
|
||||||
@@ -222,43 +216,112 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
TextRange? _getMentionTriggerRange({
|
void _handleFocusChanged() {
|
||||||
required TextEditingValue previousValue,
|
if (!_focusNode.hasFocus) {
|
||||||
required TextEditingValue currentValue,
|
_clearMentionSuggestions();
|
||||||
}) {
|
return;
|
||||||
if (!previousValue.selection.isValid ||
|
}
|
||||||
!currentValue.selection.isValid ||
|
|
||||||
!previousValue.selection.isCollapsed ||
|
if (_suppressMentionTrigger) {
|
||||||
!currentValue.selection.isCollapsed) {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateMentionSuggestions(_textController.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateMentionSuggestions(TextEditingValue value) {
|
||||||
|
final triggerRange = _getMentionTriggerRange(value);
|
||||||
|
if (triggerRange == null) {
|
||||||
|
_clearMentionSuggestions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final query = value.text.substring(
|
||||||
|
triggerRange.start + 1,
|
||||||
|
triggerRange.end,
|
||||||
|
);
|
||||||
|
final contacts = _buildMentionSuggestions(query);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_activeMentionRange = triggerRange;
|
||||||
|
_mentionQuery = query;
|
||||||
|
_mentionSuggestions = contacts;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearMentionSuggestions() {
|
||||||
|
if (_activeMentionRange == null &&
|
||||||
|
_mentionQuery.isEmpty &&
|
||||||
|
_mentionSuggestions.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_activeMentionRange = null;
|
||||||
|
_mentionQuery = '';
|
||||||
|
_mentionSuggestions = const [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRange? _getMentionTriggerRange(TextEditingValue value) {
|
||||||
|
if (!value.selection.isValid || !value.selection.isCollapsed) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final previousOffset = previousValue.selection.baseOffset;
|
final cursorOffset = value.selection.baseOffset;
|
||||||
final currentOffset = currentValue.selection.baseOffset;
|
if (cursorOffset <= 0) {
|
||||||
if (previousOffset < 0 || currentOffset < 0) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentValue.text.length != previousValue.text.length + 1 ||
|
final text = value.text;
|
||||||
currentOffset != previousOffset + 1) {
|
final triggerStart = text.lastIndexOf('@', cursorOffset - 1);
|
||||||
|
if (triggerStart == -1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentValue.text.substring(0, previousOffset) !=
|
final leadingChar = triggerStart > 0 ? text[triggerStart - 1] : null;
|
||||||
previousValue.text.substring(0, previousOffset)) {
|
if (leadingChar != null && !RegExp(r'[\s\(\[\{]').hasMatch(leadingChar)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentValue.text.substring(currentOffset) !=
|
final query = text.substring(triggerStart + 1, cursorOffset);
|
||||||
previousValue.text.substring(previousOffset)) {
|
if (query.contains(RegExp(r'[\s@\[\]\n\r]'))) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentValue.text[previousOffset] != '@') {
|
return TextRange(start: triggerStart, end: cursorOffset);
|
||||||
return null;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return TextRange(start: previousOffset, end: currentOffset);
|
List<Contact> _buildMentionSuggestions(String query) {
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
final normalizedQuery = query.trim().toLowerCase();
|
||||||
|
final contacts =
|
||||||
|
contactsProvider.contacts
|
||||||
|
.where((contact) => contact.type == ContactType.chat)
|
||||||
|
.where((contact) {
|
||||||
|
if (normalizedQuery.isEmpty) return true;
|
||||||
|
return contact.displayName.toLowerCase().contains(
|
||||||
|
normalizedQuery,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
final aName = a.displayName.toLowerCase();
|
||||||
|
final bName = b.displayName.toLowerCase();
|
||||||
|
final aStarts =
|
||||||
|
normalizedQuery.isNotEmpty && aName.startsWith(normalizedQuery);
|
||||||
|
final bStarts =
|
||||||
|
normalizedQuery.isNotEmpty && bName.startsWith(normalizedQuery);
|
||||||
|
if (aStarts != bStarts) {
|
||||||
|
return aStarts ? -1 : 1;
|
||||||
|
}
|
||||||
|
return aName.compareTo(bName);
|
||||||
|
});
|
||||||
|
|
||||||
|
return contacts.take(8).toList(growable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
int get _maxMessageBytes =>
|
int get _maxMessageBytes =>
|
||||||
@@ -378,56 +441,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showMentionSelectorForRange(TextRange triggerRange) async {
|
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
|
||||||
final contacts = contactsProvider.contacts
|
|
||||||
.where((contact) => contact.type == ContactType.chat)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
if (contacts.isEmpty || !mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_isMentionPickerOpen = true;
|
|
||||||
Contact? selectedContact;
|
|
||||||
|
|
||||||
await showModalBottomSheet<void>(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
builder: (context) => RecipientSelectorSheet(
|
|
||||||
contacts: contacts,
|
|
||||||
rooms: const [],
|
|
||||||
channels: const [],
|
|
||||||
unreadCount: 0,
|
|
||||||
unreadCountsByPublicKey: {
|
|
||||||
for (final contact in contacts) contact.publicKeyHex: 0,
|
|
||||||
},
|
|
||||||
currentDestinationType: null,
|
|
||||||
currentRecipientPublicKey: null,
|
|
||||||
showAllOption: false,
|
|
||||||
onSelect: (_, recipient) {
|
|
||||||
selectedContact = recipient;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
_isMentionPickerOpen = false;
|
|
||||||
|
|
||||||
if (!mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedContact != null) {
|
|
||||||
_insertReplyMention(
|
|
||||||
selectedContact!.displayName,
|
|
||||||
replacementRange: triggerRange,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
_focusNode.requestFocus();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle recipient selection
|
/// Handle recipient selection
|
||||||
Future<void> _onRecipientSelected(String type, Contact? recipient) async {
|
Future<void> _onRecipientSelected(String type, Contact? recipient) async {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -489,15 +502,24 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
selection: TextSelection.collapsed(offset: nextOffset),
|
selection: TextSelection.collapsed(offset: nextOffset),
|
||||||
composing: TextRange.empty,
|
composing: TextRange.empty,
|
||||||
);
|
);
|
||||||
_lastComposerValue = _textController.value;
|
|
||||||
_suppressMentionTrigger = false;
|
_suppressMentionTrigger = false;
|
||||||
|
_clearMentionSuggestions();
|
||||||
_enforceMessageByteLimit();
|
_enforceMessageByteLimit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _selectMention(Contact contact) {
|
||||||
|
_insertReplyMention(
|
||||||
|
contact.displayName,
|
||||||
|
replacementRange: _activeMentionRange,
|
||||||
|
);
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _replyToMessage(Message message) async {
|
Future<void> _replyToMessage(Message message) async {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
Contact? senderContact;
|
Contact? senderContact;
|
||||||
|
String? senderDisplayName;
|
||||||
|
|
||||||
String destinationType;
|
String destinationType;
|
||||||
Contact? recipient;
|
Contact? recipient;
|
||||||
@@ -522,28 +544,48 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
if (senderPrefix != null && senderPrefix.length >= 6) {
|
if (senderPrefix != null && senderPrefix.length >= 6) {
|
||||||
senderContact = contactsProvider.findContactByPrefix(senderPrefix);
|
senderContact = contactsProvider.findContactByPrefix(senderPrefix);
|
||||||
}
|
}
|
||||||
|
senderDisplayName =
|
||||||
|
senderContact?.displayName ?? message.senderName?.trim();
|
||||||
} else {
|
} else {
|
||||||
final senderPrefix = message.senderPublicKeyPrefix;
|
final roomRecipient = message.recipientPublicKey == null
|
||||||
if (senderPrefix == null || senderPrefix.length < 6) {
|
? null
|
||||||
ToastLogger.error(context, l10n.cannotReplySenderMissing);
|
: contactsProvider.findContactByKey(message.recipientPublicKey!);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
recipient = contactsProvider.findContactByPrefix(senderPrefix);
|
if (roomRecipient?.isRoom == true) {
|
||||||
if (recipient == null) {
|
destinationType = MessageDestinationPreferences.destinationTypeRoom;
|
||||||
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
|
recipient = roomRecipient;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
destinationType = recipient.isRoom
|
final senderPrefix = message.senderPublicKeyPrefix;
|
||||||
? MessageDestinationPreferences.destinationTypeRoom
|
if (senderPrefix != null && senderPrefix.length >= 6) {
|
||||||
: MessageDestinationPreferences.destinationTypeContact;
|
senderContact = contactsProvider.findContactByPrefix(senderPrefix);
|
||||||
|
}
|
||||||
|
senderDisplayName =
|
||||||
|
senderContact?.displayName ?? message.senderName?.trim();
|
||||||
|
} else {
|
||||||
|
final senderPrefix = message.senderPublicKeyPrefix;
|
||||||
|
if (senderPrefix == null || senderPrefix.length < 6) {
|
||||||
|
ToastLogger.error(context, l10n.cannotReplySenderMissing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
recipient = contactsProvider.findContactByPrefix(senderPrefix);
|
||||||
|
if (recipient == null) {
|
||||||
|
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
destinationType = recipient.isRoom
|
||||||
|
? MessageDestinationPreferences.destinationTypeRoom
|
||||||
|
: MessageDestinationPreferences.destinationTypeContact;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _onRecipientSelected(destinationType, recipient);
|
await _onRecipientSelected(destinationType, recipient);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (message.isChannelMessage && senderContact != null) {
|
if ((message.isChannelMessage || recipient?.isRoom == true) &&
|
||||||
_insertReplyMention(senderContact.displayName);
|
senderDisplayName != null &&
|
||||||
|
senderDisplayName.isNotEmpty) {
|
||||||
|
_insertReplyMention(senderDisplayName);
|
||||||
}
|
}
|
||||||
_focusNode.requestFocus();
|
_focusNode.requestFocus();
|
||||||
}
|
}
|
||||||
@@ -2002,7 +2044,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
return Consumer<MessagesProvider>(
|
return Consumer<MessagesProvider>(
|
||||||
builder: (context, messagesProvider, child) {
|
builder: (context, messagesProvider, child) {
|
||||||
_syncChannelAutoReadTimer(messagesProvider);
|
_syncChannelAutoReadTimer(messagesProvider);
|
||||||
final messages = _getFilteredMessages(messagesProvider);
|
final messages = messagesProvider.buildDisplayMessages(
|
||||||
|
_getFilteredMessages(messagesProvider),
|
||||||
|
);
|
||||||
final bottomInset = MediaQuery.of(context).viewPadding.bottom;
|
final bottomInset = MediaQuery.of(context).viewPadding.bottom;
|
||||||
final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0;
|
final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0;
|
||||||
|
|
||||||
@@ -2040,6 +2084,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
bottomPadding: composerBottomPadding,
|
bottomPadding: composerBottomPadding,
|
||||||
destinationLabel: _getDestinationLabel(),
|
destinationLabel: _getDestinationLabel(),
|
||||||
destinationAvatar: _buildDestinationAvatar(context),
|
destinationAvatar: _buildDestinationAvatar(context),
|
||||||
|
mentionSuggestions: _mentionSuggestions,
|
||||||
|
mentionQuery: _mentionQuery,
|
||||||
|
onMentionSelected: _selectMention,
|
||||||
onShowComposerActions: _showComposerActions,
|
onShowComposerActions: _showComposerActions,
|
||||||
onShowRecipientSelector: _showRecipientSelector,
|
onShowRecipientSelector: _showRecipientSelector,
|
||||||
onStartVoiceRecording: _startVoiceRecording,
|
onStartVoiceRecording: _startVoiceRecording,
|
||||||
|
|||||||
@@ -777,13 +777,7 @@ class _DecodedRouteSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
final originalSender = resolvedPath.isEmpty ? null : resolvedPath.first;
|
return _RouteSection(route: decodedRoute, path: resolvedPath);
|
||||||
|
|
||||||
return _RouteSection(
|
|
||||||
route: decodedRoute,
|
|
||||||
path: resolvedPath,
|
|
||||||
originalSender: originalSender,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -827,13 +821,8 @@ class _DecodedRouteSection extends StatelessWidget {
|
|||||||
class _RouteSection extends StatelessWidget {
|
class _RouteSection extends StatelessWidget {
|
||||||
final DecodedLogRxRoute route;
|
final DecodedLogRxRoute route;
|
||||||
final List<ResolvedNodeHash> path;
|
final List<ResolvedNodeHash> path;
|
||||||
final ResolvedNodeHash? originalSender;
|
|
||||||
|
|
||||||
const _RouteSection({
|
const _RouteSection({required this.route, required this.path});
|
||||||
required this.route,
|
|
||||||
required this.path,
|
|
||||||
required this.originalSender,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -879,12 +868,6 @@ class _RouteSection extends StatelessWidget {
|
|||||||
value:
|
value:
|
||||||
'${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}',
|
'${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}',
|
||||||
),
|
),
|
||||||
if (originalSender != null)
|
|
||||||
_FactCard(
|
|
||||||
icon: Icons.person_pin_circle,
|
|
||||||
label: 'Original sender',
|
|
||||||
value: _nodeLabel(originalSender!),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import '../services/image_preferences.dart';
|
|||||||
import '../services/route_hash_preferences.dart';
|
import '../services/route_hash_preferences.dart';
|
||||||
import '../services/image_codec_service.dart';
|
import '../services/image_codec_service.dart';
|
||||||
import '../services/developer_mode_service.dart';
|
import '../services/developer_mode_service.dart';
|
||||||
|
import '../services/notification_service.dart';
|
||||||
import '../utils/sample_data_generator.dart';
|
import '../utils/sample_data_generator.dart';
|
||||||
import '../utils/image_message_parser.dart';
|
import '../utils/image_message_parser.dart';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
@@ -77,6 +78,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
bool _rotateMapWithHeading = false;
|
bool _rotateMapWithHeading = false;
|
||||||
bool _showMapDebugInfo = false;
|
bool _showMapDebugInfo = false;
|
||||||
bool _openMapInFullscreen = false;
|
bool _openMapInFullscreen = false;
|
||||||
|
bool _messageNotificationsEnabled = true;
|
||||||
|
bool _sarNotificationsEnabled = true;
|
||||||
|
bool _updateNotificationsEnabled = true;
|
||||||
|
bool _muteForegroundNotifications = true;
|
||||||
bool _isDeveloperModeEnabled = false;
|
bool _isDeveloperModeEnabled = false;
|
||||||
DateTime? _onlineTraceCacheUpdatedAt;
|
DateTime? _onlineTraceCacheUpdatedAt;
|
||||||
bool _isClearingOnlineTraceCache = false;
|
bool _isClearingOnlineTraceCache = false;
|
||||||
@@ -99,6 +104,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_loadDeveloperMode();
|
_loadDeveloperMode();
|
||||||
_loadOnlineTraceCacheStatus();
|
_loadOnlineTraceCacheStatus();
|
||||||
_loadMapPreferences();
|
_loadMapPreferences();
|
||||||
|
_loadNotificationPreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -136,6 +142,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadNotificationPreferences() async {
|
||||||
|
final service = NotificationService();
|
||||||
|
await service.initialize();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_messageNotificationsEnabled = service.messageNotificationsEnabled;
|
||||||
|
_sarNotificationsEnabled = service.sarNotificationsEnabled;
|
||||||
|
_updateNotificationsEnabled = service.updateNotificationsEnabled;
|
||||||
|
_muteForegroundNotifications = service.muteForegroundNotifications;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadOnlineTraceCacheStatus() async {
|
Future<void> _loadOnlineTraceCacheStatus() async {
|
||||||
final cachedAt = await MeshMapNodesService.cachedAt();
|
final cachedAt = await MeshMapNodesService.cachedAt();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -187,8 +205,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_rotateMapWithHeading =
|
_rotateMapWithHeading = prefs.getBool('map_rotate_with_heading') ?? false;
|
||||||
prefs.getBool('map_rotate_with_heading') ?? false;
|
|
||||||
_showMapDebugInfo = prefs.getBool('map_show_debug_info') ?? false;
|
_showMapDebugInfo = prefs.getBool('map_show_debug_info') ?? false;
|
||||||
_openMapInFullscreen = prefs.getBool('map_fullscreen') ?? false;
|
_openMapInFullscreen = prefs.getBool('map_fullscreen') ?? false;
|
||||||
});
|
});
|
||||||
@@ -1002,6 +1019,72 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
_buildSectionHeader('Notifications'),
|
||||||
|
_buildSettingsCard([
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.chat_bubble_outline),
|
||||||
|
title: const Text('Message notifications'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Notify for incoming direct and channel messages',
|
||||||
|
),
|
||||||
|
value: _messageNotificationsEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
setState(() {
|
||||||
|
_messageNotificationsEnabled = value;
|
||||||
|
});
|
||||||
|
await NotificationService().setMessageNotificationsEnabled(
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.warning_amber_outlined),
|
||||||
|
title: const Text('SAR alerts'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Notify for incoming SAR markers such as found person or fire',
|
||||||
|
),
|
||||||
|
value: _sarNotificationsEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
setState(() {
|
||||||
|
_sarNotificationsEnabled = value;
|
||||||
|
});
|
||||||
|
await NotificationService().setSarNotificationsEnabled(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.system_update),
|
||||||
|
title: const Text('Update notifications'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Notify when a newer app version is available',
|
||||||
|
),
|
||||||
|
value: _updateNotificationsEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
setState(() {
|
||||||
|
_updateNotificationsEnabled = value;
|
||||||
|
});
|
||||||
|
await NotificationService().setUpdateNotificationsEnabled(
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.visibility_off_outlined),
|
||||||
|
title: const Text('Mute while app is open'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Do not show local notifications while the app is in the foreground',
|
||||||
|
),
|
||||||
|
value: _muteForegroundNotifications,
|
||||||
|
onChanged: (value) async {
|
||||||
|
setState(() {
|
||||||
|
_muteForegroundNotifications = value;
|
||||||
|
});
|
||||||
|
await NotificationService().setMuteForegroundNotifications(
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
|
||||||
_buildSectionHeader('Navigation'),
|
_buildSectionHeader('Navigation'),
|
||||||
_buildSettingsCard([
|
_buildSettingsCard([
|
||||||
Consumer<AppProvider>(
|
Consumer<AppProvider>(
|
||||||
@@ -1047,19 +1130,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
_buildSectionHeader('Messaging'),
|
_buildSectionHeader('Messaging'),
|
||||||
_buildSettingsCard([
|
_buildSettingsCard([
|
||||||
Consumer<AppProvider>(
|
|
||||||
builder: (context, appProvider, child) => SwitchListTile(
|
|
||||||
secondary: const Icon(Icons.person_add_alt_1),
|
|
||||||
title: const Text('Auto-add discovered contacts'),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Automatically fetch and add new contacts when they are discovered',
|
|
||||||
),
|
|
||||||
value: appProvider.autoAddDiscoveredContacts,
|
|
||||||
onChanged: (value) async {
|
|
||||||
await appProvider.toggleAutoAddDiscoveredContacts(value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.alt_route),
|
leading: const Icon(Icons.alt_route),
|
||||||
title: const Text('Route path byte size'),
|
title: const Text('Route path byte size'),
|
||||||
@@ -1224,9 +1294,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
builder: (context, drawingProvider, child) => SwitchListTile(
|
builder: (context, drawingProvider, child) => SwitchListTile(
|
||||||
secondary: const Icon(Icons.fmd_good_outlined),
|
secondary: const Icon(Icons.fmd_good_outlined),
|
||||||
title: const Text('Show SAR markers'),
|
title: const Text('Show SAR markers'),
|
||||||
subtitle: const Text(
|
subtitle: const Text('Display SAR markers on the main map'),
|
||||||
'Display SAR markers on the main map',
|
|
||||||
),
|
|
||||||
value: drawingProvider.showSarMarkers,
|
value: drawingProvider.showSarMarkers,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
drawingProvider.toggleSarMarkers();
|
drawingProvider.toggleSarMarkers();
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import 'package:latlong2/latlong.dart';
|
|||||||
class ContactStorageService {
|
class ContactStorageService {
|
||||||
static const String _contactsKey = 'stored_contacts';
|
static const String _contactsKey = 'stored_contacts';
|
||||||
static const String _contactGroupsKey = 'stored_contact_groups';
|
static const String _contactGroupsKey = 'stored_contact_groups';
|
||||||
|
static const String _pendingAdvertsKey = 'stored_pending_adverts';
|
||||||
static const int _maxStoredContacts = 500; // Store up to 500 contacts
|
static const int _maxStoredContacts = 500; // Store up to 500 contacts
|
||||||
|
static const int _maxStoredPendingAdverts = 500;
|
||||||
|
|
||||||
/// Save contacts to persistent storage
|
/// Save contacts to persistent storage
|
||||||
Future<void> saveContacts(List<Contact> contacts) async {
|
Future<void> saveContacts(List<Contact> contacts) async {
|
||||||
@@ -126,6 +128,47 @@ class ContactStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> savePendingAdverts(List<Map<String, dynamic>> adverts) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final limitedList = adverts.length > _maxStoredPendingAdverts
|
||||||
|
? adverts.sublist(adverts.length - _maxStoredPendingAdverts)
|
||||||
|
: adverts;
|
||||||
|
await prefs.setString(_pendingAdvertsKey, jsonEncode(limitedList));
|
||||||
|
debugPrint(
|
||||||
|
'✅ [ContactStorage] Saved ${limitedList.length} pending adverts to storage',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error saving pending adverts: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> loadPendingAdverts() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(_pendingAdvertsKey);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||||
|
return jsonList.whereType<Map<String, dynamic>>().toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error loading pending adverts: $e');
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearPendingAdverts() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_pendingAdvertsKey);
|
||||||
|
debugPrint('✅ [ContactStorage] Cleared all stored pending adverts');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error clearing pending adverts: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get storage statistics
|
/// Get storage statistics
|
||||||
Future<Map<String, dynamic>> getStorageStats() async {
|
Future<Map<String, dynamic>> getStorageStats() async {
|
||||||
try {
|
try {
|
||||||
@@ -167,6 +210,15 @@ class ContactStorageService {
|
|||||||
'telemetry': contact.telemetry != null
|
'telemetry': contact.telemetry != null
|
||||||
? _telemetryToJson(contact.telemetry!)
|
? _telemetryToJson(contact.telemetry!)
|
||||||
: null,
|
: null,
|
||||||
|
'advertHistory': contact.advertHistory
|
||||||
|
.map(
|
||||||
|
(point) => {
|
||||||
|
'lat': point.location.latitude,
|
||||||
|
'lon': point.location.longitude,
|
||||||
|
'tsMillis': point.timestamp.millisecondsSinceEpoch,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +242,7 @@ class ContactStorageService {
|
|||||||
telemetry: json['telemetry'] != null
|
telemetry: json['telemetry'] != null
|
||||||
? _telemetryFromJson(json['telemetry'] as Map<String, dynamic>)
|
? _telemetryFromJson(json['telemetry'] as Map<String, dynamic>)
|
||||||
: null,
|
: null,
|
||||||
|
advertHistory: _advertHistoryFromJson(json['advertHistory']),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [ContactStorage] Error parsing contact from JSON: $e');
|
debugPrint('❌ [ContactStorage] Error parsing contact from JSON: $e');
|
||||||
@@ -242,6 +295,25 @@ class ContactStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<AdvertLocation> _advertHistoryFromJson(dynamic json) {
|
||||||
|
if (json is! List) return [];
|
||||||
|
final result = <AdvertLocation>[];
|
||||||
|
for (final item in json) {
|
||||||
|
if (item is! Map<String, dynamic>) continue;
|
||||||
|
final lat = item['lat'];
|
||||||
|
final lon = item['lon'];
|
||||||
|
final tsMillis = item['tsMillis'];
|
||||||
|
if (lat is! num || lon is! num || tsMillis is! int) continue;
|
||||||
|
result.add(
|
||||||
|
AdvertLocation(
|
||||||
|
location: LatLng(lat.toDouble(), lon.toDouble()),
|
||||||
|
timestamp: DateTime.fromMillisecondsSinceEpoch(tsMillis),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _contactGroupToJson(SavedContactGroup group) {
|
Map<String, dynamic> _contactGroupToJson(SavedContactGroup group) {
|
||||||
return {
|
return {
|
||||||
'id': group.id,
|
'id': group.id,
|
||||||
|
|||||||
@@ -329,6 +329,8 @@ class MessageStorageService {
|
|||||||
Message message, {
|
Message message, {
|
||||||
MessageReceptionDetails? receptionDetails,
|
MessageReceptionDetails? receptionDetails,
|
||||||
}) {
|
}) {
|
||||||
|
final persistedPathBytes =
|
||||||
|
message.pathBytes ?? _pathBytesFromSnapshot(receptionDetails);
|
||||||
return {
|
return {
|
||||||
'id': message.id,
|
'id': message.id,
|
||||||
'messageType': message.messageType.name,
|
'messageType': message.messageType.name,
|
||||||
@@ -337,6 +339,7 @@ class MessageStorageService {
|
|||||||
: null,
|
: null,
|
||||||
'channelIdx': message.channelIdx,
|
'channelIdx': message.channelIdx,
|
||||||
'pathLen': message.pathLen,
|
'pathLen': message.pathLen,
|
||||||
|
'pathBytes': persistedPathBytes?.toList(),
|
||||||
'textType': message.textType.value,
|
'textType': message.textType.value,
|
||||||
'senderTimestamp': message.senderTimestamp,
|
'senderTimestamp': message.senderTimestamp,
|
||||||
'text': message.text,
|
'text': message.text,
|
||||||
@@ -465,6 +468,14 @@ class MessageStorageService {
|
|||||||
: null,
|
: null,
|
||||||
channelIdx: json['channelIdx'] as int?,
|
channelIdx: json['channelIdx'] as int?,
|
||||||
pathLen: json['pathLen'] as int,
|
pathLen: json['pathLen'] as int,
|
||||||
|
pathBytes: json['pathBytes'] is List
|
||||||
|
? Uint8List.fromList(
|
||||||
|
(json['pathBytes'] as List<dynamic>)
|
||||||
|
.whereType<num>()
|
||||||
|
.map((b) => b.toInt())
|
||||||
|
.toList(),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
textType: MessageTextType.fromValue(json['textType'] as int),
|
textType: MessageTextType.fromValue(json['textType'] as int),
|
||||||
senderTimestamp: json['senderTimestamp'] as int,
|
senderTimestamp: json['senderTimestamp'] as int,
|
||||||
text: json['text'] as String,
|
text: json['text'] as String,
|
||||||
@@ -562,4 +573,12 @@ class MessageStorageService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Uint8List? _pathBytesFromSnapshot(MessageReceptionDetails? receptionDetails) {
|
||||||
|
final pathBytes = receptionDetails?.pathBytes;
|
||||||
|
if (pathBytes == null || pathBytes.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Uint8List.fromList(pathBytes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:timezone/data/latest_all.dart' as tz;
|
import 'package:timezone/data/latest_all.dart' as tz;
|
||||||
import '../models/sar_marker.dart';
|
import '../models/sar_marker.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
@@ -13,14 +14,26 @@ class NotificationService {
|
|||||||
|
|
||||||
final FlutterLocalNotificationsPlugin _notificationsPlugin =
|
final FlutterLocalNotificationsPlugin _notificationsPlugin =
|
||||||
FlutterLocalNotificationsPlugin();
|
FlutterLocalNotificationsPlugin();
|
||||||
|
static const String _prefMessagesEnabled = 'notifications_messages_enabled';
|
||||||
|
static const String _prefSarEnabled = 'notifications_sar_enabled';
|
||||||
|
static const String _prefUpdatesEnabled = 'notifications_updates_enabled';
|
||||||
|
static const String _prefMuteForeground = 'notifications_mute_foreground';
|
||||||
|
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
bool _permissionGranted = false;
|
bool _permissionGranted = false;
|
||||||
|
bool _messageNotificationsEnabled = true;
|
||||||
|
bool _sarNotificationsEnabled = true;
|
||||||
|
bool _updateNotificationsEnabled = true;
|
||||||
|
bool _muteForegroundNotifications = true;
|
||||||
|
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
|
||||||
|
String? _launchPayload;
|
||||||
|
|
||||||
// Notification IDs
|
// Notification IDs
|
||||||
static const int _sarNotificationId = 1000;
|
static const int _sarNotificationId = 1000;
|
||||||
static const int _messageNotificationId = 2000;
|
static const int _messageNotificationId = 2000;
|
||||||
static const int _updateNotificationId = 3000;
|
static const int _updateNotificationId = 3000;
|
||||||
|
static const int _batteryNotificationId = 4000;
|
||||||
|
static const int _discoveryNotificationId = 5000;
|
||||||
|
|
||||||
// Notification channels
|
// Notification channels
|
||||||
static const String _urgentChannelId = 'sar_urgent';
|
static const String _urgentChannelId = 'sar_urgent';
|
||||||
@@ -38,12 +51,31 @@ class NotificationService {
|
|||||||
static const String _updateChannelDescription =
|
static const String _updateChannelDescription =
|
||||||
'Notifications for available app updates';
|
'Notifications for available app updates';
|
||||||
|
|
||||||
|
static const String _batteryChannelId = 'battery_alerts';
|
||||||
|
static const String _batteryChannelName = 'Battery Alerts';
|
||||||
|
static const String _batteryChannelDescription =
|
||||||
|
'Notifications when a device or contact battery is low';
|
||||||
|
|
||||||
|
static const String _discoveryChannelId = 'discovery_alerts';
|
||||||
|
static const String _discoveryChannelName = 'Discovery Alerts';
|
||||||
|
static const String _discoveryChannelDescription =
|
||||||
|
'Notifications when new contacts are discovered';
|
||||||
|
|
||||||
|
bool get messageNotificationsEnabled => _messageNotificationsEnabled;
|
||||||
|
bool get sarNotificationsEnabled => _sarNotificationsEnabled;
|
||||||
|
bool get updateNotificationsEnabled => _updateNotificationsEnabled;
|
||||||
|
bool get muteForegroundNotifications => _muteForegroundNotifications;
|
||||||
|
bool get isAppInForeground => _lifecycleState == AppLifecycleState.resumed;
|
||||||
|
|
||||||
/// Initialize notification service
|
/// Initialize notification service
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
if (_isInitialized) return;
|
if (_isInitialized) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
debugPrint('📬 [NotificationService] Initializing...');
|
debugPrint('📬 [NotificationService] Initializing...');
|
||||||
|
WidgetsBinding.instance.addObserver(_LifecycleObserver(this));
|
||||||
|
_lifecycleState =
|
||||||
|
WidgetsBinding.instance.lifecycleState ?? AppLifecycleState.resumed;
|
||||||
|
|
||||||
// Initialize timezone data
|
// Initialize timezone data
|
||||||
tz.initializeTimeZones();
|
tz.initializeTimeZones();
|
||||||
@@ -72,8 +104,15 @@ class NotificationService {
|
|||||||
onDidReceiveNotificationResponse: _onNotificationResponse,
|
onDidReceiveNotificationResponse: _onNotificationResponse,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final launchDetails = await _notificationsPlugin
|
||||||
|
.getNotificationAppLaunchDetails();
|
||||||
|
if (launchDetails?.didNotificationLaunchApp ?? false) {
|
||||||
|
_launchPayload = launchDetails?.notificationResponse?.payload;
|
||||||
|
}
|
||||||
|
|
||||||
// Request permissions
|
// Request permissions
|
||||||
await _requestPermissions();
|
await _requestPermissions();
|
||||||
|
await _loadPreferences();
|
||||||
|
|
||||||
// Create notification channels (Android)
|
// Create notification channels (Android)
|
||||||
await _createNotificationChannels();
|
await _createNotificationChannels();
|
||||||
@@ -132,6 +171,46 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPreferences() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_messageNotificationsEnabled = prefs.getBool(_prefMessagesEnabled) ?? true;
|
||||||
|
_sarNotificationsEnabled = prefs.getBool(_prefSarEnabled) ?? true;
|
||||||
|
_updateNotificationsEnabled = prefs.getBool(_prefUpdatesEnabled) ?? true;
|
||||||
|
_muteForegroundNotifications = prefs.getBool(_prefMuteForeground) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setMessageNotificationsEnabled(bool value) async {
|
||||||
|
_messageNotificationsEnabled = value;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_prefMessagesEnabled, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setSarNotificationsEnabled(bool value) async {
|
||||||
|
_sarNotificationsEnabled = value;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_prefSarEnabled, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setUpdateNotificationsEnabled(bool value) async {
|
||||||
|
_updateNotificationsEnabled = value;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_prefUpdatesEnabled, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setMuteForegroundNotifications(bool value) async {
|
||||||
|
_muteForegroundNotifications = value;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_prefMuteForeground, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleAppLifecycleStateChanged(AppLifecycleState state) {
|
||||||
|
_lifecycleState = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _shouldSuppressForegroundNotifications() {
|
||||||
|
return _muteForegroundNotifications && isAppInForeground;
|
||||||
|
}
|
||||||
|
|
||||||
/// Create notification channels for Android
|
/// Create notification channels for Android
|
||||||
Future<void> _createNotificationChannels() async {
|
Future<void> _createNotificationChannels() async {
|
||||||
try {
|
try {
|
||||||
@@ -177,9 +256,31 @@ class NotificationService {
|
|||||||
showBadge: true,
|
showBadge: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const batteryChannel = AndroidNotificationChannel(
|
||||||
|
_batteryChannelId,
|
||||||
|
_batteryChannelName,
|
||||||
|
description: _batteryChannelDescription,
|
||||||
|
importance: Importance.high,
|
||||||
|
playSound: true,
|
||||||
|
enableVibration: true,
|
||||||
|
showBadge: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const discoveryChannel = AndroidNotificationChannel(
|
||||||
|
_discoveryChannelId,
|
||||||
|
_discoveryChannelName,
|
||||||
|
description: _discoveryChannelDescription,
|
||||||
|
importance: Importance.high,
|
||||||
|
playSound: true,
|
||||||
|
enableVibration: true,
|
||||||
|
showBadge: true,
|
||||||
|
);
|
||||||
|
|
||||||
await androidPlugin.createNotificationChannel(urgentChannel);
|
await androidPlugin.createNotificationChannel(urgentChannel);
|
||||||
await androidPlugin.createNotificationChannel(messagesChannel);
|
await androidPlugin.createNotificationChannel(messagesChannel);
|
||||||
await androidPlugin.createNotificationChannel(updateChannel);
|
await androidPlugin.createNotificationChannel(updateChannel);
|
||||||
|
await androidPlugin.createNotificationChannel(batteryChannel);
|
||||||
|
await androidPlugin.createNotificationChannel(discoveryChannel);
|
||||||
debugPrint('✅ [NotificationService] Created notification channels');
|
debugPrint('✅ [NotificationService] Created notification channels');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('⚠️ [NotificationService] Error creating channels: $e');
|
debugPrint('⚠️ [NotificationService] Error creating channels: $e');
|
||||||
@@ -201,6 +302,12 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? consumeLaunchPayload() {
|
||||||
|
final payload = _launchPayload;
|
||||||
|
_launchPayload = null;
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
/// Show urgent notification for SAR marker
|
/// Show urgent notification for SAR marker
|
||||||
Future<void> showSarNotification({
|
Future<void> showSarNotification({
|
||||||
required SarMarkerType type,
|
required SarMarkerType type,
|
||||||
@@ -222,6 +329,16 @@ class NotificationService {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!_sarNotificationsEnabled) {
|
||||||
|
debugPrint('ℹ️ [NotificationService] SAR notifications disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_shouldSuppressForegroundNotifications()) {
|
||||||
|
debugPrint(
|
||||||
|
'ℹ️ [NotificationService] App in foreground, skipping SAR notification',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate unique notification ID based on timestamp
|
// Generate unique notification ID based on timestamp
|
||||||
@@ -394,6 +511,16 @@ class NotificationService {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!_messageNotificationsEnabled) {
|
||||||
|
debugPrint('ℹ️ [NotificationService] Message notifications disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_shouldSuppressForegroundNotifications()) {
|
||||||
|
debugPrint(
|
||||||
|
'ℹ️ [NotificationService] App in foreground, skipping message notification',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate unique notification ID based on timestamp
|
// Generate unique notification ID based on timestamp
|
||||||
@@ -547,6 +674,16 @@ class NotificationService {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!_updateNotificationsEnabled) {
|
||||||
|
debugPrint('ℹ️ [NotificationService] Update notifications disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_shouldSuppressForegroundNotifications()) {
|
||||||
|
debugPrint(
|
||||||
|
'ℹ️ [NotificationService] App in foreground, skipping update notification',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build notification title and body
|
// Build notification title and body
|
||||||
@@ -617,4 +754,169 @@ class NotificationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> showLowBatteryNotification({
|
||||||
|
required String nodeId,
|
||||||
|
required String nodeName,
|
||||||
|
required double batteryPercent,
|
||||||
|
required bool isCurrentDevice,
|
||||||
|
}) async {
|
||||||
|
if (!_isInitialized) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [NotificationService] Not initialized, skipping notification',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_permissionGranted) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [NotificationService] Permission not granted, skipping notification',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!_messageNotificationsEnabled) {
|
||||||
|
debugPrint('ℹ️ [NotificationService] Message notifications disabled');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (_shouldSuppressForegroundNotifications()) {
|
||||||
|
debugPrint(
|
||||||
|
'ℹ️ [NotificationService] App in foreground, skipping low battery notification',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final roundedPercent = batteryPercent.round().clamp(0, 100);
|
||||||
|
final title = isCurrentDevice
|
||||||
|
? 'Device battery low'
|
||||||
|
: 'Contact battery low';
|
||||||
|
final body = isCurrentDevice
|
||||||
|
? '$nodeName battery is at $roundedPercent%.'
|
||||||
|
: '$nodeName is at $roundedPercent% battery.';
|
||||||
|
final notificationId =
|
||||||
|
_batteryNotificationId + ((nodeId.hashCode & 0x7fffffff) % 1000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final androidDetails = AndroidNotificationDetails(
|
||||||
|
_batteryChannelId,
|
||||||
|
_batteryChannelName,
|
||||||
|
channelDescription: _batteryChannelDescription,
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
ticker: title,
|
||||||
|
playSound: true,
|
||||||
|
enableVibration: true,
|
||||||
|
showWhen: true,
|
||||||
|
when: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
styleInformation: BigTextStyleInformation(
|
||||||
|
body,
|
||||||
|
contentTitle: title,
|
||||||
|
summaryText: '$roundedPercent%',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final darwinDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
|
sound: 'default',
|
||||||
|
threadIdentifier: 'battery_alerts',
|
||||||
|
subtitle: '$roundedPercent%',
|
||||||
|
);
|
||||||
|
|
||||||
|
final notificationDetails = NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: darwinDetails,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _notificationsPlugin.show(
|
||||||
|
id: notificationId,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
notificationDetails: notificationDetails,
|
||||||
|
payload: 'battery:$nodeId',
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'✅ [NotificationService] Showed low battery notification for $nodeName ($roundedPercent%)',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'❌ [NotificationService] Error showing low battery notification: $e',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> showContactDiscoveredNotification({
|
||||||
|
required String contactKey,
|
||||||
|
String? contactName,
|
||||||
|
}) async {
|
||||||
|
if (!_isInitialized) return false;
|
||||||
|
if (!_permissionGranted) return false;
|
||||||
|
if (!_messageNotificationsEnabled) return false;
|
||||||
|
if (_shouldSuppressForegroundNotifications()) return false;
|
||||||
|
|
||||||
|
final shortKey = contactKey.length > 12
|
||||||
|
? contactKey.substring(0, 12).toUpperCase()
|
||||||
|
: contactKey.toUpperCase();
|
||||||
|
final title = 'New contact discovered';
|
||||||
|
final resolvedName = contactName?.trim();
|
||||||
|
final body = resolvedName != null && resolvedName.isNotEmpty
|
||||||
|
? '$resolvedName is available in Discovery.'
|
||||||
|
: 'New contact $shortKey is available in Discovery.';
|
||||||
|
final notificationId =
|
||||||
|
_discoveryNotificationId + ((contactKey.hashCode & 0x7fffffff) % 1000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final androidDetails = AndroidNotificationDetails(
|
||||||
|
_discoveryChannelId,
|
||||||
|
_discoveryChannelName,
|
||||||
|
channelDescription: _discoveryChannelDescription,
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
ticker: title,
|
||||||
|
playSound: true,
|
||||||
|
enableVibration: true,
|
||||||
|
showWhen: true,
|
||||||
|
when: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
);
|
||||||
|
|
||||||
|
final darwinDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
|
sound: 'default',
|
||||||
|
threadIdentifier: 'discovery_alerts',
|
||||||
|
);
|
||||||
|
|
||||||
|
await _notificationsPlugin.show(
|
||||||
|
id: notificationId,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
notificationDetails: NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: darwinDetails,
|
||||||
|
),
|
||||||
|
payload: 'discovery:$contactKey',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'❌ [NotificationService] Error showing discovery notification: $e',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LifecycleObserver with WidgetsBindingObserver {
|
||||||
|
_LifecycleObserver(this._service);
|
||||||
|
|
||||||
|
final NotificationService _service;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
_service.handleAppLifecycleStateChanged(state);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import '../utils/voice_message_parser.dart';
|
|
||||||
|
|
||||||
class VoiceCodecPreferences {
|
|
||||||
static const String _codecKey = 'voice_codec';
|
|
||||||
static const VoiceCodecKind defaultCodec = VoiceCodecKind.codec2;
|
|
||||||
|
|
||||||
static Future<VoiceCodecKind> getCodec() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final raw = prefs.getString(_codecKey);
|
|
||||||
return VoiceCodecKind.values.firstWhere(
|
|
||||||
(codec) => codec.name == raw,
|
|
||||||
orElse: () => defaultCodec,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<void> setCodec(VoiceCodecKind codec) async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setString(_codecKey, codec.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,36 +11,26 @@ const int _defaultLoRaCrcEnabled = 1;
|
|||||||
const int _defaultLoRaExplicitHeader = 1;
|
const int _defaultLoRaExplicitHeader = 1;
|
||||||
const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle
|
const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle
|
||||||
|
|
||||||
enum VoiceCodecKind {
|
|
||||||
codec2(0, 'Codec2');
|
|
||||||
|
|
||||||
const VoiceCodecKind(this.id, this.label);
|
|
||||||
final int id;
|
|
||||||
final String label;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Identifies which voice codec/mode was used for a packet.
|
/// Identifies which voice codec/mode was used for a packet.
|
||||||
/// Matches the modeId byte in the text/binary packet header.
|
/// Matches the modeId byte in the text/binary packet header.
|
||||||
enum VoicePacketMode {
|
enum VoicePacketMode {
|
||||||
mode700c(0, '700C', VoiceCodecKind.codec2, 8000, 100, 1600),
|
mode700c(0, '700C', 8000, 100, 1600),
|
||||||
mode1200(1, '1200', VoiceCodecKind.codec2, 8000, 150, 1040),
|
mode1200(1, '1200', 8000, 150, 1040),
|
||||||
mode2400(2, '2400', VoiceCodecKind.codec2, 8000, 300, 520),
|
mode2400(2, '2400', 8000, 300, 520),
|
||||||
mode1300(3, '1300', VoiceCodecKind.codec2, 8000, 175, 880),
|
mode1300(3, '1300', 8000, 175, 880),
|
||||||
mode1400(4, '1400', VoiceCodecKind.codec2, 8000, 175, 880),
|
mode1400(4, '1400', 8000, 175, 880),
|
||||||
mode1600(5, '1600', VoiceCodecKind.codec2, 8000, 200, 800),
|
mode1600(5, '1600', 8000, 200, 800),
|
||||||
mode3200(6, '3200', VoiceCodecKind.codec2, 8000, 400, 400);
|
mode3200(6, '3200', 8000, 400, 400);
|
||||||
|
|
||||||
const VoicePacketMode(
|
const VoicePacketMode(
|
||||||
this.id,
|
this.id,
|
||||||
this.label,
|
this.label,
|
||||||
this.codec,
|
|
||||||
this.sampleRateHz,
|
this.sampleRateHz,
|
||||||
this.bytesPerSecond,
|
this.bytesPerSecond,
|
||||||
this.packetDurationMs,
|
this.packetDurationMs,
|
||||||
);
|
);
|
||||||
final int id;
|
final int id;
|
||||||
final String label;
|
final String label;
|
||||||
final VoiceCodecKind codec;
|
|
||||||
final int sampleRateHz;
|
final int sampleRateHz;
|
||||||
final int bytesPerSecond;
|
final int bytesPerSecond;
|
||||||
final int packetDurationMs;
|
final int packetDurationMs;
|
||||||
|
|||||||
106
lib/widgets/compact_signal_indicator.dart
Normal file
106
lib/widgets/compact_signal_indicator.dart
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:meshcore_client/meshcore_client.dart' show LogRxDataInfo;
|
||||||
|
|
||||||
|
class SignalMetric {
|
||||||
|
final String valueLabel;
|
||||||
|
final Color color;
|
||||||
|
final int activeBars;
|
||||||
|
|
||||||
|
const SignalMetric({
|
||||||
|
required this.valueLabel,
|
||||||
|
required this.color,
|
||||||
|
required this.activeBars,
|
||||||
|
});
|
||||||
|
|
||||||
|
static SignalMetric? fromRxInfo(LogRxDataInfo? rxInfo) {
|
||||||
|
if (rxInfo == null) return null;
|
||||||
|
return fromValues(rssiDbm: rxInfo.rssiDbm, snrDb: rxInfo.snrDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static SignalMetric? fromValues({int? rssiDbm, double? snrDb}) {
|
||||||
|
if (snrDb != null) {
|
||||||
|
return SignalMetric(
|
||||||
|
valueLabel: '${snrDb.toStringAsFixed(1)}dB',
|
||||||
|
color: snrDb >= 10
|
||||||
|
? Colors.green
|
||||||
|
: snrDb >= 0
|
||||||
|
? Colors.amber
|
||||||
|
: Colors.redAccent,
|
||||||
|
activeBars: snrDb >= 10
|
||||||
|
? 3
|
||||||
|
: snrDb >= 0
|
||||||
|
? 2
|
||||||
|
: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rssiDbm != null) {
|
||||||
|
return SignalMetric(
|
||||||
|
valueLabel: '$rssiDbm dBm',
|
||||||
|
color: rssiDbm >= -80
|
||||||
|
? Colors.green
|
||||||
|
: rssiDbm >= -95
|
||||||
|
? Colors.amber
|
||||||
|
: Colors.redAccent,
|
||||||
|
activeBars: rssiDbm >= -80
|
||||||
|
? 3
|
||||||
|
: rssiDbm >= -95
|
||||||
|
? 2
|
||||||
|
: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CompactSignalIndicator extends StatelessWidget {
|
||||||
|
final SignalMetric metric;
|
||||||
|
final bool dense;
|
||||||
|
|
||||||
|
const CompactSignalIndicator({
|
||||||
|
super.key,
|
||||||
|
required this.metric,
|
||||||
|
this.dense = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final inactive = Theme.of(context).colorScheme.outlineVariant;
|
||||||
|
final barWidth = dense ? 4.0 : 5.0;
|
||||||
|
final baseHeight = dense ? 8.0 : 10.0;
|
||||||
|
final barStep = dense ? 6.0 : 8.0;
|
||||||
|
final barSpacing = dense ? 2.0 : 3.0;
|
||||||
|
final labelGap = dense ? 4.0 : 6.0;
|
||||||
|
final labelFontSize = dense ? 10.0 : 12.0;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
for (var index = 0; index < 3; index++) ...[
|
||||||
|
if (index > 0) SizedBox(width: barSpacing),
|
||||||
|
Container(
|
||||||
|
width: barWidth,
|
||||||
|
height: baseHeight + (index * barStep),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: index < metric.activeBars ? metric.color : inactive,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: labelGap),
|
||||||
|
Text(
|
||||||
|
metric.valueLabel,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: labelFontSize,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'dart:math' as math;
|
|
||||||
|
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
import '../../providers/connection_provider.dart';
|
import '../../models/path_history.dart';
|
||||||
import '../../providers/app_provider.dart';
|
import '../../providers/app_provider.dart';
|
||||||
|
import '../../providers/connection_provider.dart';
|
||||||
import '../../services/contact_route_resolver.dart';
|
import '../../services/contact_route_resolver.dart';
|
||||||
import '../../services/path_history_service.dart';
|
import '../../services/path_history_service.dart';
|
||||||
import '../../services/route_hash_preferences.dart';
|
import '../../services/route_hash_preferences.dart';
|
||||||
import '../../models/path_history.dart';
|
|
||||||
|
|
||||||
class ContactRouteDialogResult {
|
class ContactRouteDialogResult {
|
||||||
final ParsedContactRoute? route;
|
final ParsedContactRoute? route;
|
||||||
@@ -53,19 +54,11 @@ class ContactRouteDialog extends StatefulWidget {
|
|||||||
required Contact contact,
|
required Contact contact,
|
||||||
required List<Contact> availableContacts,
|
required List<Contact> availableContacts,
|
||||||
}) {
|
}) {
|
||||||
return showModalBottomSheet<ContactRouteDialogResult>(
|
return Navigator.of(context).push<ContactRouteDialogResult>(
|
||||||
context: context,
|
MaterialPageRoute(
|
||||||
isScrollControlled: true,
|
builder: (context) => ContactRouteDialog(
|
||||||
showDragHandle: true,
|
contact: contact,
|
||||||
builder: (context) => SafeArea(
|
availableContacts: availableContacts,
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
|
||||||
),
|
|
||||||
child: ContactRouteDialog(
|
|
||||||
contact: contact,
|
|
||||||
availableContacts: availableContacts,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -77,14 +70,15 @@ class ContactRouteDialog extends StatefulWidget {
|
|||||||
|
|
||||||
class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||||
late final TextEditingController _controller;
|
late final TextEditingController _controller;
|
||||||
|
late final TextEditingController _relaySearchController;
|
||||||
final PathHistoryService _pathHistoryService = PathHistoryService();
|
final PathHistoryService _pathHistoryService = PathHistoryService();
|
||||||
int _selectedHashSize = RouteHashPreferences.defaultHashSize;
|
int _selectedHashSize = RouteHashPreferences.defaultHashSize;
|
||||||
ParsedContactRoute? _parsedRoute;
|
ParsedContactRoute? _parsedRoute;
|
||||||
String? _errorText;
|
String? _errorText;
|
||||||
bool _showRoutingInfo = false;
|
bool _showRoutingInfo = false;
|
||||||
|
bool _showManualEditor = false;
|
||||||
List<Contact> _selectedMapHops = const [];
|
List<Contact> _selectedMapHops = const [];
|
||||||
ContactPathHistory? _pathHistory;
|
ContactPathHistory? _pathHistory;
|
||||||
_RouteEntryMode _entryMode = _RouteEntryMode.map;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -92,10 +86,9 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
_controller = TextEditingController(
|
_controller = TextEditingController(
|
||||||
text: widget.contact.routeCanonicalText,
|
text: widget.contact.routeCanonicalText,
|
||||||
);
|
);
|
||||||
|
_relaySearchController = TextEditingController();
|
||||||
_controller.addListener(_reparse);
|
_controller.addListener(_reparse);
|
||||||
_entryMode = widget.contact.routeCanonicalText.isNotEmpty
|
_showManualEditor = widget.contact.routeCanonicalText.isNotEmpty;
|
||||||
? _RouteEntryMode.manual
|
|
||||||
: _RouteEntryMode.map;
|
|
||||||
_loadHashSizePreference();
|
_loadHashSizePreference();
|
||||||
_loadPathHistory();
|
_loadPathHistory();
|
||||||
_reparse();
|
_reparse();
|
||||||
@@ -103,6 +96,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_relaySearchController.dispose();
|
||||||
_controller
|
_controller
|
||||||
..removeListener(_reparse)
|
..removeListener(_reparse)
|
||||||
..dispose();
|
..dispose();
|
||||||
@@ -231,7 +225,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
TextPosition(offset: _controller.text.length),
|
TextPosition(offset: _controller.text.length),
|
||||||
);
|
);
|
||||||
_errorText = null;
|
_errorText = null;
|
||||||
_entryMode = _RouteEntryMode.map;
|
_showManualEditor = false;
|
||||||
});
|
});
|
||||||
_reparse();
|
_reparse();
|
||||||
}
|
}
|
||||||
@@ -247,7 +241,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
TextPosition(offset: _controller.text.length),
|
TextPosition(offset: _controller.text.length),
|
||||||
);
|
);
|
||||||
_errorText = null;
|
_errorText = null;
|
||||||
_entryMode = _RouteEntryMode.manual;
|
_showManualEditor = true;
|
||||||
});
|
});
|
||||||
_reparse();
|
_reparse();
|
||||||
}
|
}
|
||||||
@@ -416,30 +410,279 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPreviewSection() {
|
Widget _buildPreviewSection() {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_parsedRoute == null ? 'Route preview' : _parsedRoute!.summary,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_parsedRoute == null
|
||||||
|
? 'Pick relays from the list below or open manual edit if you need exact hop tokens.'
|
||||||
|
: '${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
if (_parsedRoute != null) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
SelectableText(
|
||||||
|
_parsedRoute!.canonicalText,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelectedHopSection() {
|
||||||
|
if (_selectedMapHops.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: Theme.of(context).dividerColor),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'No relays selected yet. Add repeaters below or let auto resolve build a route.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text('Selected relays', style: Theme.of(context).textTheme.titleSmall),
|
||||||
_parsedRoute == null
|
const SizedBox(height: 8),
|
||||||
? 'Preview: enter or pick a route to validate it.'
|
..._selectedMapHops.asMap().entries.map((entry) {
|
||||||
: 'Preview: ${_parsedRoute!.summary} • ${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
final index = entry.key;
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
final contact = entry.value;
|
||||||
),
|
return Card(
|
||||||
if (_parsedRoute != null) ...[
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
const SizedBox(height: 4),
|
child: ListTile(
|
||||||
SelectableText(
|
leading: CircleAvatar(child: Text('${index + 1}')),
|
||||||
_parsedRoute!.canonicalText,
|
title: Text(contact.displayName),
|
||||||
style: Theme.of(
|
subtitle: Text(
|
||||||
context,
|
_tokenFor(contact, _selectedHashSize),
|
||||||
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
|
style: Theme.of(
|
||||||
),
|
context,
|
||||||
],
|
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
tooltip: 'Remove relay',
|
||||||
|
onPressed: () => _toggleHop(contact),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBuilderTab(
|
Widget _buildRelayPicker(List<Contact> routeCandidates) {
|
||||||
BuildContext context, {
|
final query = _relaySearchController.text.trim().toLowerCase();
|
||||||
|
final filteredCandidates = routeCandidates.where((contact) {
|
||||||
|
if (query.isEmpty) return true;
|
||||||
|
return contact.displayName.toLowerCase().contains(query) ||
|
||||||
|
_tokenFor(contact, _selectedHashSize).toLowerCase().contains(query);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _relaySearchController,
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
|
labelText: 'Find relay',
|
||||||
|
hintText: 'Search by name or token',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (filteredCandidates.isEmpty)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: Theme.of(context).dividerColor),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'No visible relays match this search.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
...filteredCandidates.map((candidate) {
|
||||||
|
final isSelected = _selectedMapHops.any(
|
||||||
|
(item) => item.publicKeyHex == candidate.publicKeyHex,
|
||||||
|
);
|
||||||
|
final location = candidate.displayLocation;
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
isSelected
|
||||||
|
? Icons.check_circle
|
||||||
|
: Icons.radio_button_unchecked,
|
||||||
|
color: isSelected
|
||||||
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
title: Text(candidate.displayName),
|
||||||
|
subtitle: Text(
|
||||||
|
[
|
||||||
|
_tokenFor(candidate, _selectedHashSize),
|
||||||
|
if (location != null)
|
||||||
|
'${location.latitude.toStringAsFixed(4)}, ${location.longitude.toStringAsFixed(4)}',
|
||||||
|
].join(' • '),
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
|
||||||
|
),
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: () => _toggleHop(candidate),
|
||||||
|
child: Text(isSelected ? 'Remove' : 'Add'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildManualEditor() {
|
||||||
|
return ExpansionTile(
|
||||||
|
tilePadding: EdgeInsets.zero,
|
||||||
|
childrenPadding: EdgeInsets.zero,
|
||||||
|
initiallyExpanded: _showManualEditor,
|
||||||
|
onExpansionChanged: (expanded) {
|
||||||
|
setState(() {
|
||||||
|
_showManualEditor = expanded;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
title: const Text('Manual route edit'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Use this when you need to paste or tweak hop tokens directly.',
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _controller,
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Route',
|
||||||
|
hintText: _selectedHashSize == 1
|
||||||
|
? 'AA,BB,CC'
|
||||||
|
: _selectedHashSize == 2
|
||||||
|
? 'AABB,CCDD'
|
||||||
|
: 'AABBCC,DDEEFF',
|
||||||
|
helperText:
|
||||||
|
'Comma-separated hops. Hash size follows global Settings. Colon form like AA:BB is also accepted.',
|
||||||
|
errorText: _errorText,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMapPreview({
|
||||||
|
required List<Contact> routeCandidates,
|
||||||
|
required List<LatLng> mapPoints,
|
||||||
|
required List<LatLng> routePoints,
|
||||||
|
}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 220,
|
||||||
|
child: mapPoints.length < 2
|
||||||
|
? const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
'Map preview needs your advertised location, the contact location, and visible repeater locations.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: flutter_map.FlutterMap(
|
||||||
|
options: flutter_map.MapOptions(
|
||||||
|
initialCameraFit: flutter_map.CameraFit.bounds(
|
||||||
|
bounds: flutter_map.LatLngBounds.fromPoints(mapPoints),
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
flutter_map.TileLayer(
|
||||||
|
urlTemplate:
|
||||||
|
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
userAgentPackageName: 'com.meshcore.sar',
|
||||||
|
),
|
||||||
|
if (routePoints.length >= 2)
|
||||||
|
flutter_map.PolylineLayer(
|
||||||
|
polylines: [
|
||||||
|
flutter_map.Polyline(
|
||||||
|
points: routePoints,
|
||||||
|
strokeWidth: 4,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
flutter_map.MarkerLayer(
|
||||||
|
markers: [
|
||||||
|
...routeCandidates.map((candidate) {
|
||||||
|
final isSelected = _selectedMapHops.any(
|
||||||
|
(item) => item.publicKeyHex == candidate.publicKeyHex,
|
||||||
|
);
|
||||||
|
return flutter_map.Marker(
|
||||||
|
point: LatLng(
|
||||||
|
candidate.displayLocation!.latitude,
|
||||||
|
candidate.displayLocation!.longitude,
|
||||||
|
),
|
||||||
|
width: 64,
|
||||||
|
height: 70,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => _toggleHop(candidate),
|
||||||
|
child: _RouteMarkerDot(
|
||||||
|
label: _tokenFor(candidate, _selectedHashSize),
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.primary
|
||||||
|
: Colors.blueGrey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBuilderTab({
|
||||||
required List<Contact> routeCandidates,
|
required List<Contact> routeCandidates,
|
||||||
required List<LatLng> mapPoints,
|
required List<LatLng> mapPoints,
|
||||||
required List<LatLng> routePoints,
|
required List<LatLng> routePoints,
|
||||||
@@ -447,174 +690,43 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SegmentedButton<_RouteEntryMode>(
|
Wrap(
|
||||||
segments: const [
|
spacing: 8,
|
||||||
ButtonSegment<_RouteEntryMode>(
|
runSpacing: 8,
|
||||||
value: _RouteEntryMode.map,
|
children: [
|
||||||
icon: Icon(Icons.map_outlined),
|
FilledButton.tonalIcon(
|
||||||
label: Text('Map'),
|
onPressed: _resolvePathAutomatically,
|
||||||
|
icon: const Icon(Icons.auto_fix_high),
|
||||||
|
label: const Text('Auto resolve'),
|
||||||
),
|
),
|
||||||
ButtonSegment<_RouteEntryMode>(
|
OutlinedButton.icon(
|
||||||
value: _RouteEntryMode.manual,
|
onPressed: _selectedMapHops.isEmpty
|
||||||
icon: Icon(Icons.tune),
|
? null
|
||||||
label: Text('Manual'),
|
: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedMapHops = const [];
|
||||||
|
_syncControllerFromSelectedHops();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.clear_all),
|
||||||
|
label: const Text('Clear relays'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
selected: {_entryMode},
|
|
||||||
onSelectionChanged: (selection) {
|
|
||||||
setState(() {
|
|
||||||
_entryMode = selection.first;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (_entryMode == _RouteEntryMode.manual) ...[
|
_buildPreviewSection(),
|
||||||
TextField(
|
const SizedBox(height: 16),
|
||||||
controller: _controller,
|
_buildSelectedHopSection(),
|
||||||
textCapitalization: TextCapitalization.characters,
|
const SizedBox(height: 16),
|
||||||
decoration: InputDecoration(
|
_buildRelayPicker(routeCandidates),
|
||||||
labelText: 'Route',
|
const SizedBox(height: 16),
|
||||||
hintText: _selectedHashSize == 1
|
_buildMapPreview(
|
||||||
? 'AA,BB,CC'
|
routeCandidates: routeCandidates,
|
||||||
: _selectedHashSize == 2
|
mapPoints: mapPoints,
|
||||||
? 'AABB,CCDD'
|
routePoints: routePoints,
|
||||||
: 'AABBCC,DDEEFF',
|
),
|
||||||
helperText:
|
const SizedBox(height: 16),
|
||||||
'Enter comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.',
|
_buildManualEditor(),
|
||||||
errorText: _errorText,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_buildPreviewSection(),
|
|
||||||
] else ...[
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
children: [
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: _resolvePathAutomatically,
|
|
||||||
icon: const Icon(Icons.auto_fix_high),
|
|
||||||
label: const Text('Resolve Path'),
|
|
||||||
),
|
|
||||||
ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 280),
|
|
||||||
child: Text(
|
|
||||||
'Tap repeaters on the map to build the path, then review the generated route below.',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
SizedBox(
|
|
||||||
height: 260,
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: Theme.of(context).dividerColor),
|
|
||||||
),
|
|
||||||
child: mapPoints.length < 2
|
|
||||||
? const Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.all(16),
|
|
||||||
child: Text(
|
|
||||||
'Map path builder needs your advertised location, the contact location, and visible repeater locations.',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: flutter_map.FlutterMap(
|
|
||||||
options: flutter_map.MapOptions(
|
|
||||||
initialCameraFit: flutter_map.CameraFit.bounds(
|
|
||||||
bounds: flutter_map.LatLngBounds.fromPoints(
|
|
||||||
mapPoints,
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(32),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
children: [
|
|
||||||
flutter_map.TileLayer(
|
|
||||||
urlTemplate:
|
|
||||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
|
||||||
userAgentPackageName: 'com.meshcore.sar',
|
|
||||||
),
|
|
||||||
if (routePoints.length >= 2)
|
|
||||||
flutter_map.PolylineLayer(
|
|
||||||
polylines: [
|
|
||||||
flutter_map.Polyline(
|
|
||||||
points: routePoints,
|
|
||||||
strokeWidth: 4,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
flutter_map.MarkerLayer(
|
|
||||||
markers: [
|
|
||||||
...routeCandidates.map((candidate) {
|
|
||||||
final isSelected = _selectedMapHops.any(
|
|
||||||
(item) =>
|
|
||||||
item.publicKeyHex ==
|
|
||||||
candidate.publicKeyHex,
|
|
||||||
);
|
|
||||||
return flutter_map.Marker(
|
|
||||||
point: LatLng(
|
|
||||||
candidate.displayLocation!.latitude,
|
|
||||||
candidate.displayLocation!.longitude,
|
|
||||||
),
|
|
||||||
width: 64,
|
|
||||||
height: 70,
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: () => _toggleHop(candidate),
|
|
||||||
child: _RouteMarkerDot(
|
|
||||||
label: _tokenFor(
|
|
||||||
candidate,
|
|
||||||
_selectedHashSize,
|
|
||||||
),
|
|
||||||
color: isSelected
|
|
||||||
? Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.primary
|
|
||||||
: Colors.blueGrey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
if (_selectedMapHops.isNotEmpty)
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: _selectedMapHops.map((contact) {
|
|
||||||
return InputChip(
|
|
||||||
label: Text(contact.displayName),
|
|
||||||
onDeleted: () => _toggleHop(contact),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextField(
|
|
||||||
controller: _controller,
|
|
||||||
readOnly: true,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Generated route',
|
|
||||||
helperText: 'Switch to Manual if you want to edit the hop list.',
|
|
||||||
errorText: _errorText,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_buildPreviewSection(),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -720,39 +832,33 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
|
|
||||||
return DefaultTabController(
|
return DefaultTabController(
|
||||||
length: 2,
|
length: 2,
|
||||||
child: FractionallySizedBox(
|
child: Scaffold(
|
||||||
heightFactor: 0.85,
|
appBar: AppBar(
|
||||||
child: Padding(
|
title: Text('Set Path for ${widget.contact.displayName}'),
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
bottom: const TabBar(
|
||||||
child: Column(
|
tabs: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Tab(text: 'Build'),
|
||||||
children: [
|
Tab(text: 'History'),
|
||||||
Text(
|
],
|
||||||
'Set Route for ${widget.contact.displayName}',
|
),
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
),
|
||||||
),
|
body: SafeArea(
|
||||||
const SizedBox(height: 8),
|
child: Padding(
|
||||||
Text(
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||||
'Choose how to build the route, or reuse one from history.',
|
child: Column(
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
),
|
children: [
|
||||||
const SizedBox(height: 16),
|
Text(
|
||||||
const TabBar(
|
'Plan the route on its own screen, then save it once the preview looks right.',
|
||||||
tabs: [
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
Tab(text: 'Build'),
|
),
|
||||||
Tab(text: 'History'),
|
const SizedBox(height: 16),
|
||||||
],
|
Expanded(
|
||||||
),
|
child: TabBarView(
|
||||||
const SizedBox(height: 16),
|
children: [
|
||||||
Expanded(
|
ListView(
|
||||||
child: TabBarView(
|
|
||||||
children: [
|
|
||||||
SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
_buildBuilderTab(
|
_buildBuilderTab(
|
||||||
context,
|
|
||||||
routeCandidates: routeCandidates,
|
routeCandidates: routeCandidates,
|
||||||
mapPoints: mapPoints,
|
mapPoints: mapPoints,
|
||||||
routePoints: routePoints,
|
routePoints: routePoints,
|
||||||
@@ -772,44 +878,54 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
clearPathOnMaxRetry:
|
clearPathOnMaxRetry:
|
||||||
appProvider.clearPathOnMaxRetry,
|
appProvider.clearPathOnMaxRetry,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
ListView(
|
||||||
SingleChildScrollView(child: _buildHistoryTab()),
|
children: [
|
||||||
],
|
_buildHistoryTab(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
OverflowBar(
|
),
|
||||||
alignment: MainAxisAlignment.spaceBetween,
|
),
|
||||||
spacing: 8,
|
),
|
||||||
overflowSpacing: 8,
|
bottomNavigationBar: SafeArea(
|
||||||
children: [
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
|
child: OverflowBar(
|
||||||
|
alignment: MainAxisAlignment.spaceBetween,
|
||||||
|
spacing: 8,
|
||||||
|
overflowSpacing: 8,
|
||||||
|
children: [
|
||||||
|
if (widget.contact.routeHasPath)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(
|
||||||
child: const Text('Cancel'),
|
context,
|
||||||
),
|
).pop(const ContactRouteDialogResult.clear()),
|
||||||
if (widget.contact.routeHasPath)
|
child: const Text('Clear Route'),
|
||||||
TextButton(
|
)
|
||||||
onPressed: () => Navigator.of(
|
else
|
||||||
context,
|
const SizedBox.shrink(),
|
||||||
).pop(const ContactRouteDialogResult.clear()),
|
FilledButton(
|
||||||
child: const Text('Clear Route'),
|
onPressed: _parsedRoute == null
|
||||||
),
|
? null
|
||||||
FilledButton(
|
: () => Navigator.of(context).pop(
|
||||||
onPressed: _parsedRoute == null
|
ContactRouteDialogResult.setWithFallback(
|
||||||
? null
|
_parsedRoute!,
|
||||||
: () => Navigator.of(context).pop(
|
inferredFallbackLocation:
|
||||||
ContactRouteDialogResult.setWithFallback(
|
_buildSyntheticFallbackLocation(),
|
||||||
_parsedRoute!,
|
|
||||||
inferredFallbackLocation:
|
|
||||||
_buildSyntheticFallbackLocation(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: const Text('Set Route'),
|
),
|
||||||
),
|
child: const Text('Save Path'),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -817,8 +933,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _RouteEntryMode { map, manual }
|
|
||||||
|
|
||||||
class _RouteMarkerDot extends StatelessWidget {
|
class _RouteMarkerDot extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|||||||
@@ -325,7 +325,9 @@ class ContactTile extends StatelessWidget {
|
|||||||
contact.type == ContactType.room ||
|
contact.type == ContactType.room ||
|
||||||
contact.type == ContactType.channel;
|
contact.type == ContactType.channel;
|
||||||
final canSetPath =
|
final canSetPath =
|
||||||
contact.type == ContactType.chat || contact.type == ContactType.room;
|
contact.type == ContactType.chat ||
|
||||||
|
contact.type == ContactType.room ||
|
||||||
|
contact.type == ContactType.repeater;
|
||||||
final canAddToSensors =
|
final canAddToSensors =
|
||||||
contact.type == ContactType.chat ||
|
contact.type == ContactType.chat ||
|
||||||
contact.type == ContactType.repeater;
|
contact.type == ContactType.repeater;
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
trace.routeHashes.isEmpty
|
trace.routeHashes.isEmpty
|
||||||
? 'Direct route to ${widget.contact.displayName}'
|
? 'No relay path saved for ${widget.contact.displayName}'
|
||||||
: 'Route from saved contact path (${trace.routeHashes.length} hop${trace.routeHashes.length == 1 ? '' : 's'})',
|
: 'Route from saved contact path (${trace.routeHashes.length} hop${trace.routeHashes.length == 1 ? '' : 's'})',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
@@ -199,14 +199,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
radius: 16,
|
radius: 16,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
entry.key == 0
|
Colors.blue,
|
||||||
? Colors.green
|
|
||||||
: (entry.key ==
|
|
||||||
concreteNodes
|
|
||||||
.length -
|
|
||||||
1
|
|
||||||
? Colors.red
|
|
||||||
: Colors.blue),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
'${entry.key + 1}',
|
'${entry.key + 1}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -236,7 +229,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Route',
|
'Relay path',
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -263,11 +256,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
: null,
|
: null,
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
radius: 14,
|
radius: 14,
|
||||||
backgroundColor: entry.key == 0
|
backgroundColor: Colors.blue,
|
||||||
? Colors.green
|
|
||||||
: (entry.key == routeEntries.length - 1
|
|
||||||
? Colors.red
|
|
||||||
: Colors.blue),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
'${entry.key + 1}',
|
'${entry.key + 1}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -279,7 +268,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
),
|
),
|
||||||
title: Text(entry.value.label),
|
title: Text(entry.value.label),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : ' • ${entry.value.resolved.cycleSummary}'}',
|
'Relay${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : ' • ${entry.value.resolved.cycleSummary}'}',
|
||||||
),
|
),
|
||||||
trailing: entry.value.resolved.canCycle
|
trailing: entry.value.resolved.canCycle
|
||||||
? const Icon(Icons.sync_alt)
|
? const Icon(Icons.sync_alt)
|
||||||
@@ -326,44 +315,18 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<_RouteDisplayEntry> _displayRouteEntries(_ContactTraceResult trace) {
|
List<_RouteDisplayEntry> _displayRouteEntries(_ContactTraceResult trace) {
|
||||||
final entries = <_RouteDisplayEntry>[];
|
return trace.matchedRelayNodes.asMap().entries.map((entry) {
|
||||||
if (trace.sender.node != null) {
|
final resolved = entry.value;
|
||||||
entries.add(
|
final node = resolved.node;
|
||||||
_RouteDisplayEntry.fromResolved(
|
final hashHex = trace.routeHashes[entry.key].toUpperCase();
|
||||||
trace.sender,
|
return _RouteDisplayEntry(
|
||||||
target: const _RouteEntryTarget.sender(),
|
resolved: resolved,
|
||||||
),
|
label: node?.name ?? 'Unknown',
|
||||||
|
keyLabel: node != null ? _prefixKeyLabel(node.publicKey) : hashHex,
|
||||||
|
matchSummary: resolved.matchSummary,
|
||||||
|
target: _RouteEntryTarget.relayNode(entry.key),
|
||||||
);
|
);
|
||||||
}
|
}).toList();
|
||||||
entries.addAll(
|
|
||||||
trace.matchedRelayNodes.asMap().entries.map((entry) {
|
|
||||||
final resolved = entry.value;
|
|
||||||
final node = resolved.node;
|
|
||||||
final hashHex = trace.routeHashes[entry.key].toUpperCase();
|
|
||||||
return _RouteDisplayEntry(
|
|
||||||
resolved: resolved,
|
|
||||||
label: node?.name ?? 'Unknown',
|
|
||||||
keyLabel: node != null ? _prefixKeyLabel(node.publicKey) : hashHex,
|
|
||||||
matchSummary: resolved.matchSummary,
|
|
||||||
target: _RouteEntryTarget.relayNode(entry.key),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (trace.recipient.node != null) {
|
|
||||||
entries.add(
|
|
||||||
_RouteDisplayEntry.fromResolved(
|
|
||||||
trace.recipient,
|
|
||||||
target: const _RouteEntryTarget.recipient(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
String _routeRoleLabel(int index, int total) {
|
|
||||||
if (index == 0) return 'Sender';
|
|
||||||
if (index == total - 1) return 'Recipient';
|
|
||||||
return 'Relay';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _prefixKeyLabel(String publicKey) =>
|
String _prefixKeyLabel(String publicKey) =>
|
||||||
@@ -527,20 +490,6 @@ class _ContactTraceResult {
|
|||||||
|
|
||||||
_ContactTraceResult cycleEntry(_RouteEntryTarget target) {
|
_ContactTraceResult cycleEntry(_RouteEntryTarget target) {
|
||||||
switch (target.kind) {
|
switch (target.kind) {
|
||||||
case _RouteEntryKind.sender:
|
|
||||||
return _ContactTraceResult(
|
|
||||||
sender: sender.cycle(),
|
|
||||||
recipient: recipient,
|
|
||||||
routeHashes: routeHashes,
|
|
||||||
matchedRelayNodes: matchedRelayNodes,
|
|
||||||
);
|
|
||||||
case _RouteEntryKind.recipient:
|
|
||||||
return _ContactTraceResult(
|
|
||||||
sender: sender,
|
|
||||||
recipient: recipient.cycle(),
|
|
||||||
routeHashes: routeHashes,
|
|
||||||
matchedRelayNodes: matchedRelayNodes,
|
|
||||||
);
|
|
||||||
case _RouteEntryKind.relayNode:
|
case _RouteEntryKind.relayNode:
|
||||||
final updated = matchedRelayNodes.toList();
|
final updated = matchedRelayNodes.toList();
|
||||||
updated[target.index] = updated[target.index].cycle();
|
updated[target.index] = updated[target.index].cycle();
|
||||||
@@ -570,26 +519,9 @@ class _RouteDisplayEntry {
|
|||||||
});
|
});
|
||||||
|
|
||||||
MeshMapNode? get node => resolved.node;
|
MeshMapNode? get node => resolved.node;
|
||||||
|
|
||||||
factory _RouteDisplayEntry.fromResolved(
|
|
||||||
ResolvedTraceNode resolved, {
|
|
||||||
required _RouteEntryTarget target,
|
|
||||||
}) {
|
|
||||||
final node = resolved.node!;
|
|
||||||
return _RouteDisplayEntry(
|
|
||||||
resolved: resolved,
|
|
||||||
label: node.name,
|
|
||||||
keyLabel: node.publicKey.substring(
|
|
||||||
0,
|
|
||||||
math.min(12, node.publicKey.length),
|
|
||||||
),
|
|
||||||
matchSummary: resolved.matchSummary,
|
|
||||||
target: target,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _RouteEntryKind { sender, recipient, relayNode }
|
enum _RouteEntryKind { relayNode }
|
||||||
|
|
||||||
class _RouteEntryTarget {
|
class _RouteEntryTarget {
|
||||||
final _RouteEntryKind kind;
|
final _RouteEntryKind kind;
|
||||||
@@ -597,8 +529,6 @@ class _RouteEntryTarget {
|
|||||||
|
|
||||||
const _RouteEntryTarget._(this.kind, [this.index = 0]);
|
const _RouteEntryTarget._(this.kind, [this.index = 0]);
|
||||||
|
|
||||||
const _RouteEntryTarget.sender() : this._(_RouteEntryKind.sender);
|
|
||||||
const _RouteEntryTarget.recipient() : this._(_RouteEntryKind.recipient);
|
|
||||||
const _RouteEntryTarget.relayNode(int index)
|
const _RouteEntryTarget.relayNode(int index)
|
||||||
: this._(_RouteEntryKind.relayNode, index);
|
: this._(_RouteEntryKind.relayNode, index);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import 'system_message_bubble.dart';
|
|||||||
/// - Grouped messages (expandable recipient list)
|
/// - Grouped messages (expandable recipient list)
|
||||||
class MessageBubble extends StatefulWidget {
|
class MessageBubble extends StatefulWidget {
|
||||||
final Message message;
|
final Message message;
|
||||||
|
final int receivedCopies;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final VoidCallback? onReply;
|
final VoidCallback? onReply;
|
||||||
final bool isHighlighted;
|
final bool isHighlighted;
|
||||||
@@ -56,6 +57,7 @@ class MessageBubble extends StatefulWidget {
|
|||||||
const MessageBubble({
|
const MessageBubble({
|
||||||
super.key,
|
super.key,
|
||||||
required this.message,
|
required this.message,
|
||||||
|
this.receivedCopies = 1,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.onReply,
|
this.onReply,
|
||||||
this.isHighlighted = false,
|
this.isHighlighted = false,
|
||||||
@@ -576,6 +578,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
'Sender to receipt ms: ${receptionDetails?.senderToReceiptMs ?? '-'}',
|
'Sender to receipt ms: ${receptionDetails?.senderToReceiptMs ?? '-'}',
|
||||||
'Estimated transmit ms: ${receptionDetails?.estimatedTransmitMs ?? '-'}',
|
'Estimated transmit ms: ${receptionDetails?.estimatedTransmitMs ?? '-'}',
|
||||||
'Post-transmit delay ms: ${receptionDetails?.postTransmitDelayMs ?? '-'}',
|
'Post-transmit delay ms: ${receptionDetails?.postTransmitDelayMs ?? '-'}',
|
||||||
|
'Received copies: ${widget.receivedCopies}',
|
||||||
'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}',
|
'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}',
|
||||||
'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}',
|
'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}',
|
||||||
'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}',
|
'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}',
|
||||||
@@ -846,6 +849,12 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
receptionDetails!.postTransmitDelayMs!,
|
receptionDetails!.postTransmitDelayMs!,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (widget.receivedCopies > 1)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Received copies',
|
||||||
|
value: '${widget.receivedCopies}',
|
||||||
|
),
|
||||||
if (widget.message.suggestedTimeoutMs != null)
|
if (widget.message.suggestedTimeoutMs != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
context,
|
||||||
|
|||||||
@@ -17,9 +17,24 @@ import '../../utils/log_rx_route_decoder.dart';
|
|||||||
import '../../utils/trace_node_resolver.dart';
|
import '../../utils/trace_node_resolver.dart';
|
||||||
|
|
||||||
class MessageTraceSheet extends StatefulWidget {
|
class MessageTraceSheet extends StatefulWidget {
|
||||||
final Message message;
|
final Message? message;
|
||||||
|
final List<int>? packetPathOverride;
|
||||||
|
final String? descriptionOverride;
|
||||||
|
final String? noRelayMatchTextOverride;
|
||||||
|
|
||||||
const MessageTraceSheet({super.key, required this.message});
|
const MessageTraceSheet({super.key, required this.message})
|
||||||
|
: assert(message != null),
|
||||||
|
packetPathOverride = null,
|
||||||
|
descriptionOverride = null,
|
||||||
|
noRelayMatchTextOverride = null;
|
||||||
|
|
||||||
|
const MessageTraceSheet.packetPath({
|
||||||
|
super.key,
|
||||||
|
required List<int> packetPath,
|
||||||
|
this.descriptionOverride,
|
||||||
|
this.noRelayMatchTextOverride,
|
||||||
|
}) : message = null,
|
||||||
|
packetPathOverride = packetPath;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MessageTraceSheet> createState() => _MessageTraceSheetState();
|
State<MessageTraceSheet> createState() => _MessageTraceSheetState();
|
||||||
@@ -40,20 +55,27 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
final messagesProvider = context.read<MessagesProvider>();
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
final preferredHashSize = await RouteHashPreferences.getHashSize();
|
final preferredHashSize = await RouteHashPreferences.getHashSize();
|
||||||
final storedPath = messagesProvider
|
List<int>? packetPath = widget.packetPathOverride;
|
||||||
.getMessageReceptionDetails(widget.message.id)
|
String? senderPrefix;
|
||||||
?.pathBytes;
|
String? recipientPrefix;
|
||||||
final packetPath = (storedPath != null && storedPath.isNotEmpty)
|
|
||||||
? storedPath
|
if (widget.message case final message?) {
|
||||||
: _extractPathFromPacketLogs(
|
final storedPath = messagesProvider
|
||||||
logs: connectionProvider.bleService.packetLogs,
|
.getMessageReceptionDetails(message.id)
|
||||||
message: widget.message,
|
?.pathBytes;
|
||||||
);
|
packetPath = (storedPath != null && storedPath.isNotEmpty)
|
||||||
|
? storedPath
|
||||||
|
: _extractPathFromPacketLogs(
|
||||||
|
logs: connectionProvider.bleService.packetLogs,
|
||||||
|
message: message,
|
||||||
|
);
|
||||||
|
|
||||||
|
senderPrefix = _toPrefixHex(message.senderPublicKeyPrefix);
|
||||||
|
recipientPrefix = message.recipientPublicKey != null
|
||||||
|
? _toPrefixHex(message.recipientPublicKey)
|
||||||
|
: _toPrefixHex(connectionProvider.deviceInfo.publicKey);
|
||||||
|
}
|
||||||
|
|
||||||
final senderPrefix = _toPrefixHex(widget.message.senderPublicKeyPrefix);
|
|
||||||
final recipientPrefix = widget.message.recipientPublicKey != null
|
|
||||||
? _toPrefixHex(widget.message.recipientPublicKey)
|
|
||||||
: _toPrefixHex(connectionProvider.deviceInfo.publicKey);
|
|
||||||
final localNodes = _localNodesFromContacts(contactsProvider);
|
final localNodes = _localNodesFromContacts(contactsProvider);
|
||||||
final localPublicKeys = localNodes.map((node) => node.publicKey).toSet();
|
final localPublicKeys = localNodes.map((node) => node.publicKey).toSet();
|
||||||
var trace = _buildTraceResult(
|
var trace = _buildTraceResult(
|
||||||
@@ -66,7 +88,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
);
|
);
|
||||||
if (_isCompleteTrace(
|
if (_isCompleteTrace(
|
||||||
trace,
|
trace,
|
||||||
expectedRelayCount: math.max(0, widget.message.pathLen),
|
expectedRelayCount: widget.message == null
|
||||||
|
? 0
|
||||||
|
: math.max(0, widget.message!.pathLen),
|
||||||
)) {
|
)) {
|
||||||
return trace;
|
return trace;
|
||||||
}
|
}
|
||||||
@@ -153,9 +177,10 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
trace.mode == TraceMode.packetPath
|
widget.descriptionOverride ??
|
||||||
? 'Route from packet path bytes'
|
(trace.mode == TraceMode.packetPath
|
||||||
: 'Route inferred from hop count (${widget.message.pathLen})',
|
? 'Relay path from packet path bytes'
|
||||||
|
: 'Relay path inferred from hop count (${widget.message!.pathLen})'),
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -222,14 +247,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
radius: 16,
|
radius: 16,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
entry.key == 0
|
Colors.blue,
|
||||||
? Colors.green
|
|
||||||
: (entry.key ==
|
|
||||||
concretePathNodes
|
|
||||||
.length -
|
|
||||||
1
|
|
||||||
? Colors.red
|
|
||||||
: Colors.blue),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
'${entry.key + 1}',
|
'${entry.key + 1}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -259,18 +277,19 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Route',
|
'Relay path',
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (routeEntries.isEmpty)
|
if (routeEntries.isEmpty)
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
vertical: 8,
|
vertical: 8,
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'No named nodes could be matched for this trace.',
|
widget.noRelayMatchTextOverride ??
|
||||||
|
'No named nodes could be matched for this trace.',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
...routeEntries.asMap().entries.map(
|
...routeEntries.asMap().entries.map(
|
||||||
@@ -286,11 +305,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
: null,
|
: null,
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
radius: 14,
|
radius: 14,
|
||||||
backgroundColor: entry.key == 0
|
backgroundColor: Colors.blue,
|
||||||
? Colors.green
|
|
||||||
: (entry.key == routeEntries.length - 1
|
|
||||||
? Colors.red
|
|
||||||
: Colors.blue),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
'${entry.key + 1}',
|
'${entry.key + 1}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -302,7 +317,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
),
|
),
|
||||||
title: Text(entry.value.label),
|
title: Text(entry.value.label),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : ' • ${entry.value.resolved.cycleSummary}'}',
|
'Relay${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : ' • ${entry.value.resolved.cycleSummary}'}',
|
||||||
),
|
),
|
||||||
trailing: entry.value.resolved.canCycle
|
trailing: entry.value.resolved.canCycle
|
||||||
? const Icon(Icons.sync_alt)
|
? const Icon(Icons.sync_alt)
|
||||||
@@ -356,6 +371,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
if (concrete.isEmpty) return const [];
|
if (concrete.isEmpty) return const [];
|
||||||
|
|
||||||
if (trace.mode == TraceMode.packetPath) {
|
if (trace.mode == TraceMode.packetPath) {
|
||||||
|
if (widget.message == null) {
|
||||||
|
return concrete;
|
||||||
|
}
|
||||||
if (concrete.length <= 1) return const [];
|
if (concrete.length <= 1) return const [];
|
||||||
return concrete.sublist(1);
|
return concrete.sublist(1);
|
||||||
}
|
}
|
||||||
@@ -365,28 +383,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
|
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
|
||||||
final pathNodes = trace.matchedPathNodes
|
|
||||||
.map((entry) => entry.node)
|
|
||||||
.whereType<MeshMapNode>()
|
|
||||||
.toList();
|
|
||||||
if (pathNodes.isEmpty) {
|
|
||||||
return [
|
|
||||||
if (trace.sender.node != null)
|
|
||||||
_RouteDisplayEntry.fromResolved(
|
|
||||||
trace.sender,
|
|
||||||
target: const _RouteEntryTarget.sender(),
|
|
||||||
),
|
|
||||||
if (trace.recipient.node != null &&
|
|
||||||
trace.recipient.node!.publicKey != trace.sender.node?.publicKey)
|
|
||||||
_RouteDisplayEntry.fromResolved(
|
|
||||||
trace.recipient,
|
|
||||||
target: const _RouteEntryTarget.recipient(),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trace.mode == TraceMode.packetPath) {
|
if (trace.mode == TraceMode.packetPath) {
|
||||||
final entries = trace.matchedPathNodes.asMap().entries.map((entry) {
|
return trace.matchedPathNodes.asMap().entries.map((entry) {
|
||||||
final hashHex = trace.pathHashes[entry.key].toUpperCase();
|
final hashHex = trace.pathHashes[entry.key].toUpperCase();
|
||||||
return _RouteDisplayEntry(
|
return _RouteDisplayEntry(
|
||||||
resolved: entry.value,
|
resolved: entry.value,
|
||||||
@@ -398,54 +396,24 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
target: _RouteEntryTarget.pathNode(entry.key),
|
target: _RouteEntryTarget.pathNode(entry.key),
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
final lastKey = pathNodes.last.publicKey;
|
|
||||||
return [
|
|
||||||
...entries,
|
|
||||||
if (trace.recipient.node != null &&
|
|
||||||
trace.recipient.node!.publicKey != lastKey)
|
|
||||||
_RouteDisplayEntry.fromResolved(
|
|
||||||
trace.recipient,
|
|
||||||
target: const _RouteEntryTarget.recipient(),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final firstKey = pathNodes.first.publicKey;
|
return trace.matchedPathNodes
|
||||||
final lastKey = pathNodes.last.publicKey;
|
.asMap()
|
||||||
return [
|
.entries
|
||||||
if (trace.sender.node != null && trace.sender.node!.publicKey != firstKey)
|
.where((entry) => entry.value.node != null)
|
||||||
_RouteDisplayEntry.fromResolved(
|
.map(
|
||||||
trace.sender,
|
(entry) => _RouteDisplayEntry.fromResolved(
|
||||||
target: const _RouteEntryTarget.sender(),
|
entry.value,
|
||||||
),
|
target: _RouteEntryTarget.pathNode(entry.key),
|
||||||
...trace.matchedPathNodes
|
|
||||||
.asMap()
|
|
||||||
.entries
|
|
||||||
.where((entry) => entry.value.node != null)
|
|
||||||
.map(
|
|
||||||
(entry) => _RouteDisplayEntry.fromResolved(
|
|
||||||
entry.value,
|
|
||||||
target: _RouteEntryTarget.pathNode(entry.key),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (trace.recipient.node != null &&
|
)
|
||||||
trace.recipient.node!.publicKey != lastKey)
|
.toList();
|
||||||
_RouteDisplayEntry.fromResolved(
|
|
||||||
trace.recipient,
|
|
||||||
target: const _RouteEntryTarget.recipient(),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _prefixKeyLabel(String publicKey) =>
|
String _prefixKeyLabel(String publicKey) =>
|
||||||
publicKey.substring(0, math.min(12, publicKey.length));
|
publicKey.substring(0, math.min(12, publicKey.length));
|
||||||
|
|
||||||
String _routeRoleLabel(int index, int total) {
|
|
||||||
if (index == 0) return 'Sender';
|
|
||||||
if (index == total - 1) return 'Recipient';
|
|
||||||
return 'Relay';
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _toPrefixHex(List<int>? key) {
|
String? _toPrefixHex(List<int>? key) {
|
||||||
if (key == null || key.isEmpty) return null;
|
if (key == null || key.isEmpty) return null;
|
||||||
final take = key.length < 6 ? key.length : 6;
|
final take = key.length < 6 ? key.length : 6;
|
||||||
@@ -521,7 +489,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
final hopHashes = LogRxRouteDecoder.splitHopHashes(
|
final hopHashes = LogRxRouteDecoder.splitHopHashes(
|
||||||
packetPath,
|
packetPath,
|
||||||
hashSize: hashSize,
|
hashSize: hashSize,
|
||||||
).reversed.toList();
|
);
|
||||||
final matched = _matchNodesFromPathHashes(
|
final matched = _matchNodesFromPathHashes(
|
||||||
nodes: nodes,
|
nodes: nodes,
|
||||||
localPublicKeys: localPublicKeys,
|
localPublicKeys: localPublicKeys,
|
||||||
@@ -549,7 +517,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
nodes: nodes,
|
nodes: nodes,
|
||||||
sender: senderNode.node,
|
sender: senderNode.node,
|
||||||
recipient: recipientNode.node,
|
recipient: recipientNode.node,
|
||||||
relayCount: math.max(0, widget.message.pathLen),
|
relayCount: math.max(0, widget.message!.pathLen),
|
||||||
);
|
);
|
||||||
final matchedPathNodes = <ResolvedTraceNode>[
|
final matchedPathNodes = <ResolvedTraceNode>[
|
||||||
if (senderNode.node != null) senderNode,
|
if (senderNode.node != null) senderNode,
|
||||||
@@ -731,22 +699,6 @@ class _TraceResult {
|
|||||||
|
|
||||||
_TraceResult cycleEntry(_RouteEntryTarget target) {
|
_TraceResult cycleEntry(_RouteEntryTarget target) {
|
||||||
switch (target.kind) {
|
switch (target.kind) {
|
||||||
case _RouteEntryKind.sender:
|
|
||||||
return _TraceResult(
|
|
||||||
mode: mode,
|
|
||||||
sender: sender.cycle(),
|
|
||||||
recipient: recipient,
|
|
||||||
pathHashes: pathHashes,
|
|
||||||
matchedPathNodes: matchedPathNodes,
|
|
||||||
);
|
|
||||||
case _RouteEntryKind.recipient:
|
|
||||||
return _TraceResult(
|
|
||||||
mode: mode,
|
|
||||||
sender: sender,
|
|
||||||
recipient: recipient.cycle(),
|
|
||||||
pathHashes: pathHashes,
|
|
||||||
matchedPathNodes: matchedPathNodes,
|
|
||||||
);
|
|
||||||
case _RouteEntryKind.pathNode:
|
case _RouteEntryKind.pathNode:
|
||||||
final updated = matchedPathNodes.toList();
|
final updated = matchedPathNodes.toList();
|
||||||
updated[target.index] = updated[target.index].cycle();
|
updated[target.index] = updated[target.index].cycle();
|
||||||
@@ -796,7 +748,7 @@ class _RouteDisplayEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _RouteEntryKind { sender, recipient, pathNode }
|
enum _RouteEntryKind { pathNode }
|
||||||
|
|
||||||
class _RouteEntryTarget {
|
class _RouteEntryTarget {
|
||||||
final _RouteEntryKind kind;
|
final _RouteEntryKind kind;
|
||||||
@@ -804,8 +756,6 @@ class _RouteEntryTarget {
|
|||||||
|
|
||||||
const _RouteEntryTarget._(this.kind, [this.index = 0]);
|
const _RouteEntryTarget._(this.kind, [this.index = 0]);
|
||||||
|
|
||||||
const _RouteEntryTarget.sender() : this._(_RouteEntryKind.sender);
|
|
||||||
const _RouteEntryTarget.recipient() : this._(_RouteEntryKind.recipient);
|
|
||||||
const _RouteEntryTarget.pathNode(int index)
|
const _RouteEntryTarget.pathNode(int index)
|
||||||
: this._(_RouteEntryKind.pathNode, index);
|
: this._(_RouteEntryKind.pathNode, index);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../../models/contact.dart';
|
||||||
import '../../providers/app_provider.dart';
|
import '../../providers/app_provider.dart';
|
||||||
|
import '../common/contact_avatar.dart';
|
||||||
|
|
||||||
class MessagesComposer extends StatelessWidget {
|
class MessagesComposer extends StatelessWidget {
|
||||||
final TextEditingController textController;
|
final TextEditingController textController;
|
||||||
@@ -17,6 +19,9 @@ class MessagesComposer extends StatelessWidget {
|
|||||||
final double bottomPadding;
|
final double bottomPadding;
|
||||||
final String destinationLabel;
|
final String destinationLabel;
|
||||||
final Widget destinationAvatar;
|
final Widget destinationAvatar;
|
||||||
|
final List<Contact> mentionSuggestions;
|
||||||
|
final String mentionQuery;
|
||||||
|
final ValueChanged<Contact> onMentionSelected;
|
||||||
final VoidCallback onShowComposerActions;
|
final VoidCallback onShowComposerActions;
|
||||||
final VoidCallback onShowRecipientSelector;
|
final VoidCallback onShowRecipientSelector;
|
||||||
final Future<void> Function() onStartVoiceRecording;
|
final Future<void> Function() onStartVoiceRecording;
|
||||||
@@ -36,6 +41,9 @@ class MessagesComposer extends StatelessWidget {
|
|||||||
required this.bottomPadding,
|
required this.bottomPadding,
|
||||||
required this.destinationLabel,
|
required this.destinationLabel,
|
||||||
required this.destinationAvatar,
|
required this.destinationAvatar,
|
||||||
|
required this.mentionSuggestions,
|
||||||
|
required this.mentionQuery,
|
||||||
|
required this.onMentionSelected,
|
||||||
required this.onShowComposerActions,
|
required this.onShowComposerActions,
|
||||||
required this.onShowRecipientSelector,
|
required this.onShowRecipientSelector,
|
||||||
required this.onStartVoiceRecording,
|
required this.onStartVoiceRecording,
|
||||||
@@ -50,6 +58,15 @@ class MessagesComposer extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
if (mentionSuggestions.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(10, 0, 10, 6),
|
||||||
|
child: _MentionSuggestionsCard(
|
||||||
|
suggestions: mentionSuggestions,
|
||||||
|
query: mentionQuery,
|
||||||
|
onSelected: onMentionSelected,
|
||||||
|
),
|
||||||
|
),
|
||||||
SafeArea(
|
SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -72,7 +89,7 @@ class MessagesComposer extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 6),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@@ -95,7 +112,7 @@ class MessagesComposer extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 6),
|
||||||
ListenableBuilder(
|
ListenableBuilder(
|
||||||
listenable: Listenable.merge([
|
listenable: Listenable.merge([
|
||||||
textController,
|
textController,
|
||||||
@@ -153,6 +170,87 @@ class MessagesComposer extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _MentionSuggestionsCard extends StatelessWidget {
|
||||||
|
final List<Contact> suggestions;
|
||||||
|
final String query;
|
||||||
|
final ValueChanged<Contact> onSelected;
|
||||||
|
|
||||||
|
const _MentionSuggestionsCard({
|
||||||
|
required this.suggestions,
|
||||||
|
required this.query,
|
||||||
|
required this.onSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final visibleCount = suggestions.length.clamp(1, 4);
|
||||||
|
final maxHeight = visibleCount * 54.0;
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
elevation: 8,
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
child: Container(
|
||||||
|
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).dividerColor.withValues(alpha: 0.30),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
itemCount: suggestions.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 2),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final contact = suggestions[index];
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
onTap: () => onSelected(contact),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ContactAvatar(contact: contact, radius: 16),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
contact.displayName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _ComposerActionButton extends StatelessWidget {
|
class _ComposerActionButton extends StatelessWidget {
|
||||||
final bool isRecording;
|
final bool isRecording;
|
||||||
final VoidCallback onPressed;
|
final VoidCallback onPressed;
|
||||||
@@ -165,8 +263,8 @@ class _ComposerActionButton extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
width: 42,
|
width: 40,
|
||||||
height: 42,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
@@ -175,7 +273,7 @@ class _ComposerActionButton extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: Icon(isRecording ? Icons.stop : Icons.add, size: 22),
|
icon: Icon(isRecording ? Icons.stop : Icons.add, size: 20),
|
||||||
tooltip: isRecording ? 'Stop recording' : 'More actions',
|
tooltip: isRecording ? 'Stop recording' : 'More actions',
|
||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
color: isRecording ? Colors.red : Theme.of(context).colorScheme.primary,
|
color: isRecording ? Colors.red : Theme.of(context).colorScheme.primary,
|
||||||
@@ -203,7 +301,7 @@ class _DestinationSelector extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Ink(
|
child: Ink(
|
||||||
height: 42,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
@@ -212,17 +310,17 @@ class _DestinationSelector extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
destinationAvatar,
|
destinationAvatar,
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
destinationLabel,
|
destinationLabel,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
@@ -230,7 +328,7 @@ class _DestinationSelector extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
Icon(
|
Icon(
|
||||||
Icons.expand_more_rounded,
|
Icons.expand_more_rounded,
|
||||||
size: 20,
|
size: 18,
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -261,7 +359,7 @@ class _MessageInput extends StatelessWidget {
|
|||||||
|
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: const Duration(milliseconds: 180),
|
||||||
constraints: const BoxConstraints(minHeight: 46, maxHeight: 132),
|
constraints: const BoxConstraints(minHeight: 42, maxHeight: 116),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
@@ -284,7 +382,7 @@ class _MessageInput extends StatelessWidget {
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: textController,
|
controller: textController,
|
||||||
focusNode: focusNode,
|
focusNode: focusNode,
|
||||||
@@ -375,8 +473,8 @@ class _SendButton extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
AnimatedContainer(
|
AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: const Duration(milliseconds: 180),
|
||||||
width: 46,
|
width: 42,
|
||||||
height: 46,
|
height: 42,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: canSendText || isRecording
|
color: canSendText || isRecording
|
||||||
? Theme.of(context).colorScheme.primary
|
? Theme.of(context).colorScheme.primary
|
||||||
@@ -410,7 +508,7 @@ class _SendButton extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: Icon(
|
: Icon(
|
||||||
isRecording ? Icons.mic_rounded : Icons.send_rounded,
|
isRecording ? Icons.mic_rounded : Icons.send_rounded,
|
||||||
size: 22,
|
size: 20,
|
||||||
color: canSendText || isRecording
|
color: canSendText || isRecording
|
||||||
? Theme.of(context).colorScheme.onPrimary
|
? Theme.of(context).colorScheme.onPrimary
|
||||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../models/message.dart';
|
import '../../models/message.dart';
|
||||||
|
import '../../providers/messages_provider.dart';
|
||||||
import '../common/bidirectional_refresh.dart';
|
import '../common/bidirectional_refresh.dart';
|
||||||
import '../../widgets/messages/message_bubble.dart';
|
import '../../widgets/messages/message_bubble.dart';
|
||||||
|
|
||||||
class MessagesContent extends StatelessWidget {
|
class MessagesContent extends StatelessWidget {
|
||||||
static const double defaultPadding = 8;
|
static const double defaultPadding = 8;
|
||||||
|
|
||||||
final List<Message> messages;
|
final List<DisplayMessageEntry> messages;
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
final String? highlightedMessageId;
|
final String? highlightedMessageId;
|
||||||
final double bottomContentPadding;
|
final double bottomContentPadding;
|
||||||
@@ -84,11 +85,13 @@ class MessagesContent extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
itemCount: messages.length,
|
itemCount: messages.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final message = messages[index];
|
final entry = messages[index];
|
||||||
|
final message = entry.message;
|
||||||
|
|
||||||
return MessageBubble(
|
return MessageBubble(
|
||||||
key: ValueKey(message.id),
|
key: ValueKey(message.id),
|
||||||
message: message,
|
message: message,
|
||||||
|
receivedCopies: entry.occurrenceCount,
|
||||||
isHighlighted: message.id == highlightedMessageId,
|
isHighlighted: message.id == highlightedMessageId,
|
||||||
onNavigateToMap: onNavigateToMap,
|
onNavigateToMap: onNavigateToMap,
|
||||||
onTap: onMessageTap == null
|
onTap: onMessageTap == null
|
||||||
|
|||||||
@@ -795,8 +795,8 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: "461f010"
|
ref: "813f5b3"
|
||||||
resolved-ref: "461f010a2f1fcd343357c152228891e295d391be"
|
resolved-ref: "813f5b3e0b9d2ea6b85a428be443bf5e6a38c6c5"
|
||||||
url: "https://github.com/dz0ny/meshcore_client.git"
|
url: "https://github.com/dz0ny/meshcore_client.git"
|
||||||
source: git
|
source: git
|
||||||
version: "0.1.0"
|
version: "0.1.0"
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ dependencies:
|
|||||||
meshcore_client:
|
meshcore_client:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/dz0ny/meshcore_client.git
|
url: https://github.com/dz0ny/meshcore_client.git
|
||||||
ref: "461f010"
|
ref: "813f5b3"
|
||||||
|
|
||||||
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
|
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
|
||||||
codec2_flutter:
|
codec2_flutter:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:fake_async/fake_async.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:meshcore_sar_app/models/contact.dart';
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
import 'package:meshcore_sar_app/models/message.dart';
|
import 'package:meshcore_sar_app/models/message.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/message_reception_details.dart';
|
||||||
import 'package:meshcore_sar_app/models/path_selection.dart';
|
import 'package:meshcore_sar_app/models/path_selection.dart';
|
||||||
import 'package:meshcore_sar_app/providers/helpers/message_retry_manager.dart';
|
import 'package:meshcore_sar_app/providers/helpers/message_retry_manager.dart';
|
||||||
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
||||||
@@ -347,6 +348,88 @@ void main() {
|
|||||||
expect(provider.messages.single.id, equals('c-prefix'));
|
expect(provider.messages.single.id, equals('c-prefix'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('duplicate incoming message increments received copy count', () {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
final sender = Uint8List.fromList([7, 7, 4, 1, 7, 2]);
|
||||||
|
|
||||||
|
provider.addMessage(
|
||||||
|
Message(
|
||||||
|
id: 'copy-1',
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
pathLen: 1,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000600,
|
||||||
|
text: 'same payload',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
senderPublicKeyPrefix: sender,
|
||||||
|
),
|
||||||
|
receptionDetailsSnapshot: MessageReceptionDetails(
|
||||||
|
capturedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
provider.addMessage(
|
||||||
|
Message(
|
||||||
|
id: 'copy-2',
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
pathLen: 1,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000601,
|
||||||
|
text: 'same payload',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
senderPublicKeyPrefix: sender,
|
||||||
|
),
|
||||||
|
receptionDetailsSnapshot: MessageReceptionDetails(
|
||||||
|
capturedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(provider.messages, hasLength(1));
|
||||||
|
expect(
|
||||||
|
provider.getMessageReceptionDetails('copy-1')?.receivedCopies,
|
||||||
|
equals(2),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('display list collapses stored duplicates and sums copy counts', () {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
final sender = Uint8List.fromList([5, 4, 3, 2, 1, 0]);
|
||||||
|
|
||||||
|
provider.addMessages([
|
||||||
|
Message(
|
||||||
|
id: 'dup-a',
|
||||||
|
messageType: MessageType.channel,
|
||||||
|
channelIdx: 0,
|
||||||
|
pathLen: 1,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000705,
|
||||||
|
text: 'same payload',
|
||||||
|
senderName: 'Klemen',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
senderPublicKeyPrefix: sender,
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
id: 'dup-b',
|
||||||
|
messageType: MessageType.channel,
|
||||||
|
channelIdx: 0,
|
||||||
|
pathLen: 2,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000700,
|
||||||
|
text: 'same payload',
|
||||||
|
senderName: 'Klemen',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
senderPublicKeyPrefix: sender,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final display = provider.buildDisplayMessages(
|
||||||
|
provider.getRecentMessages(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(display, hasLength(1));
|
||||||
|
expect(display.single.message.id, equals('dup-a'));
|
||||||
|
expect(display.single.occurrenceCount, equals(2));
|
||||||
|
});
|
||||||
|
|
||||||
test('missing ACK schedules a delayed retransmission', () {
|
test('missing ACK schedules a delayed retransmission', () {
|
||||||
fakeAsync((async) {
|
fakeAsync((async) {
|
||||||
final provider = MessagesProvider();
|
final provider = MessagesProvider();
|
||||||
|
|||||||
@@ -115,9 +115,15 @@ void main() {
|
|||||||
|
|
||||||
expect(find.text('1 pkt/min'), findsOneWidget);
|
expect(find.text('1 pkt/min'), findsOneWidget);
|
||||||
expect(find.text('Device total 7'), findsOneWidget);
|
expect(find.text('Device total 7'), findsOneWidget);
|
||||||
expect(find.text('Response'), findsWidgets);
|
expect(find.text('FLOOD RESPONSE'), findsOneWidget);
|
||||||
expect(find.text('MULTI-HOP'), findsOneWidget);
|
expect(find.text('MULTI-HOP'), findsOneWidget);
|
||||||
expect(find.textContaining('RSSI -84 dBm'), findsOneWidget);
|
expect(find.textContaining('RSSI -84 dBm'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Hash:'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.textContaining('Path: 3 hops [c010,6301,68d9]'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.textContaining('Path Hashes: 2-byte per hop'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('clear live view only resets transient screen state', (
|
testWidgets('clear live view only resets transient screen state', (
|
||||||
@@ -174,6 +180,6 @@ void main() {
|
|||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('No packets for this filter'), findsNothing);
|
expect(find.text('No packets for this filter'), findsNothing);
|
||||||
expect(find.textContaining('3 bytes'), findsOneWidget);
|
expect(find.textContaining('Size: 3 bytes'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
import 'package:meshcore_sar_app/models/message.dart';
|
import 'package:meshcore_sar_app/models/message.dart';
|
||||||
import 'package:meshcore_sar_app/providers/app_provider.dart';
|
import 'package:meshcore_sar_app/providers/app_provider.dart';
|
||||||
import 'package:meshcore_sar_app/providers/channels_provider.dart';
|
import 'package:meshcore_sar_app/providers/channels_provider.dart';
|
||||||
@@ -16,10 +17,34 @@ import 'package:meshcore_sar_app/services/voice_codec_service.dart';
|
|||||||
import 'package:meshcore_sar_app/services/voice_player_service.dart';
|
import 'package:meshcore_sar_app/services/voice_player_service.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
Contact buildContact({
|
||||||
|
required String name,
|
||||||
|
required ContactType type,
|
||||||
|
int secondByte = 1,
|
||||||
|
}) {
|
||||||
|
final publicKey = Uint8List(32);
|
||||||
|
publicKey[0] = secondByte;
|
||||||
|
publicKey[1] = secondByte;
|
||||||
|
|
||||||
|
return Contact(
|
||||||
|
publicKey: publicKey,
|
||||||
|
type: type,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: 0,
|
||||||
|
outPath: Uint8List(64),
|
||||||
|
advName: name,
|
||||||
|
lastAdvert: 0,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
});
|
});
|
||||||
@@ -104,4 +129,83 @@ void main() {
|
|||||||
connectionProvider.dispose();
|
connectionProvider.dispose();
|
||||||
channelsProvider.dispose();
|
channelsProvider.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('shows mention overlay above composer and inserts selection', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final connectionProvider = ConnectionProvider();
|
||||||
|
final contactsProvider = ContactsProvider();
|
||||||
|
final messagesProvider = MessagesProvider();
|
||||||
|
final mapProvider = MapProvider();
|
||||||
|
final drawingProvider = DrawingProvider();
|
||||||
|
await messagesProvider.initialize();
|
||||||
|
await drawingProvider.initialize();
|
||||||
|
final channelsProvider = ChannelsProvider()..initializePublicChannel();
|
||||||
|
final voiceProvider = VoiceProvider(
|
||||||
|
codec: VoiceCodecService(),
|
||||||
|
player: VoicePlayerService(),
|
||||||
|
);
|
||||||
|
final imageProvider = ip.ImageProvider();
|
||||||
|
final appProvider = AppProvider(
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
messagesProvider: messagesProvider,
|
||||||
|
drawingProvider: drawingProvider,
|
||||||
|
channelsProvider: channelsProvider,
|
||||||
|
voiceProvider: voiceProvider,
|
||||||
|
imageProvider: imageProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
contactsProvider.addContacts([
|
||||||
|
buildContact(name: 'Tim', type: ContactType.chat, secondByte: 2),
|
||||||
|
buildContact(name: 'Slane', type: ContactType.chat, secondByte: 3),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider.value(value: connectionProvider),
|
||||||
|
ChangeNotifierProvider.value(value: contactsProvider),
|
||||||
|
ChangeNotifierProvider.value(value: messagesProvider),
|
||||||
|
ChangeNotifierProvider.value(value: mapProvider),
|
||||||
|
ChangeNotifierProvider.value(value: drawingProvider),
|
||||||
|
ChangeNotifierProvider.value(value: channelsProvider),
|
||||||
|
ChangeNotifierProvider.value(value: voiceProvider),
|
||||||
|
ChangeNotifierProvider.value(value: imageProvider),
|
||||||
|
ChangeNotifierProvider.value(value: appProvider),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: const Scaffold(body: MessagesTab(isActive: true)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
|
||||||
|
await tester.tap(find.byType(TextField).first);
|
||||||
|
await tester.enterText(find.byType(TextField).first, '@t');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Tim'), findsOneWidget);
|
||||||
|
expect(find.text('Slane'), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Tim'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('@[Tim] '), findsOneWidget);
|
||||||
|
expect(find.text('Tim'), findsNothing);
|
||||||
|
|
||||||
|
appProvider.dispose();
|
||||||
|
voiceProvider.dispose();
|
||||||
|
imageProvider.dispose();
|
||||||
|
drawingProvider.dispose();
|
||||||
|
mapProvider.dispose();
|
||||||
|
messagesProvider.dispose();
|
||||||
|
contactsProvider.dispose();
|
||||||
|
connectionProvider.dispose();
|
||||||
|
channelsProvider.dispose();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,9 +43,11 @@ void main() {
|
|||||||
await prefs.remove('stored_message_reception_details');
|
await prefs.remove('stored_message_reception_details');
|
||||||
|
|
||||||
final restoredDetails = await storage.loadMessageReceptionDetails();
|
final restoredDetails = await storage.loadMessageReceptionDetails();
|
||||||
|
final restoredMessages = await storage.loadMessages();
|
||||||
|
|
||||||
expect(restoredDetails.keys, contains(message.id));
|
expect(restoredDetails.keys, contains(message.id));
|
||||||
expect(restoredDetails[message.id]?.pathBytes, [0xAA, 0xBB, 0xCC]);
|
expect(restoredDetails[message.id]?.pathBytes, [0xAA, 0xBB, 0xCC]);
|
||||||
|
expect(restoredMessages.single.pathBytes, [0xAA, 0xBB, 0xCC]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -76,6 +78,7 @@ void main() {
|
|||||||
senderToReceiptMs: 1200,
|
senderToReceiptMs: 1200,
|
||||||
estimatedTransmitMs: 800,
|
estimatedTransmitMs: 800,
|
||||||
postTransmitDelayMs: 400,
|
postTransmitDelayMs: 400,
|
||||||
|
receivedCopies: 3,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -93,6 +96,7 @@ void main() {
|
|||||||
expect(restored.senderToReceiptMs, 1200);
|
expect(restored.senderToReceiptMs, 1200);
|
||||||
expect(restored.estimatedTransmitMs, 800);
|
expect(restored.estimatedTransmitMs, 800);
|
||||||
expect(restored.postTransmitDelayMs, 400);
|
expect(restored.postTransmitDelayMs, 400);
|
||||||
|
expect(restored.receivedCopies, 3);
|
||||||
expect(
|
expect(
|
||||||
restored.packetLoggedAt,
|
restored.packetLoggedAt,
|
||||||
DateTime.fromMillisecondsSinceEpoch(1700000100400),
|
DateTime.fromMillisecondsSinceEpoch(1700000100400),
|
||||||
|
|||||||
Reference in New Issue
Block a user