mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Compare commits
8 Commits
v2026.0317
...
v2026.0318
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f286b0afd1 | ||
|
|
68ec72534d | ||
|
|
6af8b62d9b | ||
|
|
01fbb7de6c | ||
|
|
9303d24b69 | ||
|
|
140088fe2f | ||
|
|
67d8e1c906 | ||
|
|
29b75a3155 |
@@ -5,22 +5,22 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00024">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00103">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.554978">
|
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="1.372199">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="100.17557">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="121.394124">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="264.49426">
|
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="365.414242">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -1338,11 +1338,14 @@ class AppProvider with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'📊 [AppProvider] Binary response (0x8C) received - updating contact telemetry',
|
'📊 [AppProvider] Binary response (0x8C tag=$tag) received',
|
||||||
);
|
);
|
||||||
// Binary response tag 0 = telemetry data (Cayenne LPP format)
|
// Binary responses carry Cayenne LPP telemetry data.
|
||||||
// Other tags may be used for different data types in the future
|
// The data starts with a channel byte — valid LPP always has at least
|
||||||
|
// 3 bytes (channel + type + value). Skip clearly non-telemetry payloads.
|
||||||
|
if (responseData.length >= 3) {
|
||||||
contactsProvider.updateTelemetry(publicKeyPrefix, responseData);
|
contactsProvider.updateTelemetry(publicKeyPrefix, responseData);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// When raw binary data is received (PUSH_CODE_RAW_DATA 0x84)
|
// When raw binary data is received (PUSH_CODE_RAW_DATA 0x84)
|
||||||
|
|||||||
@@ -224,6 +224,77 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
_wireServiceCallbacks(_bleService);
|
_wireServiceCallbacks(_bleService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _prepareForConnectionSwitch(ConnectionMode nextMode) async {
|
||||||
|
if (_isScanning) {
|
||||||
|
debugPrint('🔵 [Provider] Stopping active scan before connect()');
|
||||||
|
await stopScan();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _disconnectInactiveTransports(nextMode);
|
||||||
|
_error = null;
|
||||||
|
_supportsAutoaddConfig = null;
|
||||||
|
_resetSyncState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _disconnectInactiveTransports(ConnectionMode activeMode) async {
|
||||||
|
if (activeMode != ConnectionMode.ble && _bleService.isConnected) {
|
||||||
|
await _bleService.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeMode != ConnectionMode.tcp && _tcpService != null) {
|
||||||
|
await _disposeTcpService();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeMode != ConnectionMode.usb && _serialService != null) {
|
||||||
|
_disposeSerialService();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _disposeTcpService() async {
|
||||||
|
final service = _tcpService;
|
||||||
|
if (service == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_tcpService = null;
|
||||||
|
await service.disconnect();
|
||||||
|
service.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _disposeSerialService() {
|
||||||
|
_serialService?.markDisconnected();
|
||||||
|
_serialService?.dispose();
|
||||||
|
_serialService = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _beginConnectionAttempt({
|
||||||
|
required ConnectionMode mode,
|
||||||
|
required String deviceId,
|
||||||
|
required String deviceName,
|
||||||
|
String? tcpHost,
|
||||||
|
}) {
|
||||||
|
_connectionMode = mode;
|
||||||
|
_tcpHost = mode == ConnectionMode.tcp ? tcpHost : null;
|
||||||
|
_deviceInfo = _deviceInfo.copyWith(
|
||||||
|
deviceId: deviceId,
|
||||||
|
deviceName: deviceName,
|
||||||
|
connectionState: ConnectionState.connecting,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetConnectionSession({ConnectionMode nextMode = ConnectionMode.ble}) {
|
||||||
|
_tcpHost = nextMode == ConnectionMode.tcp ? _tcpHost : null;
|
||||||
|
_connectionMode = nextMode;
|
||||||
|
_supportsAutoaddConfig = null;
|
||||||
|
_resetSyncState();
|
||||||
|
_deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected);
|
||||||
|
_roomLoginManager.clearRoomLoginStates();
|
||||||
|
_pingTracker.clearAll();
|
||||||
|
_pendingSendOperations.clear();
|
||||||
|
_messageDeliveryTracker.clearTracking();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Wire all shared event callbacks onto [service].
|
/// Wire all shared event callbacks onto [service].
|
||||||
/// Called for both BLE and TCP services so the provider handles events
|
/// Called for both BLE and TCP services so the provider handles events
|
||||||
/// identically regardless of transport.
|
/// identically regardless of transport.
|
||||||
@@ -629,27 +700,15 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
'🔵 [Provider] connect() called for device: ${device.platformName}',
|
'🔵 [Provider] connect() called for device: ${device.platformName}',
|
||||||
);
|
);
|
||||||
|
await _prepareForConnectionSwitch(ConnectionMode.ble);
|
||||||
if (_isScanning) {
|
_beginConnectionAttempt(
|
||||||
debugPrint('🔵 [Provider] Stopping active scan before connect()');
|
mode: ConnectionMode.ble,
|
||||||
await stopScan();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure we route commands to BLE, not a stale TCP service.
|
|
||||||
_connectionMode = ConnectionMode.ble;
|
|
||||||
|
|
||||||
_deviceInfo = _deviceInfo.copyWith(
|
|
||||||
deviceId: device.remoteId.toString(),
|
deviceId: device.remoteId.toString(),
|
||||||
deviceName: device.platformName.isNotEmpty
|
deviceName: device.platformName.isNotEmpty
|
||||||
? device.platformName
|
? device.platformName
|
||||||
: 'Unknown',
|
: 'Unknown',
|
||||||
connectionState: ConnectionState.connecting,
|
|
||||||
);
|
);
|
||||||
_error = null;
|
|
||||||
_supportsAutoaddConfig = null;
|
|
||||||
_resetSyncState();
|
|
||||||
debugPrint('✅ [Provider] Device info updated to connecting state');
|
debugPrint('✅ [Provider] Device info updated to connecting state');
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
debugPrint('🔵 [Provider] Calling BLE service connect()...');
|
debugPrint('🔵 [Provider] Calling BLE service connect()...');
|
||||||
final success = await _bleService.connect(device);
|
final success = await _bleService.connect(device);
|
||||||
@@ -669,23 +728,18 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
/// Connect to a MeshCore device over TCP/WiFi (port 5000)
|
/// Connect to a MeshCore device over TCP/WiFi (port 5000)
|
||||||
Future<bool> connectTcp(String host, int port) async {
|
Future<bool> connectTcp(String host, int port) async {
|
||||||
debugPrint('🌐 [Provider] connectTcp() $host:$port');
|
debugPrint('🌐 [Provider] connectTcp() $host:$port');
|
||||||
|
await _prepareForConnectionSwitch(ConnectionMode.tcp);
|
||||||
_tcpHost = host;
|
|
||||||
_deviceInfo = _deviceInfo.copyWith(
|
|
||||||
deviceId: '$host:$port',
|
|
||||||
deviceName: host,
|
|
||||||
connectionState: ConnectionState.connecting,
|
|
||||||
);
|
|
||||||
_error = null;
|
|
||||||
_supportsAutoaddConfig = null;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
// Create fresh TCP service and wire its callbacks
|
// Create fresh TCP service and wire its callbacks
|
||||||
_tcpService?.dispose();
|
await _disposeTcpService();
|
||||||
_tcpService = MeshCoreTcpService();
|
_tcpService = MeshCoreTcpService();
|
||||||
_wireServiceCallbacks(_tcpService!);
|
_wireServiceCallbacks(_tcpService!);
|
||||||
|
_beginConnectionAttempt(
|
||||||
_connectionMode = ConnectionMode.tcp;
|
mode: ConnectionMode.tcp,
|
||||||
|
deviceId: '$host:$port',
|
||||||
|
deviceName: host,
|
||||||
|
tcpHost: host,
|
||||||
|
);
|
||||||
|
|
||||||
final success = await _tcpService!.connect(host, port);
|
final success = await _tcpService!.connect(host, port);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
@@ -699,21 +753,8 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Disconnect from TCP/WiFi device
|
/// Disconnect from TCP/WiFi device
|
||||||
Future<void> disconnectTcp() async {
|
Future<void> disconnectTcp() async {
|
||||||
if (_tcpService != null) {
|
await _disposeTcpService();
|
||||||
await _tcpService!.disconnect();
|
_resetConnectionSession();
|
||||||
_tcpService!.dispose();
|
|
||||||
_tcpService = null;
|
|
||||||
}
|
|
||||||
_tcpHost = null;
|
|
||||||
_connectionMode = ConnectionMode.ble;
|
|
||||||
_supportsAutoaddConfig = null;
|
|
||||||
_resetSyncState();
|
|
||||||
_deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected);
|
|
||||||
_roomLoginManager.clearRoomLoginStates();
|
|
||||||
_pingTracker.clearAll();
|
|
||||||
_pendingSendOperations.clear();
|
|
||||||
_messageDeliveryTracker.clearTracking();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect via USB serial using a pre-configured [MeshCoreSerialService].
|
/// Connect via USB serial using a pre-configured [MeshCoreSerialService].
|
||||||
@@ -723,20 +764,15 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
/// After this call succeeds, [service.markConnected()] has already run.
|
/// After this call succeeds, [service.markConnected()] has already run.
|
||||||
Future<bool> connectSerial(MeshCoreSerialService service) async {
|
Future<bool> connectSerial(MeshCoreSerialService service) async {
|
||||||
debugPrint('🔌 [Provider] connectSerial()');
|
debugPrint('🔌 [Provider] connectSerial()');
|
||||||
|
await _prepareForConnectionSwitch(ConnectionMode.usb);
|
||||||
_deviceInfo = _deviceInfo.copyWith(
|
_disposeSerialService();
|
||||||
deviceId: 'usb',
|
|
||||||
deviceName: 'USB Companion',
|
|
||||||
connectionState: ConnectionState.connecting,
|
|
||||||
);
|
|
||||||
_error = null;
|
|
||||||
_supportsAutoaddConfig = null;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
_serialService?.dispose();
|
|
||||||
_serialService = service;
|
_serialService = service;
|
||||||
_wireServiceCallbacks(_serialService!);
|
_wireServiceCallbacks(_serialService!);
|
||||||
_connectionMode = ConnectionMode.usb;
|
_beginConnectionAttempt(
|
||||||
|
mode: ConnectionMode.usb,
|
||||||
|
deviceId: 'usb',
|
||||||
|
deviceName: 'USB Companion',
|
||||||
|
);
|
||||||
|
|
||||||
// markConnected() should already have been called by the transport.
|
// markConnected() should already have been called by the transport.
|
||||||
// If it hasn't, the service won't be connected yet.
|
// If it hasn't, the service won't be connected yet.
|
||||||
@@ -752,18 +788,8 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Disconnect from USB serial device.
|
/// Disconnect from USB serial device.
|
||||||
Future<void> disconnectSerial() async {
|
Future<void> disconnectSerial() async {
|
||||||
_serialService?.markDisconnected();
|
_disposeSerialService();
|
||||||
_serialService?.dispose();
|
_resetConnectionSession();
|
||||||
_serialService = null;
|
|
||||||
_connectionMode = ConnectionMode.ble;
|
|
||||||
_supportsAutoaddConfig = null;
|
|
||||||
_resetSyncState();
|
|
||||||
_deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected);
|
|
||||||
_roomLoginManager.clearRoomLoginStates();
|
|
||||||
_pingTracker.clearAll();
|
|
||||||
_pendingSendOperations.clear();
|
|
||||||
_messageDeliveryTracker.clearTracking();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disconnect from device
|
/// Disconnect from device
|
||||||
@@ -783,15 +809,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _bleService.disconnect();
|
await _bleService.disconnect();
|
||||||
|
_resetConnectionSession();
|
||||||
_supportsAutoaddConfig = null;
|
|
||||||
_resetSyncState();
|
|
||||||
_deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected);
|
|
||||||
_roomLoginManager.clearRoomLoginStates();
|
|
||||||
_pingTracker.clearAll();
|
|
||||||
_pendingSendOperations.clear();
|
|
||||||
_messageDeliveryTracker.clearTracking();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset message sync state so the next connect/reconnect can sync cleanly.
|
/// Reset message sync state so the next connect/reconnect can sync cleanly.
|
||||||
@@ -2267,9 +2285,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
if (!_activeService.isConnected) return null;
|
if (!_activeService.isConnected) return null;
|
||||||
try {
|
try {
|
||||||
final frame = await _activeService.exportContact(publicKey);
|
final frame = await _activeService.exportContact(publicKey);
|
||||||
final hex = frame
|
final hex = frame.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
|
||||||
.join();
|
|
||||||
return 'meshcore://$hex';
|
return 'meshcore://$hex';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('⚠️ [Provider] exportContact failed: $e');
|
debugPrint('⚠️ [Provider] exportContact failed: $e');
|
||||||
|
|||||||
@@ -926,6 +926,10 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update contact telemetry
|
/// Update contact telemetry
|
||||||
|
// Dedup: track last telemetry data hash per contact to avoid processing
|
||||||
|
// the same payload twice (0x8B + 0x8C can both fire for the same data).
|
||||||
|
final Map<String, (int, DateTime)> _lastTelemetryHash = {};
|
||||||
|
|
||||||
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
|
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
|
||||||
debugPrint('📊 [ContactsProvider] updateTelemetry() called');
|
debugPrint('📊 [ContactsProvider] updateTelemetry() called');
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -933,6 +937,21 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
debugPrint(' LPP data size: ${lppData.length} bytes');
|
debugPrint(' LPP data size: ${lppData.length} bytes');
|
||||||
|
|
||||||
|
// Deduplicate: same data for same contact within 2 seconds = skip
|
||||||
|
final prefixHex = publicKeyPrefix
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
final dataHash = lppData.fold<int>(0, (h, b) => h * 31 + b);
|
||||||
|
final now = DateTime.now();
|
||||||
|
final last = _lastTelemetryHash[prefixHex];
|
||||||
|
if (last != null &&
|
||||||
|
last.$1 == dataHash &&
|
||||||
|
now.difference(last.$2).inSeconds < 2) {
|
||||||
|
debugPrint(' ⏭️ Duplicate telemetry payload, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_lastTelemetryHash[prefixHex] = (dataHash, now);
|
||||||
|
|
||||||
// Find contact by public key prefix
|
// Find contact by public key prefix
|
||||||
final contact = _findContactByPrefix(publicKeyPrefix);
|
final contact = _findContactByPrefix(publicKeyPrefix);
|
||||||
if (contact == null) {
|
if (contact == null) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -29,7 +31,9 @@ import '../widgets/connection_dialog.dart';
|
|||||||
import '../utils/battery_display_helper.dart';
|
import '../utils/battery_display_helper.dart';
|
||||||
import '../services/developer_mode_service.dart';
|
import '../services/developer_mode_service.dart';
|
||||||
import '../services/mesh_map_nodes_service.dart';
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
|
import '../services/profile_device_key_resolver.dart';
|
||||||
import '../services/profile_manager.dart';
|
import '../services/profile_manager.dart';
|
||||||
|
import '../services/profile_workspace_coordinator.dart';
|
||||||
import '../services/profiles_feature_service.dart';
|
import '../services/profiles_feature_service.dart';
|
||||||
|
|
||||||
enum _HomeTab { messages, contacts, sensors, map }
|
enum _HomeTab { messages, contacts, sensors, map }
|
||||||
@@ -60,6 +64,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
with TickerProviderStateMixin, WidgetsBindingObserver {
|
with TickerProviderStateMixin, WidgetsBindingObserver {
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
late final AppProvider _appProvider;
|
late final AppProvider _appProvider;
|
||||||
|
late final ConnectionProvider _connectionProvider;
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
bool _isMapFullscreen = false;
|
bool _isMapFullscreen = false;
|
||||||
bool _showRxTxIndicators = true;
|
bool _showRxTxIndicators = true;
|
||||||
@@ -68,6 +73,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
bool _isContactsEnabled = true;
|
bool _isContactsEnabled = true;
|
||||||
bool _isSensorsEnabled = false;
|
bool _isSensorsEnabled = false;
|
||||||
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
|
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
|
||||||
|
String? _lastProfileDeviceKey;
|
||||||
|
|
||||||
List<_HomeTab> get _enabledTabs {
|
List<_HomeTab> get _enabledTabs {
|
||||||
return [
|
return [
|
||||||
@@ -91,6 +97,8 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_appProvider = context.read<AppProvider>();
|
_appProvider = context.read<AppProvider>();
|
||||||
|
_connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
_connectionProvider.addListener(_handleConnectionProviderChanged);
|
||||||
_isMapEnabled = _appProvider.isMapEnabled;
|
_isMapEnabled = _appProvider.isMapEnabled;
|
||||||
_isContactsEnabled = _appProvider.isContactsEnabled;
|
_isContactsEnabled = _appProvider.isContactsEnabled;
|
||||||
_isSensorsEnabled = _appProvider.isSensorsEnabled;
|
_isSensorsEnabled = _appProvider.isSensorsEnabled;
|
||||||
@@ -108,6 +116,10 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
_showPermissionDialog();
|
_showPermissionDialog();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_handleConnectionProviderChanged();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initTabController() {
|
void _initTabController() {
|
||||||
@@ -223,6 +235,36 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleConnectionProviderChanged() {
|
||||||
|
final deviceKey = ProfileDeviceKeyResolver.resolve(
|
||||||
|
deviceInfo: _connectionProvider.deviceInfo,
|
||||||
|
connectionMode: _connectionProvider.connectionMode,
|
||||||
|
);
|
||||||
|
if (_lastProfileDeviceKey == deviceKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_lastProfileDeviceKey = deviceKey;
|
||||||
|
if (deviceKey == null || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(
|
||||||
|
context
|
||||||
|
.read<ProfileWorkspaceCoordinator>()
|
||||||
|
.syncActiveProfileForCurrentDevice(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_connectionProvider.removeListener(_handleConnectionProviderChanged);
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
_appProvider.setFastLocationUiActive(false);
|
||||||
|
_appProvider.removeListener(_handleAppProviderChanged);
|
||||||
|
_tabController.removeListener(_onTabChanged);
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
void _openLiveTraffic(ConnectionProvider provider) {
|
void _openLiveTraffic(ConnectionProvider provider) {
|
||||||
openLiveTrafficScreen(context, provider);
|
openLiveTrafficScreen(context, provider);
|
||||||
}
|
}
|
||||||
@@ -257,16 +299,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
|
||||||
_appProvider.setFastLocationUiActive(false);
|
|
||||||
_appProvider.removeListener(_handleAppProviderChanged);
|
|
||||||
_tabController.removeListener(_onTabChanged);
|
|
||||||
_tabController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showPermissionDialog() {
|
void _showPermissionDialog() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
@@ -402,10 +434,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMiniSignalBars({
|
Widget _buildMiniSignalBars({required int activeBars, required Color color}) {
|
||||||
required int activeBars,
|
|
||||||
required Color color,
|
|
||||||
}) {
|
|
||||||
final inactive = Colors.grey.withValues(alpha: 0.3);
|
final inactive = Colors.grey.withValues(alpha: 0.3);
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -914,19 +943,35 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
final isTcpConnected = provider.connectionMode == ConnectionMode.tcp;
|
final isTcpConnected = provider.connectionMode == ConnectionMode.tcp;
|
||||||
|
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
// Disconnected state: show connect button
|
final buttonLabel = provider.isReconnecting
|
||||||
|
? '${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts}'
|
||||||
|
: AppLocalizations.of(context)!.connect;
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
AppLocalizations.of(context)!.appTitle,
|
AppLocalizations.of(context)!.appTitle,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Text(
|
||||||
|
provider.isReconnecting
|
||||||
|
? 'Restoring previous link'
|
||||||
|
: 'No device connected',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
ElevatedButton.icon(
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
onPressed: provider.isReconnecting
|
onPressed: provider.isReconnecting
|
||||||
? null
|
? null
|
||||||
: () => _showConnectionDialog(context),
|
: () => _showConnectionDialog(context),
|
||||||
@@ -937,22 +982,19 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
Colors.black54,
|
Colors.white70,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Icon(Icons.bluetooth, size: 18),
|
: const Icon(Icons.add_link_rounded, size: 18),
|
||||||
label: Text(
|
label: Text(buttonLabel),
|
||||||
provider.isReconnecting
|
style: FilledButton.styleFrom(
|
||||||
? '${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts}'
|
padding: const EdgeInsets.symmetric(
|
||||||
: AppLocalizations.of(context)!.connect,
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
),
|
),
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
foregroundColor: Colors.black87,
|
|
||||||
elevation: 0,
|
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1033,7 +1075,8 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
deviceInfo.signalRssi != null) ...[
|
deviceInfo.signalRssi != null) ...[
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
_buildMiniSignalBars(
|
_buildMiniSignalBars(
|
||||||
activeBars: BatteryDisplayHelper.getSignalBars(
|
activeBars:
|
||||||
|
BatteryDisplayHelper.getSignalBars(
|
||||||
deviceInfo.signalRssi!,
|
deviceInfo.signalRssi!,
|
||||||
),
|
),
|
||||||
color: signalColor,
|
color: signalColor,
|
||||||
|
|||||||
21
lib/services/profile_device_key_resolver.dart
Normal file
21
lib/services/profile_device_key_resolver.dart
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import '../models/device_info.dart';
|
||||||
|
|
||||||
|
class ProfileDeviceKeyResolver {
|
||||||
|
static String? resolve({
|
||||||
|
required DeviceInfo deviceInfo,
|
||||||
|
required ConnectionMode connectionMode,
|
||||||
|
}) {
|
||||||
|
// Always prefer the public key — it's the only truly stable identifier.
|
||||||
|
// Don't fall back to deviceId to avoid creating duplicate profiles when
|
||||||
|
// publicKey arrives late (after initial connection but before DeviceInfo).
|
||||||
|
final publicKey = deviceInfo.publicKey;
|
||||||
|
if (publicKey == null || publicKey.isEmpty) {
|
||||||
|
return null; // Wait until publicKey is available
|
||||||
|
}
|
||||||
|
|
||||||
|
final hex = publicKey
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
return hex.isNotEmpty ? 'pk:$hex' : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,11 +9,14 @@ import 'profiles_feature_service.dart';
|
|||||||
class ProfileManager with ChangeNotifier {
|
class ProfileManager with ChangeNotifier {
|
||||||
static const String _profilesKey = 'profiles_library';
|
static const String _profilesKey = 'profiles_library';
|
||||||
static const String activeProfileIdKey = 'profiles_active_profile_id';
|
static const String activeProfileIdKey = 'profiles_active_profile_id';
|
||||||
|
static const String _deviceProfileDefaultsKey =
|
||||||
|
'profiles_device_active_profile_ids';
|
||||||
static const String _transferHistoryKey = 'profiles_transfer_history';
|
static const String _transferHistoryKey = 'profiles_transfer_history';
|
||||||
|
|
||||||
final List<ConfigProfile> _customProfiles = <ConfigProfile>[];
|
final List<ConfigProfile> _customProfiles = <ConfigProfile>[];
|
||||||
final List<ProfileTransferRecord> _transferHistory =
|
final List<ProfileTransferRecord> _transferHistory =
|
||||||
<ProfileTransferRecord>[];
|
<ProfileTransferRecord>[];
|
||||||
|
final Map<String, String> _deviceProfileDefaults = <String, String>{};
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
bool _profilesEnabled = false;
|
bool _profilesEnabled = false;
|
||||||
String _activeProfileId = ConfigProfile.defaultProfileId;
|
String _activeProfileId = ConfigProfile.defaultProfileId;
|
||||||
@@ -37,6 +40,19 @@ class ProfileManager with ChangeNotifier {
|
|||||||
_activeProfileId =
|
_activeProfileId =
|
||||||
prefs.getString(activeProfileIdKey) ?? ConfigProfile.defaultProfileId;
|
prefs.getString(activeProfileIdKey) ?? ConfigProfile.defaultProfileId;
|
||||||
|
|
||||||
|
final deviceDefaultsJson = prefs.getString(_deviceProfileDefaultsKey);
|
||||||
|
if (deviceDefaultsJson != null && deviceDefaultsJson.isNotEmpty) {
|
||||||
|
final decoded = jsonDecode(deviceDefaultsJson);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
_deviceProfileDefaults
|
||||||
|
..clear()
|
||||||
|
..addAll(
|
||||||
|
decoded.map((key, value) => MapEntry(key, value?.toString() ?? ''))
|
||||||
|
..removeWhere((key, value) => value.isEmpty),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final profilesJson = prefs.getString(_profilesKey);
|
final profilesJson = prefs.getString(_profilesKey);
|
||||||
if (profilesJson != null && profilesJson.isNotEmpty) {
|
if (profilesJson != null && profilesJson.isNotEmpty) {
|
||||||
final decoded = jsonDecode(profilesJson) as List<dynamic>;
|
final decoded = jsonDecode(profilesJson) as List<dynamic>;
|
||||||
@@ -90,9 +106,37 @@ class ProfileManager with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setActiveProfileId(String id) async {
|
Future<void> setActiveProfileId(String id) async {
|
||||||
|
await setActiveProfileIdForDevice(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
String profileIdForDevice(String? deviceKey) {
|
||||||
|
if (deviceKey == null || deviceKey.isEmpty) {
|
||||||
|
return _activeProfileId;
|
||||||
|
}
|
||||||
|
return _deviceProfileDefaults[deviceKey] ?? ConfigProfile.defaultProfileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasProfileForDevice(String? deviceKey) {
|
||||||
|
if (deviceKey == null || deviceKey.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return _deviceProfileDefaults.containsKey(deviceKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setActiveProfileIdForDevice(
|
||||||
|
String id, {
|
||||||
|
String? deviceKey,
|
||||||
|
}) async {
|
||||||
_activeProfileId = id;
|
_activeProfileId = id;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(activeProfileIdKey, id);
|
await prefs.setString(activeProfileIdKey, id);
|
||||||
|
if (deviceKey != null && deviceKey.isNotEmpty) {
|
||||||
|
_deviceProfileDefaults[deviceKey] = id;
|
||||||
|
await prefs.setString(
|
||||||
|
_deviceProfileDefaultsKey,
|
||||||
|
jsonEncode(_deviceProfileDefaults),
|
||||||
|
);
|
||||||
|
}
|
||||||
ProfileStorageScope.setScope(
|
ProfileStorageScope.setScope(
|
||||||
profilesEnabled: _profilesEnabled,
|
profilesEnabled: _profilesEnabled,
|
||||||
activeProfileId: _activeProfileId,
|
activeProfileId: _activeProfileId,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import 'contact_storage_service.dart';
|
|||||||
import 'device_config_applicator.dart';
|
import 'device_config_applicator.dart';
|
||||||
import 'message_storage_service.dart';
|
import 'message_storage_service.dart';
|
||||||
import 'profile_manager.dart';
|
import 'profile_manager.dart';
|
||||||
|
import 'profile_device_key_resolver.dart';
|
||||||
import 'profiles_feature_service.dart';
|
import 'profiles_feature_service.dart';
|
||||||
import 'map_workspace_snapshot_service.dart';
|
import 'map_workspace_snapshot_service.dart';
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ class ProfileWorkspaceCoordinator {
|
|||||||
final DeviceConfigApplicator _deviceConfigApplicator;
|
final DeviceConfigApplicator _deviceConfigApplicator;
|
||||||
final MessageStorageService _messageStorageService;
|
final MessageStorageService _messageStorageService;
|
||||||
final ContactStorageService _contactStorageService;
|
final ContactStorageService _contactStorageService;
|
||||||
|
bool _isSyncingDeviceProfile = false;
|
||||||
|
|
||||||
Future<void> setProfilesEnabled(bool enabled) async {
|
Future<void> setProfilesEnabled(bool enabled) async {
|
||||||
final wasEnabled = profileManager.profilesEnabled;
|
final wasEnabled = profileManager.profilesEnabled;
|
||||||
@@ -77,6 +79,7 @@ class ProfileWorkspaceCoordinator {
|
|||||||
activeProfileId: enabled ? profileManager.activeProfileId : 'default',
|
activeProfileId: enabled ? profileManager.activeProfileId : 'default',
|
||||||
);
|
);
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
|
await _ensureProfileForCurrentDevice();
|
||||||
if (wasEnabled) {
|
if (wasEnabled) {
|
||||||
await openProfile(profileManager.activeProfileId);
|
await openProfile(profileManager.activeProfileId);
|
||||||
} else {
|
} else {
|
||||||
@@ -181,9 +184,13 @@ class ProfileWorkspaceCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> openProfile(String profileId) async {
|
Future<void> openProfile(String profileId) async {
|
||||||
|
final deviceKey = _currentDeviceProfileKey;
|
||||||
await _saveActiveCustomProfileSnapshot();
|
await _saveActiveCustomProfileSnapshot();
|
||||||
await connectionProvider.disconnect();
|
await connectionProvider.disconnect();
|
||||||
await profileManager.setActiveProfileId(profileId);
|
await profileManager.setActiveProfileIdForDevice(
|
||||||
|
profileId,
|
||||||
|
deviceKey: deviceKey,
|
||||||
|
);
|
||||||
await _switchRuntimeScope(profileId);
|
await _switchRuntimeScope(profileId);
|
||||||
|
|
||||||
final profile = await resolveProfile(profileId);
|
final profile = await resolveProfile(profileId);
|
||||||
@@ -271,6 +278,99 @@ class ProfileWorkspaceCoordinator {
|
|||||||
return imported;
|
return imported;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> syncActiveProfileForCurrentDevice() async {
|
||||||
|
if (!profileManager.profilesEnabled || _isSyncingDeviceProfile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final deviceKey = _currentDeviceProfileKey;
|
||||||
|
if (deviceKey == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isSyncingDeviceProfile = true;
|
||||||
|
try {
|
||||||
|
final profile = await _ensureProfileForCurrentDevice();
|
||||||
|
final targetProfileId = profile.id;
|
||||||
|
if (targetProfileId == profileManager.activeProfileId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _persistCurrentState();
|
||||||
|
await profileManager.setActiveProfileIdForDevice(
|
||||||
|
targetProfileId,
|
||||||
|
deviceKey: deviceKey,
|
||||||
|
);
|
||||||
|
await _switchRuntimeScope(targetProfileId);
|
||||||
|
|
||||||
|
await _appConfigSnapshotService.apply(
|
||||||
|
profile.sections.appSettings,
|
||||||
|
appProvider,
|
||||||
|
);
|
||||||
|
await _mapWorkspaceSnapshotService.apply(
|
||||||
|
profile.sections.mapWorkspace,
|
||||||
|
mapProvider: mapProvider,
|
||||||
|
drawingProvider: drawingProvider,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
_isSyncingDeviceProfile = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ConfigProfile> _ensureProfileForCurrentDevice() async {
|
||||||
|
final deviceKey = _currentDeviceProfileKey;
|
||||||
|
final targetProfileId = profileManager.profileIdForDevice(deviceKey);
|
||||||
|
final existingProfile = profileManager.getProfile(targetProfileId);
|
||||||
|
if (profileManager.hasProfileForDevice(deviceKey) &&
|
||||||
|
existingProfile != null) {
|
||||||
|
return existingProfile;
|
||||||
|
}
|
||||||
|
if (profileManager.hasProfileForDevice(deviceKey) &&
|
||||||
|
targetProfileId == ConfigProfile.defaultProfileId) {
|
||||||
|
return ConfigProfile.defaultProfile();
|
||||||
|
}
|
||||||
|
if (deviceKey == null) {
|
||||||
|
return await resolveProfile(profileManager.activeProfileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
final profile = await createProfileFromCurrent(
|
||||||
|
name: _buildDeviceProfileName(),
|
||||||
|
);
|
||||||
|
await profileManager.setActiveProfileIdForDevice(
|
||||||
|
profile.id,
|
||||||
|
deviceKey: deviceKey,
|
||||||
|
);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildDeviceProfileName() {
|
||||||
|
final deviceInfo = connectionProvider.deviceInfo;
|
||||||
|
final name = deviceInfo.selfName?.trim();
|
||||||
|
if (name != null && name.isNotEmpty) {
|
||||||
|
return 'Device ${_sanitizeProfileLabel(name)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
final displayName = deviceInfo.displayName?.trim();
|
||||||
|
if (displayName != null && displayName.isNotEmpty) {
|
||||||
|
return 'Device ${_sanitizeProfileLabel(displayName)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
final deviceId = deviceInfo.deviceId?.trim();
|
||||||
|
if (deviceId != null && deviceId.isNotEmpty) {
|
||||||
|
return 'Device ${_sanitizeProfileLabel(deviceId)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Device Profile';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _sanitizeProfileLabel(String value) {
|
||||||
|
return value.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get _currentDeviceProfileKey => ProfileDeviceKeyResolver.resolve(
|
||||||
|
deviceInfo: connectionProvider.deviceInfo,
|
||||||
|
connectionMode: connectionProvider.connectionMode,
|
||||||
|
);
|
||||||
|
|
||||||
Future<void> _switchRuntimeScope(String profileId) async {
|
Future<void> _switchRuntimeScope(String profileId) async {
|
||||||
final runtimeProfilesEnabled = profileManager.profilesEnabled;
|
final runtimeProfilesEnabled = profileManager.profilesEnabled;
|
||||||
ProfileStorageScope.setScope(
|
ProfileStorageScope.setScope(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class ProfilesFeatureService {
|
|||||||
|
|
||||||
static Future<bool> isEnabled() async {
|
static Future<bool> isEnabled() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
return prefs.getBool(enabledKey) ?? false;
|
return prefs.getBool(enabledKey) ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setEnabled(bool enabled) async {
|
static Future<void> setEnabled(bool enabled) async {
|
||||||
@@ -15,7 +15,7 @@ class ProfilesFeatureService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ProfileStorageScope {
|
class ProfileStorageScope {
|
||||||
static bool _profilesEnabled = false;
|
static bool _profilesEnabled = true;
|
||||||
static String _activeProfileId = 'default';
|
static String _activeProfileId = 'default';
|
||||||
|
|
||||||
static Future<void> bootstrap({
|
static Future<void> bootstrap({
|
||||||
|
|||||||
54
lib/utils/link_quality.dart
Normal file
54
lib/utils/link_quality.dart
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
int rssiScore(int rssiDbm) {
|
||||||
|
if (rssiDbm >= -60) return 5;
|
||||||
|
if (rssiDbm >= -70) return 4;
|
||||||
|
if (rssiDbm >= -80) return 3;
|
||||||
|
if (rssiDbm >= -90) return 2;
|
||||||
|
if (rssiDbm >= -100) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int snrScore(double snrDb) {
|
||||||
|
if (snrDb >= 10) return 5;
|
||||||
|
if (snrDb >= 5) return 4;
|
||||||
|
if (snrDb >= 0) return 3;
|
||||||
|
if (snrDb >= -5) return 2;
|
||||||
|
if (snrDb >= -10) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
String linkQualityLabel(int? rssiDbm, double? snrDb) {
|
||||||
|
var totalScore = 0;
|
||||||
|
var metricCount = 0;
|
||||||
|
|
||||||
|
if (rssiDbm != null) {
|
||||||
|
totalScore += rssiScore(rssiDbm);
|
||||||
|
metricCount += 1;
|
||||||
|
}
|
||||||
|
if (snrDb != null) {
|
||||||
|
totalScore += snrScore(snrDb);
|
||||||
|
metricCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metricCount == 0) return 'Weak';
|
||||||
|
|
||||||
|
final averageScore = totalScore / metricCount;
|
||||||
|
if (averageScore >= 4.5) return 'Excellent';
|
||||||
|
if (averageScore >= 3.5) return 'Good';
|
||||||
|
if (averageScore >= 2.5) return 'Fair';
|
||||||
|
return 'Weak';
|
||||||
|
}
|
||||||
|
|
||||||
|
Color linkQualityColor(String quality) {
|
||||||
|
switch (quality) {
|
||||||
|
case 'Excellent':
|
||||||
|
return Colors.green;
|
||||||
|
case 'Good':
|
||||||
|
return Colors.lightGreen;
|
||||||
|
case 'Fair':
|
||||||
|
return Colors.orange;
|
||||||
|
default:
|
||||||
|
return Colors.redAccent;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:meshcore_client/meshcore_client.dart' hide Contact;
|
import 'package:meshcore_client/meshcore_client.dart' hide Contact;
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import 'package:usb_serial/usb_serial.dart';
|
import 'package:usb_serial/usb_serial.dart';
|
||||||
import '../providers/connection_provider.dart';
|
|
||||||
import '../providers/app_provider.dart';
|
|
||||||
import '../services/network_scanner_service.dart';
|
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../providers/app_provider.dart';
|
||||||
|
import '../providers/connection_provider.dart';
|
||||||
|
import '../services/network_scanner_service.dart';
|
||||||
|
|
||||||
/// Connection Dialog with tabs for BLE devices and Network servers
|
/// Connection Dialog with tabs for BLE devices and Network servers
|
||||||
class ConnectionDialog extends StatefulWidget {
|
class ConnectionDialog extends StatefulWidget {
|
||||||
@@ -25,10 +26,9 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
int _scannedCount = 0;
|
int _scannedCount = 0;
|
||||||
int _totalToScan = 0;
|
int _totalToScan = 0;
|
||||||
int _lastTabIndex = 0;
|
int _lastTabIndex = 0;
|
||||||
String?
|
String? _connectingToServerKey;
|
||||||
_connectingToServerKey; // Track which server is being connected to (ip:port)
|
String? _connectingBleDeviceId;
|
||||||
|
|
||||||
// Named listener method for proper cleanup
|
|
||||||
void _onTabChanged() {
|
void _onTabChanged() {
|
||||||
if (_tabController.index == _lastTabIndex) return;
|
if (_tabController.index == _lastTabIndex) return;
|
||||||
_lastTabIndex = _tabController.index;
|
_lastTabIndex = _tabController.index;
|
||||||
@@ -38,18 +38,12 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_tabController.index == 1) {
|
if (_tabController.index == 1) {
|
||||||
// Switched to network tab
|
|
||||||
if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) {
|
if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) {
|
||||||
// Load cached results
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_discoveredServers.addAll(_networkScanner.cachedServers);
|
_discoveredServers.addAll(_networkScanner.cachedServers);
|
||||||
});
|
});
|
||||||
debugPrint(
|
|
||||||
'📦 [NetworkScanner] Loaded ${_discoveredServers.length} servers from cache',
|
|
||||||
);
|
|
||||||
} else if (!_networkScanner.isScanning &&
|
} else if (!_networkScanner.isScanning &&
|
||||||
!_networkScanner.hasCachedResults) {
|
!_networkScanner.hasCachedResults) {
|
||||||
// No cache, start initial scan
|
|
||||||
_startNetworkScan();
|
_startNetworkScan();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,35 +58,28 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
listen: false,
|
listen: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Defer scan startup until after the first frame so Provider listeners
|
|
||||||
// are not notified while this dialog is still being built.
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_refreshBleDevices();
|
_refreshBleDevices();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up network scanner callbacks
|
|
||||||
_networkScanner.onServerDiscovered = (server) {
|
_networkScanner.onServerDiscovered = (server) {
|
||||||
if (mounted) {
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
// Only add if not already in the list (deduplicate)
|
|
||||||
if (!_discoveredServers.contains(server)) {
|
if (!_discoveredServers.contains(server)) {
|
||||||
_discoveredServers.add(server);
|
_discoveredServers.add(server);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
_networkScanner.onProgressUpdate = (scanned, total) {
|
_networkScanner.onProgressUpdate = (scanned, total) {
|
||||||
if (mounted) {
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_scannedCount = scanned;
|
_scannedCount = scanned;
|
||||||
_totalToScan = total;
|
_totalToScan = total;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Listen to tab changes using named method for proper cleanup
|
|
||||||
_tabController.addListener(_onTabChanged);
|
_tabController.addListener(_onTabChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +87,6 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_connectionProvider.stopScan();
|
_connectionProvider.stopScan();
|
||||||
_networkScanner.stopScan();
|
_networkScanner.stopScan();
|
||||||
// Remove listener before disposing to prevent memory leaks
|
|
||||||
_tabController.removeListener(_onTabChanged);
|
_tabController.removeListener(_onTabChanged);
|
||||||
_tabController.dispose();
|
_tabController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -112,7 +98,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
_scannedCount = 0;
|
_scannedCount = 0;
|
||||||
_totalToScan = 0;
|
_totalToScan = 0;
|
||||||
});
|
});
|
||||||
_networkScanner.clearCache(); // Clear cache before starting new scan
|
_networkScanner.clearCache();
|
||||||
_networkScanner.scan();
|
_networkScanner.scan();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,78 +117,94 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final connectionProvider = context.watch<ConnectionProvider>();
|
final connectionProvider = context.watch<ConnectionProvider>();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: MediaQuery.of(context).size.height * 0.9,
|
height: MediaQuery.of(context).size.height * 0.9,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: theme.colorScheme.surface,
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Header
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: const BorderRadius.vertical(
|
borderRadius: const BorderRadius.vertical(
|
||||||
top: Radius.circular(20),
|
top: Radius.circular(24),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant.withValues(
|
||||||
|
alpha: 0.35,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: const Icon(Icons.close_rounded),
|
||||||
Icons.arrow_back,
|
onPressed: () => Navigator.pop(context),
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Column(
|
||||||
AppLocalizations.of(context)!.appTitle,
|
children: [
|
||||||
|
Text(
|
||||||
|
'Connect Device',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
fontWeight: FontWeight.w700,
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Choose Bluetooth, WiFi, or USB transport',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 48), // Balance the back button
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
// Tab Bar
|
const SizedBox(width: 48),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
TabBar(
|
TabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
|
dividerColor: Colors.transparent,
|
||||||
|
indicator: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
indicatorSize: TabBarIndicatorSize.tab,
|
||||||
|
labelColor: theme.colorScheme.onPrimaryContainer,
|
||||||
|
unselectedLabelColor: theme.colorScheme.onSurfaceVariant,
|
||||||
tabs: const [
|
tabs: const [
|
||||||
Tab(text: 'BLE', icon: Icon(Icons.bluetooth)),
|
Tab(text: 'BLE', icon: Icon(Icons.bluetooth_rounded)),
|
||||||
Tab(text: 'Network', icon: Icon(Icons.wifi)),
|
Tab(text: 'Network', icon: Icon(Icons.wifi_rounded)),
|
||||||
Tab(text: 'USB', icon: Icon(Icons.usb)),
|
Tab(text: 'USB', icon: Icon(Icons.usb_rounded)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Tab Content
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
children: [
|
children: [
|
||||||
// BLE Devices Tab
|
|
||||||
_buildBleDevicesTab(connectionProvider),
|
_buildBleDevicesTab(connectionProvider),
|
||||||
|
|
||||||
// Network Servers Tab
|
|
||||||
_buildNetworkServersTab(),
|
_buildNetworkServersTab(),
|
||||||
|
_buildUsbTab(),
|
||||||
// USB Serial Tab
|
|
||||||
_buildUsbTab(connectionProvider),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -211,78 +213,175 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBleDevicesTab(ConnectionProvider connectionProvider) {
|
Widget _buildSectionBanner({
|
||||||
return Column(
|
required IconData icon,
|
||||||
children: [
|
required String message,
|
||||||
// Info banner
|
required VoidCallback onRefresh,
|
||||||
Container(
|
}) {
|
||||||
margin: const EdgeInsets.all(16),
|
final theme = Theme.of(context);
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.72),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(icon, color: theme.colorScheme.onPrimaryContainer),
|
||||||
Icons.info_outline,
|
const SizedBox(width: 12),
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
SizedBox(width: 12),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
AppLocalizations.of(context)!.defaultPinInfo,
|
message,
|
||||||
style: TextStyle(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
fontSize: 13,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.refresh,
|
Icons.refresh_rounded,
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
),
|
),
|
||||||
onPressed: _refreshBleDevices,
|
onPressed: onRefresh,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState({
|
||||||
|
required IconData icon,
|
||||||
|
required String title,
|
||||||
|
required String actionLabel,
|
||||||
|
required VoidCallback onAction,
|
||||||
|
}) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
size: 36,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant.withValues(
|
||||||
|
alpha: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
onPressed: onAction,
|
||||||
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
|
label: Text(actionLabel),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Device list
|
Widget _buildTransportCard({
|
||||||
|
required IconData icon,
|
||||||
|
required Color iconColor,
|
||||||
|
required String title,
|
||||||
|
required String subtitle,
|
||||||
|
required Widget trailing,
|
||||||
|
VoidCallback? onTap,
|
||||||
|
bool enabled = true,
|
||||||
|
}) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
side: BorderSide(
|
||||||
|
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
onTap: enabled ? onTap : null,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: iconColor.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: iconColor),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
trailing,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBleDevicesTab(ConnectionProvider connectionProvider) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_buildSectionBanner(
|
||||||
|
icon: Icons.bluetooth_searching_rounded,
|
||||||
|
message: AppLocalizations.of(context)!.defaultPinInfo,
|
||||||
|
onRefresh: _refreshBleDevices,
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child:
|
child:
|
||||||
connectionProvider.isScanning &&
|
connectionProvider.isScanning &&
|
||||||
connectionProvider.scannedDevices.isEmpty
|
connectionProvider.scannedDevices.isEmpty
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: connectionProvider.scannedDevices.isEmpty
|
: connectionProvider.scannedDevices.isEmpty
|
||||||
? Center(
|
? _buildEmptyState(
|
||||||
child: Column(
|
icon: Icons.bluetooth_searching_rounded,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
title: AppLocalizations.of(context)!.noDevicesFound,
|
||||||
children: [
|
actionLabel: AppLocalizations.of(context)!.scanAgain,
|
||||||
Icon(
|
onAction: _refreshBleDevices,
|
||||||
Icons.bluetooth_searching,
|
|
||||||
size: 64,
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
AppLocalizations.of(context)!.noDevicesFound,
|
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: _refreshBleDevices,
|
|
||||||
icon: Icon(Icons.refresh),
|
|
||||||
label: Text(AppLocalizations.of(context)!.scanAgain),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
itemCount: connectionProvider.scannedDevices.length,
|
itemCount: connectionProvider.scannedDevices.length,
|
||||||
@@ -292,74 +391,16 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
final device = scannedDevice.device;
|
final device = scannedDevice.device;
|
||||||
final rssi = scannedDevice.rssi;
|
final rssi = scannedDevice.rssi;
|
||||||
final signalColor = _getSignalColor(rssi);
|
final signalColor = _getSignalColor(rssi);
|
||||||
|
final deviceId = device.remoteId.toString();
|
||||||
|
final isConnecting = _connectingBleDeviceId == deviceId;
|
||||||
|
|
||||||
return Container(
|
Future<void> connectBle() async {
|
||||||
margin: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 16,
|
|
||||||
vertical: 4,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.outline.withValues(alpha: 0.2),
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: ListTile(
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 16,
|
|
||||||
vertical: 8,
|
|
||||||
),
|
|
||||||
leading: Icon(
|
|
||||||
Icons.bluetooth,
|
|
||||||
color: signalColor,
|
|
||||||
size: 32,
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
device.platformName.isNotEmpty
|
|
||||||
? device.platformName
|
|
||||||
: 'Unknown Device',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
subtitle: Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
AppLocalizations.of(context)!.tapToConnect,
|
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onSurfaceVariant,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'$rssi dBm',
|
|
||||||
style: TextStyle(
|
|
||||||
color: signalColor,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
trailing: Icon(
|
|
||||||
Icons.chevron_right,
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
onTap: () async {
|
|
||||||
final appProvider = context.read<AppProvider>();
|
final appProvider = context.read<AppProvider>();
|
||||||
|
setState(() {
|
||||||
|
_connectingBleDeviceId = deviceId;
|
||||||
|
});
|
||||||
|
try {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
|
|
||||||
final success = await connectionProvider.connect(
|
final success = await connectionProvider.connect(
|
||||||
device,
|
device,
|
||||||
);
|
);
|
||||||
@@ -367,8 +408,36 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
connectionProvider.deviceInfo.isConnected) {
|
connectionProvider.deviceInfo.isConnected) {
|
||||||
await appProvider.initialize();
|
await appProvider.initialize();
|
||||||
}
|
}
|
||||||
},
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_connectingBleDeviceId = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _buildTransportCard(
|
||||||
|
icon: Icons.bluetooth_rounded,
|
||||||
|
iconColor: signalColor,
|
||||||
|
title: device.platformName.isNotEmpty
|
||||||
|
? device.platformName
|
||||||
|
: 'Unknown Device',
|
||||||
|
subtitle: 'Signal $rssi dBm',
|
||||||
|
trailing: isConnecting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5,
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
: FilledButton.tonal(
|
||||||
|
onPressed: connectBle,
|
||||||
|
child: const Text('Connect'),
|
||||||
|
),
|
||||||
|
onTap: isConnecting ? null : connectBle,
|
||||||
|
enabled: !isConnecting,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -385,44 +454,15 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// Info banner
|
_buildSectionBanner(
|
||||||
Container(
|
icon: showingCachedResults
|
||||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
? Icons.cached_rounded
|
||||||
padding: const EdgeInsets.all(16),
|
: Icons.wifi_find_rounded,
|
||||||
decoration: BoxDecoration(
|
message: showingCachedResults
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
showingCachedResults ? Icons.cached : Icons.info_outline,
|
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
showingCachedResults
|
|
||||||
? 'Showing cached results. Tap refresh to rescan.'
|
? 'Showing cached results. Tap refresh to rescan.'
|
||||||
: 'Scanning local network for MeshCore WiFi devices on port 5000',
|
: 'Scanning local network for MeshCore WiFi devices on port 5000',
|
||||||
style: TextStyle(
|
onRefresh: _startNetworkScan,
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
Icons.refresh,
|
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
onPressed: _startNetworkScan,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Scan progress
|
|
||||||
if (_networkScanner.isScanning)
|
if (_networkScanner.isScanning)
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
@@ -439,39 +479,15 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Server list
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _networkScanner.isScanning && _discoveredServers.isEmpty
|
child: _networkScanner.isScanning && _discoveredServers.isEmpty
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _discoveredServers.isEmpty
|
: _discoveredServers.isEmpty
|
||||||
? Center(
|
? _buildEmptyState(
|
||||||
child: Column(
|
icon: Icons.wifi_off_rounded,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
title: 'No servers found',
|
||||||
children: [
|
actionLabel: 'Scan Again',
|
||||||
Icon(
|
onAction: _startNetworkScan,
|
||||||
Icons.wifi_off,
|
|
||||||
size: 64,
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No servers found',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: _startNetworkScan,
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
label: const Text('Scan Again'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
itemCount: _discoveredServers.length,
|
itemCount: _discoveredServers.length,
|
||||||
@@ -483,100 +499,24 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
final isAnyConnectionInProgress =
|
final isAnyConnectionInProgress =
|
||||||
_connectingToServerKey != null;
|
_connectingToServerKey != null;
|
||||||
|
|
||||||
return Container(
|
Future<void> connectServer() async {
|
||||||
margin: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 16,
|
|
||||||
vertical: 4,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(
|
|
||||||
color: isConnectingToThisServer
|
|
||||||
? Theme.of(context).colorScheme.primary
|
|
||||||
: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.outline.withValues(alpha: 0.2),
|
|
||||||
width: isConnectingToThisServer ? 2 : 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: ListTile(
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 16,
|
|
||||||
vertical: 8,
|
|
||||||
),
|
|
||||||
leading: isConnectingToThisServer
|
|
||||||
? SizedBox(
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 3,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Icon(
|
|
||||||
Icons.wifi,
|
|
||||||
color: Colors.green,
|
|
||||||
size: 32,
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
server.ipAddress,
|
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
isConnectingToThisServer
|
|
||||||
? 'Connecting...'
|
|
||||||
: 'Port ${server.port} • ${server.responseTime}ms',
|
|
||||||
style: TextStyle(
|
|
||||||
color: isConnectingToThisServer
|
|
||||||
? Theme.of(context).colorScheme.primary
|
|
||||||
: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onSurfaceVariant,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: isConnectingToThisServer
|
|
||||||
? FontWeight.w500
|
|
||||||
: FontWeight.normal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
trailing: isConnectingToThisServer
|
|
||||||
? null
|
|
||||||
: Icon(
|
|
||||||
Icons.chevron_right,
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
enabled: !isAnyConnectionInProgress,
|
|
||||||
onTap: isAnyConnectionInProgress
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
// Capture context-dependent objects before async operations
|
|
||||||
final connectionProvider = context
|
final connectionProvider = context
|
||||||
.read<ConnectionProvider>();
|
.read<ConnectionProvider>();
|
||||||
final appProvider = context.read<AppProvider>();
|
final appProvider = context.read<AppProvider>();
|
||||||
final navigator = Navigator.of(context);
|
final navigator = Navigator.of(context);
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
|
||||||
// Mark this server as connecting
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_connectingToServerKey = serverKey;
|
_connectingToServerKey = serverKey;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Pre-verify server is still available
|
final isAvailable = await _networkScanner.verifyServer(
|
||||||
final isAvailable = await _networkScanner
|
server,
|
||||||
.verifyServer(server);
|
);
|
||||||
if (!isAvailable) {
|
if (!isAvailable) {
|
||||||
throw Exception(
|
throw Exception(
|
||||||
'Server at ${server.ipAddress}:${server.port} is no longer available. '
|
'Server at ${server.ipAddress}:${server.port} is no longer available. Please scan again to find active servers.',
|
||||||
'Please scan again to find active servers.',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,17 +530,13 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
navigator.pop();
|
navigator.pop();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Clear connecting state on error
|
if (!mounted) return;
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_connectingToServerKey = null;
|
_connectingToServerKey = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up error message (remove "Exception: " prefix)
|
var errorMessage = e.toString();
|
||||||
String errorMessage = e.toString();
|
if (errorMessage.startsWith('Exception: ')) {
|
||||||
if (errorMessage.startsWith(
|
|
||||||
'Exception: ',
|
|
||||||
)) {
|
|
||||||
errorMessage = errorMessage.substring(
|
errorMessage = errorMessage.substring(
|
||||||
'Exception: '.length,
|
'Exception: '.length,
|
||||||
);
|
);
|
||||||
@@ -628,8 +564,30 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
return _buildTransportCard(
|
||||||
|
icon: Icons.wifi_rounded,
|
||||||
|
iconColor: Colors.green,
|
||||||
|
title: server.ipAddress,
|
||||||
|
subtitle: isConnectingToThisServer
|
||||||
|
? 'Connecting...'
|
||||||
|
: 'Port ${server.port} • ${server.responseTime}ms',
|
||||||
|
trailing: isConnectingToThisServer
|
||||||
|
? const SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5,
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
: FilledButton.tonal(
|
||||||
|
onPressed: isAnyConnectionInProgress
|
||||||
|
? null
|
||||||
|
: connectServer,
|
||||||
|
child: const Text('Connect'),
|
||||||
|
),
|
||||||
|
enabled: !isAnyConnectionInProgress,
|
||||||
|
onTap: isAnyConnectionInProgress ? null : connectServer,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -638,8 +596,38 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildUsbTab(ConnectionProvider connectionProvider) {
|
Widget _buildUsbTab() {
|
||||||
return _UsbDeviceList(
|
return _UsbDeviceList(
|
||||||
|
buildTransportCard:
|
||||||
|
({
|
||||||
|
required icon,
|
||||||
|
required iconColor,
|
||||||
|
required title,
|
||||||
|
required subtitle,
|
||||||
|
required trailing,
|
||||||
|
onTap,
|
||||||
|
enabled = true,
|
||||||
|
}) => _buildTransportCard(
|
||||||
|
icon: icon,
|
||||||
|
iconColor: iconColor,
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
trailing: trailing,
|
||||||
|
onTap: onTap,
|
||||||
|
enabled: enabled,
|
||||||
|
),
|
||||||
|
buildEmptyState:
|
||||||
|
({
|
||||||
|
required icon,
|
||||||
|
required title,
|
||||||
|
required actionLabel,
|
||||||
|
required onAction,
|
||||||
|
}) => _buildEmptyState(
|
||||||
|
icon: icon,
|
||||||
|
title: title,
|
||||||
|
actionLabel: actionLabel,
|
||||||
|
onAction: onAction,
|
||||||
|
),
|
||||||
onConnected: () {
|
onConnected: () {
|
||||||
if (mounted) Navigator.of(context).pop();
|
if (mounted) Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
@@ -647,10 +635,35 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
typedef _TransportCardBuilder =
|
||||||
|
Widget Function({
|
||||||
|
required IconData icon,
|
||||||
|
required Color iconColor,
|
||||||
|
required String title,
|
||||||
|
required String subtitle,
|
||||||
|
required Widget trailing,
|
||||||
|
VoidCallback? onTap,
|
||||||
|
bool enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
typedef _EmptyStateBuilder =
|
||||||
|
Widget Function({
|
||||||
|
required IconData icon,
|
||||||
|
required String title,
|
||||||
|
required String actionLabel,
|
||||||
|
required VoidCallback onAction,
|
||||||
|
});
|
||||||
|
|
||||||
class _UsbDeviceList extends StatefulWidget {
|
class _UsbDeviceList extends StatefulWidget {
|
||||||
final VoidCallback onConnected;
|
final VoidCallback onConnected;
|
||||||
|
final _TransportCardBuilder buildTransportCard;
|
||||||
|
final _EmptyStateBuilder buildEmptyState;
|
||||||
|
|
||||||
const _UsbDeviceList({required this.onConnected});
|
const _UsbDeviceList({
|
||||||
|
required this.onConnected,
|
||||||
|
required this.buildTransportCard,
|
||||||
|
required this.buildEmptyState,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_UsbDeviceList> createState() => _UsbDeviceListState();
|
State<_UsbDeviceList> createState() => _UsbDeviceListState();
|
||||||
@@ -678,7 +691,7 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
|
|||||||
_devices = devices;
|
_devices = devices;
|
||||||
_isScanning = false;
|
_isScanning = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_devices = [];
|
_devices = [];
|
||||||
@@ -767,9 +780,9 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _isConnecting = false);
|
setState(() => _isConnecting = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('USB error: $e')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('USB error: $e')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -796,20 +809,21 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
|
|||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
child: OutlinedButton.icon(
|
child: FilledButton.tonalIcon(
|
||||||
onPressed: _isConnecting ? null : _scanDevices,
|
onPressed: _isConnecting ? null : _scanDevices,
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.usb_rounded),
|
||||||
label: const Text('Scan USB devices'),
|
label: const Text('Scan USB devices'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_devices.isEmpty)
|
if (_devices.isEmpty)
|
||||||
const Expanded(
|
Expanded(
|
||||||
child: Center(
|
child: widget.buildEmptyState(
|
||||||
child: Text(
|
icon: Icons.usb_off_rounded,
|
||||||
|
title:
|
||||||
'No USB serial devices found.\nConnect a MeshCore device via OTG cable.',
|
'No USB serial devices found.\nConnect a MeshCore device via OTG cable.',
|
||||||
textAlign: TextAlign.center,
|
actionLabel: 'Scan USB devices',
|
||||||
),
|
onAction: _scanDevices,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
@@ -818,17 +832,26 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
|
|||||||
itemCount: _devices.length,
|
itemCount: _devices.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final device = _devices[index];
|
final device = _devices[index];
|
||||||
return ListTile(
|
return widget.buildTransportCard(
|
||||||
leading: const Icon(Icons.usb),
|
icon: Icons.usb_rounded,
|
||||||
title: Text(device.productName ?? 'USB Device'),
|
iconColor: Theme.of(context).colorScheme.primary,
|
||||||
subtitle: Text(device.manufacturerName ?? ''),
|
title: device.productName ?? 'USB Device',
|
||||||
|
subtitle: (device.manufacturerName?.isNotEmpty ?? false)
|
||||||
|
? device.manufacturerName!
|
||||||
|
: 'Ready over OTG serial',
|
||||||
trailing: _isConnecting
|
trailing: _isConnecting
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 20,
|
width: 24,
|
||||||
height: 20,
|
height: 24,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.chevron_right),
|
: FilledButton.tonal(
|
||||||
|
onPressed: _isConnecting
|
||||||
|
? null
|
||||||
|
: () => _connectToDevice(device),
|
||||||
|
child: const Text('Connect'),
|
||||||
|
),
|
||||||
|
enabled: !_isConnecting,
|
||||||
onTap: _isConnecting ? null : () => _connectToDevice(device),
|
onTap: _isConnecting ? null : () => _connectToDevice(device),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
@@ -16,6 +17,8 @@ import 'contact_trace_sheet.dart';
|
|||||||
import 'room_login_sheet.dart';
|
import 'room_login_sheet.dart';
|
||||||
import '../common/contact_avatar.dart';
|
import '../common/contact_avatar.dart';
|
||||||
import '../sensors/sensor_telemetry_card.dart';
|
import '../sensors/sensor_telemetry_card.dart';
|
||||||
|
import '../../utils/link_quality.dart';
|
||||||
|
import '../../utils/time_ago_extensions.dart';
|
||||||
import '../../utils/toast_logger.dart';
|
import '../../utils/toast_logger.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -1259,6 +1262,7 @@ class _NeighboursSheetState extends State<_NeighboursSheet> {
|
|||||||
|
|
||||||
Future<void> _fetchNeighbours() async {
|
Future<void> _fetchNeighbours() async {
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final previousOnMessageReceived = connectionProvider.onMessageReceived;
|
||||||
|
|
||||||
String? responseText;
|
String? responseText;
|
||||||
void onMessage(message) {
|
void onMessage(message) {
|
||||||
@@ -1270,9 +1274,12 @@ class _NeighboursSheetState extends State<_NeighboursSheet> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
connectionProvider.onMessageReceived = (message) {
|
void sheetListener(message) {
|
||||||
|
previousOnMessageReceived?.call(message);
|
||||||
onMessage(message);
|
onMessage(message);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
connectionProvider.onMessageReceived = sheetListener;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await connectionProvider.sendTextMessage(
|
await connectionProvider.sendTextMessage(
|
||||||
@@ -1343,32 +1350,321 @@ class _NeighboursSheetState extends State<_NeighboursSheet> {
|
|||||||
_loading = false;
|
_loading = false;
|
||||||
_error = 'Failed: $e';
|
_error = 'Failed: $e';
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
if (identical(connectionProvider.onMessageReceived, sheetListener)) {
|
||||||
|
connectionProvider.onMessageReceived = previousOnMessageReceived;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _resolveNeighbourName(String keyHex) {
|
Contact? _resolveNeighbourContact(String keyHex) {
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
for (final contact in contactsProvider.contacts) {
|
for (final contact in contactsProvider.contacts) {
|
||||||
if (contact.publicKeyHex.toLowerCase().startsWith(keyHex.toLowerCase())) {
|
if (contact.publicKeyHex.toLowerCase().startsWith(keyHex.toLowerCase())) {
|
||||||
return contact.displayName;
|
return contact;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _resolveNeighbourName(String keyHex) {
|
||||||
|
final contact = _resolveNeighbourContact(keyHex);
|
||||||
|
if (contact != null) {
|
||||||
|
return contact.displayName;
|
||||||
|
}
|
||||||
return keyHex.length > 12 ? '${keyHex.substring(0, 12)}...' : keyHex;
|
return keyHex.length > 12 ? '${keyHex.substring(0, 12)}...' : keyHex;
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatAge(DateTime when) {
|
String _formatAge(BuildContext context, _Neighbour neighbour) {
|
||||||
final diff = DateTime.now().difference(when);
|
if (neighbour.lastSeenAt != null) {
|
||||||
if (diff.inMinutes < 1) return 'just now';
|
return DateTime.now()
|
||||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
.difference(neighbour.lastSeenAt!)
|
||||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
.toLocalizedTimeAgoWithSeconds(context);
|
||||||
return '${diff.inDays}d ago';
|
}
|
||||||
|
if (neighbour.lastSeenMs != null) {
|
||||||
|
if (neighbour.lastSeenMs! < 1000) {
|
||||||
|
return AppLocalizations.of(context)!.justNow;
|
||||||
|
}
|
||||||
|
return Duration(
|
||||||
|
milliseconds: neighbour.lastSeenMs!,
|
||||||
|
).toLocalizedTimeAgoWithSeconds(context);
|
||||||
|
}
|
||||||
|
return AppLocalizations.of(context)!.justNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<_MappedNeighbour> _mappedNeighbours() {
|
||||||
|
return _neighbours
|
||||||
|
.map((neighbour) {
|
||||||
|
final contact = _resolveNeighbourContact(neighbour.publicKeyHex);
|
||||||
|
final location = contact?.displayLocation;
|
||||||
|
if (contact == null || location == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _MappedNeighbour(
|
||||||
|
neighbour: neighbour,
|
||||||
|
contact: contact,
|
||||||
|
location: LatLng(location.latitude, location.longitude),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.whereType<_MappedNeighbour>()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSummaryChip(
|
||||||
|
BuildContext context, {
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 14, color: colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRepeaterMarker(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 132),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.78),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
widget.contact.displayName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Container(
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.2),
|
||||||
|
blurRadius: 6,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.hub_outlined, color: Colors.white, size: 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNeighbourMarker(BuildContext context, _MappedNeighbour mapped) {
|
||||||
|
final quality = linkQualityLabel(null, mapped.neighbour.snrDb);
|
||||||
|
final qualityColor = linkQualityColor(quality);
|
||||||
|
final ageLabel = _formatAge(context, mapped.neighbour);
|
||||||
|
final signalLabel = mapped.neighbour.snrDb == null
|
||||||
|
? quality
|
||||||
|
: '$quality • ${mapped.neighbour.snrDb!.toStringAsFixed(1)} dB';
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 146),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: qualityColor.withValues(alpha: 0.92),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.12),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
signalLabel,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Container(
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: qualityColor, width: 2),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.18),
|
||||||
|
blurRadius: 6,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
mapped.contact.isRepeater
|
||||||
|
? Icons.router_outlined
|
||||||
|
: Icons.location_on_outlined,
|
||||||
|
color: qualityColor,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 148),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.78),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
mapped.contact.displayName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
ageLabel,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRouteMap(
|
||||||
|
BuildContext context, {
|
||||||
|
required LatLng repeaterLocation,
|
||||||
|
required List<_MappedNeighbour> mappedNeighbours,
|
||||||
|
}) {
|
||||||
|
final points = <LatLng>[
|
||||||
|
repeaterLocation,
|
||||||
|
...mappedNeighbours.map((mapped) => mapped.location),
|
||||||
|
];
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: flutter_map.FlutterMap(
|
||||||
|
options: flutter_map.MapOptions(
|
||||||
|
initialCameraFit: flutter_map.CameraFit.bounds(
|
||||||
|
bounds: flutter_map.LatLngBounds.fromPoints(points),
|
||||||
|
padding: const EdgeInsets.all(42),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
flutter_map.TileLayer(
|
||||||
|
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
userAgentPackageName: 'com.meshcore.sar',
|
||||||
|
),
|
||||||
|
flutter_map.PolylineLayer(
|
||||||
|
polylines: mappedNeighbours.map((mapped) {
|
||||||
|
final quality = linkQualityLabel(null, mapped.neighbour.snrDb);
|
||||||
|
final qualityColor = linkQualityColor(quality);
|
||||||
|
return flutter_map.Polyline(
|
||||||
|
points: [repeaterLocation, mapped.location],
|
||||||
|
color: qualityColor.withValues(alpha: 0.9),
|
||||||
|
strokeWidth: 4,
|
||||||
|
borderColor: Colors.white.withValues(alpha: 0.7),
|
||||||
|
borderStrokeWidth: 1.5,
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
flutter_map.MarkerLayer(
|
||||||
|
markers: [
|
||||||
|
flutter_map.Marker(
|
||||||
|
point: repeaterLocation,
|
||||||
|
width: 150,
|
||||||
|
height: 74,
|
||||||
|
child: _buildRepeaterMarker(context),
|
||||||
|
),
|
||||||
|
...mappedNeighbours.map(
|
||||||
|
(mapped) => flutter_map.Marker(
|
||||||
|
point: mapped.location,
|
||||||
|
width: 164,
|
||||||
|
height: 112,
|
||||||
|
child: _buildNeighbourMarker(context, mapped),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final repeaterDisplayLocation = widget.contact.displayLocation;
|
||||||
|
final repeaterLocation = repeaterDisplayLocation == null
|
||||||
|
? null
|
||||||
|
: LatLng(
|
||||||
|
repeaterDisplayLocation.latitude,
|
||||||
|
repeaterDisplayLocation.longitude,
|
||||||
|
);
|
||||||
|
final mappedNeighbours = _mappedNeighbours();
|
||||||
|
final missingLocations = _neighbours.length - mappedNeighbours.length;
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: MediaQuery.of(context).size.height * 0.55,
|
height: MediaQuery.of(context).size.height * 0.72,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -1410,26 +1706,68 @@ class _NeighboursSheetState extends State<_NeighboursSheet> {
|
|||||||
)
|
)
|
||||||
: _neighbours.isEmpty
|
: _neighbours.isEmpty
|
||||||
? const Center(child: Text('No neighbours found'))
|
? const Center(child: Text('No neighbours found'))
|
||||||
: ListView.builder(
|
: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
|
||||||
itemCount: _neighbours.length,
|
child: Column(
|
||||||
itemBuilder: (context, index) {
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
final n = _neighbours[index];
|
children: [
|
||||||
final name = _resolveNeighbourName(n.publicKeyHex);
|
Wrap(
|
||||||
final parts = <String>[
|
spacing: 8,
|
||||||
if (n.snrDb != null)
|
runSpacing: 8,
|
||||||
'SNR ${n.snrDb!.toStringAsFixed(1)} dB',
|
children: [
|
||||||
if (n.lastSeenAt != null) _formatAge(n.lastSeenAt!),
|
_buildSummaryChip(
|
||||||
if (n.lastSeenMs != null) '${n.lastSeenMs}ms ago',
|
context,
|
||||||
];
|
icon: Icons.route_outlined,
|
||||||
return ListTile(
|
label:
|
||||||
leading: const Icon(Icons.router_outlined),
|
'${_neighbours.length} neighbour${_neighbours.length == 1 ? '' : 's'}',
|
||||||
title: Text(name),
|
),
|
||||||
subtitle: parts.isNotEmpty
|
_buildSummaryChip(
|
||||||
? Text(parts.join(' • '))
|
context,
|
||||||
: null,
|
icon: Icons.map_outlined,
|
||||||
);
|
label: '${mappedNeighbours.length} on map',
|
||||||
},
|
),
|
||||||
|
if (missingLocations > 0)
|
||||||
|
_buildSummaryChip(
|
||||||
|
context,
|
||||||
|
icon: Icons.location_off_outlined,
|
||||||
|
label: '$missingLocations without GPS',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
child: repeaterLocation == null
|
||||||
|
? Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 24,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${widget.contact.displayName} has no saved location, so neighbour routes cannot be drawn on the map yet.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: mappedNeighbours.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 24,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Neighbours responded, but no geolocated contacts matched saved nodes to plot. Recent neighbour: ${_resolveNeighbourName(_neighbours.first.publicKeyHex)} • ${_formatAge(context, _neighbours.first)}',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _buildRouteMap(
|
||||||
|
context,
|
||||||
|
repeaterLocation: repeaterLocation,
|
||||||
|
mappedNeighbours: mappedNeighbours,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1452,3 +1790,15 @@ class _Neighbour {
|
|||||||
this.snrDb,
|
this.snrDb,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _MappedNeighbour {
|
||||||
|
final _Neighbour neighbour;
|
||||||
|
final Contact contact;
|
||||||
|
final LatLng location;
|
||||||
|
|
||||||
|
const _MappedNeighbour({
|
||||||
|
required this.neighbour,
|
||||||
|
required this.contact,
|
||||||
|
required this.location,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -174,14 +174,21 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
!widget.message.isSystemMessage) {
|
!widget.message.isSystemMessage) {
|
||||||
context.read<MessagesProvider>().markAsRead(widget.message.id);
|
context.read<MessagesProvider>().markAsRead(widget.message.id);
|
||||||
}
|
}
|
||||||
|
widget.onTap?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleBubbleDoubleTap({
|
||||||
|
required bool isSarMarker,
|
||||||
|
required bool isDrawing,
|
||||||
|
}) {
|
||||||
|
if (widget.isCompact || isSarMarker || isDrawing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!widget.isCompact && !isSarMarker && !isDrawing) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_showReceivedStats = !_showReceivedStats;
|
_showReceivedStats = !_showReceivedStats;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
widget.onTap?.call();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _retryFailedMessage(
|
Future<void> _retryFailedMessage(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
@@ -684,7 +691,10 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
|
|
||||||
void copyField(String value) {
|
void copyField(String value) {
|
||||||
Clipboard.setData(ClipboardData(text: value));
|
Clipboard.setData(ClipboardData(text: value));
|
||||||
ToastLogger.success(context, AppLocalizations.of(context)!.textCopiedToClipboard);
|
ToastLogger.success(
|
||||||
|
context,
|
||||||
|
AppLocalizations.of(context)!.textCopiedToClipboard,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
@@ -812,7 +822,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
_detailRow(
|
_detailRow(
|
||||||
sheetContext,
|
sheetContext,
|
||||||
label: AppLocalizations.of(context)!.receivedRfc3339,
|
label: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.receivedRfc3339,
|
||||||
value: _formatRfc3339(widget.message.receivedAt),
|
value: _formatRfc3339(widget.message.receivedAt),
|
||||||
onCopy: () => copyField(
|
onCopy: () => copyField(
|
||||||
_formatRfc3339(widget.message.receivedAt),
|
_formatRfc3339(widget.message.receivedAt),
|
||||||
@@ -821,14 +833,18 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
if (widget.message.expectedAckTag != null)
|
if (widget.message.expectedAckTag != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
sheetContext,
|
sheetContext,
|
||||||
label: AppLocalizations.of(context)!.expectedAckTag,
|
label: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.expectedAckTag,
|
||||||
value: widget.message.expectedAckTag!
|
value: widget.message.expectedAckTag!
|
||||||
.toString(),
|
.toString(),
|
||||||
),
|
),
|
||||||
if (receptionDetails?.senderToReceiptMs != null)
|
if (receptionDetails?.senderToReceiptMs != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
sheetContext,
|
sheetContext,
|
||||||
label: AppLocalizations.of(context)!.senderToReceipt,
|
label: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.senderToReceipt,
|
||||||
value: _formatDurationMs(
|
value: _formatDurationMs(
|
||||||
receptionDetails!.senderToReceiptMs!,
|
receptionDetails!.senderToReceiptMs!,
|
||||||
),
|
),
|
||||||
@@ -836,7 +852,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
if (receptionDetails?.estimatedTransmitMs != null)
|
if (receptionDetails?.estimatedTransmitMs != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
sheetContext,
|
sheetContext,
|
||||||
label: AppLocalizations.of(context)!.estimatedTx,
|
label: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.estimatedTx,
|
||||||
value: _formatDurationMs(
|
value: _formatDurationMs(
|
||||||
receptionDetails!.estimatedTransmitMs!,
|
receptionDetails!.estimatedTransmitMs!,
|
||||||
),
|
),
|
||||||
@@ -844,7 +862,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
if (receptionDetails?.postTransmitDelayMs != null)
|
if (receptionDetails?.postTransmitDelayMs != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
sheetContext,
|
sheetContext,
|
||||||
label: AppLocalizations.of(context)!.posttxDelay,
|
label: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.posttxDelay,
|
||||||
value: _formatDurationMs(
|
value: _formatDurationMs(
|
||||||
receptionDetails!.postTransmitDelayMs!,
|
receptionDetails!.postTransmitDelayMs!,
|
||||||
),
|
),
|
||||||
@@ -852,7 +872,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
if (widget.receivedCopies > 1)
|
if (widget.receivedCopies > 1)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
sheetContext,
|
sheetContext,
|
||||||
label: AppLocalizations.of(context)!.receivedCopies,
|
label: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.receivedCopies,
|
||||||
value: '${widget.receivedCopies}',
|
value: '${widget.receivedCopies}',
|
||||||
),
|
),
|
||||||
if (widget.message.suggestedTimeoutMs != null)
|
if (widget.message.suggestedTimeoutMs != null)
|
||||||
@@ -883,7 +905,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
if (widget.message.retryAttempt > 0)
|
if (widget.message.retryAttempt > 0)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
sheetContext,
|
sheetContext,
|
||||||
label: AppLocalizations.of(context)!.retryAttempt,
|
label: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.retryAttempt,
|
||||||
value: '${widget.message.retryAttempt}/4',
|
value: '${widget.message.retryAttempt}/4',
|
||||||
),
|
),
|
||||||
if (widget.message.lastRetryAt != null)
|
if (widget.message.lastRetryAt != null)
|
||||||
@@ -900,21 +924,27 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
if (widget.message.usedFloodFallback)
|
if (widget.message.usedFloodFallback)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
sheetContext,
|
sheetContext,
|
||||||
label: AppLocalizations.of(context)!.floodFallback,
|
label: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.floodFallback,
|
||||||
value: AppLocalizations.of(context)!.yes,
|
value: AppLocalizations.of(context)!.yes,
|
||||||
),
|
),
|
||||||
if (routeMetadata?.canonicalPath
|
if (routeMetadata?.canonicalPath
|
||||||
case final routePath?)
|
case final routePath?)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
sheetContext,
|
sheetContext,
|
||||||
label: AppLocalizations.of(context)!.selectedPath,
|
label: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.selectedPath,
|
||||||
value: routePath,
|
value: routePath,
|
||||||
onCopy: () => copyField(routePath),
|
onCopy: () => copyField(routePath),
|
||||||
),
|
),
|
||||||
if (retryResult != null)
|
if (retryResult != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
sheetContext,
|
sheetContext,
|
||||||
label: AppLocalizations.of(context)!.retryResult,
|
label: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.retryResult,
|
||||||
value: retryResult,
|
value: retryResult,
|
||||||
),
|
),
|
||||||
if (packetPathHex != null)
|
if (packetPathHex != null)
|
||||||
@@ -964,7 +994,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
if (recipientPrefixHex != null)
|
if (recipientPrefixHex != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
sheetContext,
|
sheetContext,
|
||||||
label: AppLocalizations.of(context)!.recipientKey,
|
label: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.recipientKey,
|
||||||
value: recipientPrefixHex,
|
value: recipientPrefixHex,
|
||||||
onCopy: () => copyField(recipientPrefixHex),
|
onCopy: () => copyField(recipientPrefixHex),
|
||||||
),
|
),
|
||||||
@@ -994,7 +1026,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
if (voiceSession != null)
|
if (voiceSession != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
sheetContext,
|
sheetContext,
|
||||||
label: AppLocalizations.of(context)!.sessionProgress,
|
label: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.sessionProgress,
|
||||||
value:
|
value:
|
||||||
'${voiceSession.receivedCount}/${voiceSession.total} segments',
|
'${voiceSession.receivedCount}/${voiceSession.total} segments',
|
||||||
),
|
),
|
||||||
@@ -1009,14 +1043,18 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
if (transferDetails != null)
|
if (transferDetails != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
sheetContext,
|
sheetContext,
|
||||||
label: AppLocalizations.of(context)!.transfers,
|
label: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.transfers,
|
||||||
value: '${transferDetails.totalTransfers}',
|
value: '${transferDetails.totalTransfers}',
|
||||||
),
|
),
|
||||||
if (transferDetails != null &&
|
if (transferDetails != null &&
|
||||||
transferDetails.downloaders.isNotEmpty)
|
transferDetails.downloaders.isNotEmpty)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
sheetContext,
|
sheetContext,
|
||||||
label: AppLocalizations.of(context)!.downloadedBy,
|
label: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.downloadedBy,
|
||||||
value: _formatDownloaderSummary(
|
value: _formatDownloaderSummary(
|
||||||
transferDetails,
|
transferDetails,
|
||||||
),
|
),
|
||||||
@@ -1024,7 +1062,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
if (voiceTxEstimate > Duration.zero)
|
if (voiceTxEstimate > Duration.zero)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
sheetContext,
|
sheetContext,
|
||||||
label: AppLocalizations.of(context)!.estimatedTx,
|
label: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.estimatedTx,
|
||||||
value: voiceTxEstimate.inSeconds < 60
|
value: voiceTxEstimate.inSeconds < 60
|
||||||
? '~${voiceTxEstimate.inSeconds}s'
|
? '~${voiceTxEstimate.inSeconds}s'
|
||||||
: '~${voiceTxEstimate.inMinutes}m ${voiceTxEstimate.inSeconds % 60}s',
|
: '~${voiceTxEstimate.inMinutes}m ${voiceTxEstimate.inSeconds % 60}s',
|
||||||
@@ -1075,14 +1115,18 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
if (transferDetails != null)
|
if (transferDetails != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
sheetContext,
|
sheetContext,
|
||||||
label: AppLocalizations.of(context)!.transfers,
|
label: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.transfers,
|
||||||
value: '${transferDetails.totalTransfers}',
|
value: '${transferDetails.totalTransfers}',
|
||||||
),
|
),
|
||||||
if (transferDetails != null &&
|
if (transferDetails != null &&
|
||||||
transferDetails.downloaders.isNotEmpty)
|
transferDetails.downloaders.isNotEmpty)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
sheetContext,
|
sheetContext,
|
||||||
label: AppLocalizations.of(context)!.downloadedBy,
|
label: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.downloadedBy,
|
||||||
value: _formatDownloaderSummary(
|
value: _formatDownloaderSummary(
|
||||||
transferDetails,
|
transferDetails,
|
||||||
),
|
),
|
||||||
@@ -1090,7 +1134,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
if (imageTxEstimate > Duration.zero)
|
if (imageTxEstimate > Duration.zero)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
sheetContext,
|
sheetContext,
|
||||||
label: AppLocalizations.of(context)!.estimatedTx,
|
label: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.estimatedTx,
|
||||||
value: imageTxEstimate.inSeconds < 60
|
value: imageTxEstimate.inSeconds < 60
|
||||||
? '~${imageTxEstimate.inSeconds}s'
|
? '~${imageTxEstimate.inSeconds}s'
|
||||||
: '~${imageTxEstimate.inMinutes}m ${imageTxEstimate.inSeconds % 60}s',
|
: '~${imageTxEstimate.inMinutes}m ${imageTxEstimate.inSeconds % 60}s',
|
||||||
@@ -1438,7 +1484,10 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
|
|
||||||
// Share the location
|
// Share the location
|
||||||
SharePlus.instance.share(
|
SharePlus.instance.share(
|
||||||
ShareParams(text: shareText, subject: AppLocalizations.of(context)!.sarLocationShare),
|
ShareParams(
|
||||||
|
text: shareText,
|
||||||
|
subject: AppLocalizations.of(context)!.sarLocationShare,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1890,6 +1939,10 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
isSarMarker: isSarMarker,
|
isSarMarker: isSarMarker,
|
||||||
isDrawing: message.isDrawing,
|
isDrawing: message.isDrawing,
|
||||||
),
|
),
|
||||||
|
onDoubleTap: () => _handleBubbleDoubleTap(
|
||||||
|
isSarMarker: isSarMarker,
|
||||||
|
isDrawing: message.isDrawing,
|
||||||
|
),
|
||||||
onLongPress: widget.isCompact
|
onLongPress: widget.isCompact
|
||||||
? null
|
? null
|
||||||
: () => _showMessageOptions(context),
|
: () => _showMessageOptions(context),
|
||||||
@@ -2779,10 +2832,8 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (shouldShowSentChannelStats(
|
if (_showReceivedStats &&
|
||||||
message,
|
shouldShowSentChannelStats(message)) ...[
|
||||||
showReceivedStats: _showReceivedStats,
|
|
||||||
)) ...[
|
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
buildChannelEchoStatus(context, message),
|
buildChannelEchoStatus(context, message),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../../models/message_route_metadata.dart';
|
|||||||
import '../../models/path_selection.dart';
|
import '../../models/path_selection.dart';
|
||||||
import '../../models/message_reception_details.dart';
|
import '../../models/message_reception_details.dart';
|
||||||
import '../../providers/messages_provider.dart';
|
import '../../providers/messages_provider.dart';
|
||||||
|
import '../../utils/link_quality.dart';
|
||||||
|
|
||||||
IconData getDeliveryStatusIcon(MessageDeliveryStatus status) {
|
IconData getDeliveryStatusIcon(MessageDeliveryStatus status) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -91,10 +92,7 @@ Widget buildChannelEchoStatus(BuildContext context, Message message) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool shouldShowSentChannelStats(
|
bool shouldShowSentChannelStats(Message message) {
|
||||||
Message message, {
|
|
||||||
required bool showReceivedStats,
|
|
||||||
}) {
|
|
||||||
if (!message.isSentMessage || !message.isChannelMessage) {
|
if (!message.isSentMessage || !message.isChannelMessage) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -104,7 +102,7 @@ bool shouldShowSentChannelStats(
|
|||||||
message.lastEchoRssiDbm != null ||
|
message.lastEchoRssiDbm != null ||
|
||||||
message.lastEchoSnrRaw != null ||
|
message.lastEchoSnrRaw != null ||
|
||||||
message.expectedAckTag != null;
|
message.expectedAckTag != null;
|
||||||
return showReceivedStats && hasSignalData;
|
return hasSignalData;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget buildReceivedSignalStatus(
|
Widget buildReceivedSignalStatus(
|
||||||
@@ -382,30 +380,3 @@ Widget _signalCapsule(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
int rssiScore(int rssiDbm) => ((rssiDbm + 120) / 10).round().clamp(0, 5);
|
|
||||||
|
|
||||||
int snrScore(double snrDb) => ((snrDb + 5.0) / 5.0).round().clamp(0, 5);
|
|
||||||
|
|
||||||
String linkQualityLabel(int? rssiDbm, double? snrDb) {
|
|
||||||
var score = 0;
|
|
||||||
if (rssiDbm != null) score += rssiScore(rssiDbm);
|
|
||||||
if (snrDb != null) score += snrScore(snrDb);
|
|
||||||
if (score >= 8) return 'Excellent';
|
|
||||||
if (score >= 6) return 'Good';
|
|
||||||
if (score >= 4) return 'Fair';
|
|
||||||
return 'Weak';
|
|
||||||
}
|
|
||||||
|
|
||||||
Color linkQualityColor(String quality) {
|
|
||||||
switch (quality) {
|
|
||||||
case 'Excellent':
|
|
||||||
return Colors.green;
|
|
||||||
case 'Good':
|
|
||||||
return Colors.lightGreen;
|
|
||||||
case 'Fair':
|
|
||||||
return Colors.orange;
|
|
||||||
default:
|
|
||||||
return Colors.redAccent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -207,9 +207,23 @@ List<SensorMetricOption> sensorMetricOptionsFor(
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Keys that are already shown as core telemetry fields above.
|
||||||
|
// Skip them from extraSensorData to avoid duplicates.
|
||||||
|
final coreFieldKeys = <String>{
|
||||||
|
if (batteryMilliVolts != null || batteryPercentage != null)
|
||||||
|
...extraSensorData?.keys.where((k) =>
|
||||||
|
k.startsWith('voltage_') || k.startsWith('analog_input_')) ?? [],
|
||||||
|
if (temperature != null)
|
||||||
|
...extraSensorData?.keys.where((k) => k.startsWith('temperature_')) ?? [],
|
||||||
|
if (humidity != null)
|
||||||
|
...extraSensorData?.keys.where((k) => k.startsWith('humidity_')) ?? [],
|
||||||
|
if (pressure != null)
|
||||||
|
...extraSensorData?.keys.where((k) => k.startsWith('pressure_')) ?? [],
|
||||||
|
};
|
||||||
|
|
||||||
if (extraSensorData != null) {
|
if (extraSensorData != null) {
|
||||||
for (final key in extraSensorData.keys) {
|
for (final key in extraSensorData.keys) {
|
||||||
if (_isTelemetryMetadataKey(key)) {
|
if (_isTelemetryMetadataKey(key) || coreFieldKeys.contains(key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
final metricKey = _parseMetricKey(key);
|
final metricKey = _parseMetricKey(key);
|
||||||
@@ -2237,7 +2251,9 @@ class SensorMetricTile extends StatelessWidget {
|
|||||||
border: Border.all(color: data.accent.withValues(alpha: 0.14)),
|
border: Border.all(color: data.accent.withValues(alpha: 0.14)),
|
||||||
),
|
),
|
||||||
child: data.mapLocation == null || !allowMapPreview
|
child: data.mapLocation == null || !allowMapPreview
|
||||||
? Row(
|
? Stack(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_MetricIcon(accent: data.accent, icon: data.icon),
|
_MetricIcon(accent: data.accent, icon: data.icon),
|
||||||
@@ -2246,6 +2262,21 @@ class SensorMetricTile extends StatelessWidget {
|
|||||||
child: _MetricText(data: data, keyPrefix: keyPrefix),
|
child: _MetricText(data: data, keyPrefix: keyPrefix),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
if (data.channel != null)
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Text(
|
||||||
|
'ch${data.channel}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: data.accent.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
: Column(
|
: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -2381,11 +2412,7 @@ class _MetricText extends StatelessWidget {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Text(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
data.label,
|
data.label,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -2394,27 +2421,6 @@ class _MetricText extends StatelessWidget {
|
|||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (data.channel != null) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Container(
|
|
||||||
key: ValueKey('${keyPrefix}_channel_${data.fieldKey}'),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: data.accent.withValues(alpha: 0.12),
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'ch${data.channel}',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: data.accent,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
data.value,
|
data.value,
|
||||||
|
|||||||
@@ -10,40 +10,28 @@ void main() {
|
|||||||
setUp(() {
|
setUp(() {
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
ProfileStorageScope.setScope(
|
ProfileStorageScope.setScope(
|
||||||
profilesEnabled: false,
|
profilesEnabled: true,
|
||||||
activeProfileId: ConfigProfile.defaultProfileId,
|
activeProfileId: ConfigProfile.defaultProfileId,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('ProfileManager', () {
|
group('ProfileManager', () {
|
||||||
test(
|
test('shows the built-in default profile on a fresh install', () async {
|
||||||
'hides the built-in default profile until profiles are enabled',
|
|
||||||
() async {
|
|
||||||
final manager = ProfileManager();
|
final manager = ProfileManager();
|
||||||
|
|
||||||
await manager.initialize();
|
await manager.initialize();
|
||||||
|
|
||||||
expect(manager.activeProfileId, ConfigProfile.defaultProfileId);
|
expect(manager.activeProfileId, ConfigProfile.defaultProfileId);
|
||||||
expect(manager.visibleProfiles, isEmpty);
|
expect(manager.visibleProfiles, hasLength(1));
|
||||||
expect(
|
expect(
|
||||||
manager.getProfile(ConfigProfile.defaultProfileId)?.isDefault,
|
manager.getProfile(ConfigProfile.defaultProfileId)?.isDefault,
|
||||||
isTrue,
|
isTrue,
|
||||||
);
|
);
|
||||||
expect(ProfileStorageScope.profilesEnabled, isFalse);
|
|
||||||
expect(ProfileStorageScope.effectiveNamespace, isNull);
|
|
||||||
|
|
||||||
await manager.setProfilesEnabled(true);
|
|
||||||
|
|
||||||
expect(manager.visibleProfiles, hasLength(1));
|
|
||||||
expect(
|
|
||||||
manager.visibleProfiles.single.id,
|
|
||||||
ConfigProfile.defaultProfileId,
|
|
||||||
);
|
|
||||||
expect(manager.visibleProfiles.single.name, 'Default');
|
|
||||||
expect(ProfileStorageScope.profilesEnabled, isTrue);
|
expect(ProfileStorageScope.profilesEnabled, isTrue);
|
||||||
expect(ProfileStorageScope.effectiveNamespace, isNull);
|
expect(ProfileStorageScope.effectiveNamespace, isNull);
|
||||||
},
|
expect(manager.visibleProfiles.single.name, 'Default');
|
||||||
);
|
expect(ProfileStorageScope.effectiveNamespace, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'persists custom profiles and restores scoped active profile state',
|
'persists custom profiles and restores scoped active profile state',
|
||||||
@@ -82,5 +70,44 @@ void main() {
|
|||||||
expect(reloaded.activeProfileId, profile.id);
|
expect(reloaded.activeProfileId, profile.id);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('stores per-device default profiles independently', () async {
|
||||||
|
final manager = ProfileManager();
|
||||||
|
await manager.initialize();
|
||||||
|
await manager.setProfilesEnabled(true);
|
||||||
|
|
||||||
|
final profile = ConfigProfile(
|
||||||
|
id: 'profile-alpha',
|
||||||
|
name: 'Alpha',
|
||||||
|
createdAt: DateTime.parse('2026-03-16T12:00:00Z'),
|
||||||
|
updatedAt: DateTime.parse('2026-03-16T12:00:00Z'),
|
||||||
|
sections: const ConfigProfileSections(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.upsertProfile(profile);
|
||||||
|
await manager.setActiveProfileIdForDevice(
|
||||||
|
profile.id,
|
||||||
|
deviceKey: 'pk:device-a',
|
||||||
|
);
|
||||||
|
await manager.setActiveProfileIdForDevice(
|
||||||
|
ConfigProfile.defaultProfileId,
|
||||||
|
deviceKey: 'pk:device-b',
|
||||||
|
);
|
||||||
|
|
||||||
|
final reloaded = ProfileManager();
|
||||||
|
await reloaded.initialize();
|
||||||
|
|
||||||
|
expect(reloaded.profileIdForDevice('pk:device-a'), profile.id);
|
||||||
|
expect(
|
||||||
|
reloaded.profileIdForDevice('pk:device-b'),
|
||||||
|
ConfigProfile.defaultProfileId,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
reloaded.profileIdForDevice('pk:device-c'),
|
||||||
|
ConfigProfile.defaultProfileId,
|
||||||
|
);
|
||||||
|
expect(reloaded.hasProfileForDevice('pk:device-a'), isTrue);
|
||||||
|
expect(reloaded.hasProfileForDevice('pk:device-c'), isFalse);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,73 @@ void main() {
|
|||||||
expect(manager.activeProfileId, target.id);
|
expect(manager.activeProfileId, target.id);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('syncActiveProfileForCurrentDevice uses per-device default', () async {
|
||||||
|
final manager = ProfileManager();
|
||||||
|
await manager.initialize();
|
||||||
|
await manager.setProfilesEnabled(true);
|
||||||
|
|
||||||
|
final alpha = ConfigProfile(
|
||||||
|
id: 'profile-alpha',
|
||||||
|
name: 'Alpha',
|
||||||
|
createdAt: DateTime.parse('2026-03-16T12:00:00Z'),
|
||||||
|
updatedAt: DateTime.parse('2026-03-16T12:00:00Z'),
|
||||||
|
sections: const ConfigProfileSections(),
|
||||||
|
);
|
||||||
|
await manager.upsertProfile(alpha);
|
||||||
|
await manager.setActiveProfileIdForDevice(
|
||||||
|
alpha.id,
|
||||||
|
deviceKey: 'pk:01020304',
|
||||||
|
);
|
||||||
|
await manager.setActiveProfileId(ConfigProfile.defaultProfileId);
|
||||||
|
|
||||||
|
final connectionProvider = _FakeConnectionProvider(
|
||||||
|
deviceInfo: DeviceInfo(publicKey: Uint8List.fromList([1, 2, 3, 4])),
|
||||||
|
);
|
||||||
|
final coordinator = _buildCoordinator(
|
||||||
|
profileManager: manager,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
await coordinator.syncActiveProfileForCurrentDevice();
|
||||||
|
|
||||||
|
expect(manager.activeProfileId, alpha.id);
|
||||||
|
expect(connectionProvider.disconnectCallCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'syncActiveProfileForCurrentDevice creates a profile for a new device',
|
||||||
|
() async {
|
||||||
|
final manager = ProfileManager();
|
||||||
|
await manager.initialize();
|
||||||
|
await manager.setProfilesEnabled(true);
|
||||||
|
|
||||||
|
final connectionProvider = _FakeConnectionProvider(
|
||||||
|
deviceInfo: DeviceInfo(
|
||||||
|
deviceId: 'ble-77',
|
||||||
|
deviceName: 'MeshCore-Field Unit',
|
||||||
|
selfName: 'Field Unit',
|
||||||
|
publicKey: Uint8List.fromList([7, 7, 7, 7]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final coordinator = _buildCoordinator(
|
||||||
|
profileManager: manager,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
await coordinator.syncActiveProfileForCurrentDevice();
|
||||||
|
|
||||||
|
expect(manager.activeProfileId, isNot(ConfigProfile.defaultProfileId));
|
||||||
|
final profile = manager.getProfile(manager.activeProfileId);
|
||||||
|
expect(profile, isNotNull);
|
||||||
|
expect(profile!.name, 'Device Field Unit');
|
||||||
|
expect(manager.hasProfileForDevice('pk:07070707'), isTrue);
|
||||||
|
expect(
|
||||||
|
manager.profileIdForDevice('pk:07070707'),
|
||||||
|
manager.activeProfileId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,10 +332,17 @@ class _FakeDeviceConfigApplicator extends DeviceConfigApplicator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _FakeConnectionProvider implements ConnectionProvider {
|
class _FakeConnectionProvider implements ConnectionProvider {
|
||||||
|
_FakeConnectionProvider({
|
||||||
|
DeviceInfo? deviceInfo,
|
||||||
|
}) : deviceInfo = deviceInfo ?? DeviceInfo();
|
||||||
|
|
||||||
int disconnectCallCount = 0;
|
int disconnectCallCount = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
DeviceInfo get deviceInfo => DeviceInfo();
|
final DeviceInfo deviceInfo;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConnectionMode get connectionMode => ConnectionMode.ble;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
|
|||||||
25
test/utils/link_quality_test.dart
Normal file
25
test/utils/link_quality_test.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/utils/link_quality.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('linkQualityLabel', () {
|
||||||
|
test('classifies SNR-only values without forcing them to weak', () {
|
||||||
|
expect(linkQualityLabel(null, 12.0), 'Excellent');
|
||||||
|
expect(linkQualityLabel(null, 6.0), 'Good');
|
||||||
|
expect(linkQualityLabel(null, 1.0), 'Fair');
|
||||||
|
expect(linkQualityLabel(null, -6.0), 'Weak');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifies RSSI-only values using direct thresholds', () {
|
||||||
|
expect(linkQualityLabel(-58, null), 'Excellent');
|
||||||
|
expect(linkQualityLabel(-68, null), 'Good');
|
||||||
|
expect(linkQualityLabel(-78, null), 'Fair');
|
||||||
|
expect(linkQualityLabel(-92, null), 'Weak');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('averages mixed metrics when both are available', () {
|
||||||
|
expect(linkQualityLabel(-72, 11.0), 'Good');
|
||||||
|
expect(linkQualityLabel(-85, 7.0), 'Fair');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
244
test/widgets/message_bubble_test.dart
Normal file
244
test/widgets/message_bubble_test.dart
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/message.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/connection_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/drawing_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/image_provider.dart' as ip;
|
||||||
|
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/voice_provider.dart';
|
||||||
|
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/widgets/messages/message_bubble.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('received bubbles show signal chips on double tap', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final harness = await _TestHarness.create();
|
||||||
|
try {
|
||||||
|
final message = Message(
|
||||||
|
id: 'received-signal',
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
senderPublicKeyPrefix: _prefix(1),
|
||||||
|
pathLen: 1,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000000,
|
||||||
|
text: 'Inbound message',
|
||||||
|
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
|
||||||
|
deliveryStatus: MessageDeliveryStatus.received,
|
||||||
|
lastEchoRssiDbm: -84,
|
||||||
|
lastEchoSnrRaw: 24,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_buildApp(harness, message));
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
|
||||||
|
expect(find.text('1 hop'), findsNothing);
|
||||||
|
expect(find.text('Fair'), findsNothing);
|
||||||
|
expect(find.text('-84'), findsNothing);
|
||||||
|
expect(find.text('6.0'), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Inbound message'));
|
||||||
|
await tester.pump(kDoubleTapTimeout);
|
||||||
|
|
||||||
|
expect(find.text('1 hop'), findsNothing);
|
||||||
|
expect(find.text('-84'), findsNothing);
|
||||||
|
|
||||||
|
await _doubleTap(tester, find.text('Inbound message'));
|
||||||
|
|
||||||
|
expect(find.text('1 hop'), findsOneWidget);
|
||||||
|
expect(find.text('Fair'), findsOneWidget);
|
||||||
|
expect(find.text('-84'), findsOneWidget);
|
||||||
|
expect(find.text('6.0'), findsOneWidget);
|
||||||
|
} finally {
|
||||||
|
await _disposeHarness(tester, harness);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('delivered direct bubbles show timing chips on double tap', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final harness = await _TestHarness.create();
|
||||||
|
try {
|
||||||
|
final message = Message(
|
||||||
|
id: 'sent-direct-signal',
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
senderPublicKeyPrefix: _prefix(11),
|
||||||
|
recipientPublicKey: _key(77),
|
||||||
|
pathLen: 0,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000000,
|
||||||
|
text: 'Outbound message',
|
||||||
|
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
|
||||||
|
deliveryStatus: MessageDeliveryStatus.delivered,
|
||||||
|
roundTripTimeMs: 320,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_buildApp(harness, message));
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
|
||||||
|
expect(find.text('Direct'), findsNothing);
|
||||||
|
expect(find.text('320ms'), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Outbound message'));
|
||||||
|
await tester.pump(kDoubleTapTimeout);
|
||||||
|
|
||||||
|
expect(find.text('Direct'), findsNothing);
|
||||||
|
expect(find.text('320ms'), findsNothing);
|
||||||
|
|
||||||
|
await _doubleTap(tester, find.text('Outbound message'));
|
||||||
|
|
||||||
|
expect(find.text('Direct'), findsOneWidget);
|
||||||
|
expect(find.text('320ms'), findsOneWidget);
|
||||||
|
} finally {
|
||||||
|
await _disposeHarness(tester, harness);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('sent channel bubbles show echo chips on double tap', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final harness = await _TestHarness.create();
|
||||||
|
try {
|
||||||
|
final message = Message(
|
||||||
|
id: 'sent-channel-signal',
|
||||||
|
messageType: MessageType.channel,
|
||||||
|
senderPublicKeyPrefix: _prefix(21),
|
||||||
|
channelIdx: 0,
|
||||||
|
pathLen: 0,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000000,
|
||||||
|
text: 'Broadcast message',
|
||||||
|
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
|
||||||
|
deliveryStatus: MessageDeliveryStatus.sent,
|
||||||
|
echoCount: 2,
|
||||||
|
lastEchoRssiDbm: -76,
|
||||||
|
lastEchoSnrRaw: 20,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_buildApp(harness, message));
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
|
||||||
|
expect(find.text('x2'), findsNothing);
|
||||||
|
expect(find.text('-76'), findsNothing);
|
||||||
|
expect(find.text('5.0'), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Broadcast message'));
|
||||||
|
await tester.pump(kDoubleTapTimeout);
|
||||||
|
|
||||||
|
expect(find.text('x2'), findsNothing);
|
||||||
|
expect(find.text('-76'), findsNothing);
|
||||||
|
|
||||||
|
await _doubleTap(tester, find.text('Broadcast message'));
|
||||||
|
|
||||||
|
expect(find.text('x2'), findsOneWidget);
|
||||||
|
expect(find.text('-76'), findsOneWidget);
|
||||||
|
expect(find.text('5.0'), findsOneWidget);
|
||||||
|
} finally {
|
||||||
|
await _disposeHarness(tester, harness);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildApp(_TestHarness harness, Message message) {
|
||||||
|
return MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider.value(value: harness.connectionProvider),
|
||||||
|
ChangeNotifierProvider.value(value: harness.contactsProvider),
|
||||||
|
ChangeNotifierProvider.value(value: harness.messagesProvider),
|
||||||
|
ChangeNotifierProvider.value(value: harness.drawingProvider),
|
||||||
|
ChangeNotifierProvider.value(value: harness.channelsProvider),
|
||||||
|
ChangeNotifierProvider.value(value: harness.voiceProvider),
|
||||||
|
ChangeNotifierProvider.value(value: harness.imageProvider),
|
||||||
|
ChangeNotifierProvider.value(value: harness.appProvider),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(body: MessageBubble(message: message)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TestHarness {
|
||||||
|
final connectionProvider = ConnectionProvider();
|
||||||
|
final contactsProvider = ContactsProvider();
|
||||||
|
final messagesProvider = MessagesProvider();
|
||||||
|
final drawingProvider = DrawingProvider();
|
||||||
|
final channelsProvider = ChannelsProvider()..initializePublicChannel();
|
||||||
|
final voiceProvider = VoiceProvider(
|
||||||
|
codec: VoiceCodecService(),
|
||||||
|
player: VoicePlayerService(),
|
||||||
|
);
|
||||||
|
final imageProvider = ip.ImageProvider();
|
||||||
|
|
||||||
|
late final AppProvider appProvider;
|
||||||
|
|
||||||
|
static Future<_TestHarness> create() async {
|
||||||
|
final harness = _TestHarness();
|
||||||
|
await harness.messagesProvider.initialize();
|
||||||
|
await harness.drawingProvider.initialize();
|
||||||
|
harness.appProvider = AppProvider(
|
||||||
|
connectionProvider: harness.connectionProvider,
|
||||||
|
contactsProvider: harness.contactsProvider,
|
||||||
|
messagesProvider: harness.messagesProvider,
|
||||||
|
drawingProvider: harness.drawingProvider,
|
||||||
|
channelsProvider: harness.channelsProvider,
|
||||||
|
voiceProvider: harness.voiceProvider,
|
||||||
|
imageProvider: harness.imageProvider,
|
||||||
|
);
|
||||||
|
return harness;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isDisposed = false;
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
if (_isDisposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_isDisposed = true;
|
||||||
|
appProvider.dispose();
|
||||||
|
voiceProvider.dispose();
|
||||||
|
imageProvider.dispose();
|
||||||
|
drawingProvider.dispose();
|
||||||
|
messagesProvider.dispose();
|
||||||
|
contactsProvider.dispose();
|
||||||
|
connectionProvider.dispose();
|
||||||
|
channelsProvider.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List _prefix(int seed) =>
|
||||||
|
Uint8List.fromList(List<int>.generate(6, (index) => seed + index));
|
||||||
|
|
||||||
|
Uint8List _key(int seed) =>
|
||||||
|
Uint8List.fromList(List<int>.generate(32, (index) => seed + index));
|
||||||
|
|
||||||
|
Future<void> _disposeHarness(WidgetTester tester, _TestHarness harness) async {
|
||||||
|
await tester.pump(kDoubleTapTimeout);
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
harness.dispose();
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _doubleTap(WidgetTester tester, Finder finder) async {
|
||||||
|
await tester.tap(finder);
|
||||||
|
await tester.pump(kDoubleTapMinTime);
|
||||||
|
await tester.tap(finder);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user