diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 993d2e7..efd35a5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -660,6 +660,30 @@ "@flood": { "description": "Flood routing indicator" }, + "autoSend": "Auto Send", + "@autoSend": { + "description": "Auto send mode - uses current path" + }, + "autoSendDescription": "Send via current path.", + "@autoSendDescription": { + "description": "Description for auto send mode" + }, + "sendDirect": "Send Direct", + "@sendDirect": { + "description": "Send direct mode - zero-hop to contact" + }, + "sendDirectDescription": "Send directly to this contact.", + "@sendDirectDescription": { + "description": "Description for send direct mode" + }, + "sendFlood": "Send Flood", + "@sendFlood": { + "description": "Send flood mode - via all repeaters" + }, + "sendFloodDescription": "Send via all repeaters.", + "@sendFloodDescription": { + "description": "Description for send flood mode" + }, "loggedIn": "Logged In", "@loggedIn": { "description": "Logged in status badge" diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 2d9b2ee..c88a61a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -961,6 +961,42 @@ abstract class AppLocalizations { /// **'Flood'** String get flood; + /// Auto send mode - uses current path + /// + /// In en, this message translates to: + /// **'Auto Send'** + String get autoSend; + + /// Description for auto send mode + /// + /// In en, this message translates to: + /// **'Send via current path.'** + String get autoSendDescription; + + /// Send direct mode - zero-hop to contact + /// + /// In en, this message translates to: + /// **'Send Direct'** + String get sendDirect; + + /// Description for send direct mode + /// + /// In en, this message translates to: + /// **'Send directly to this contact.'** + String get sendDirectDescription; + + /// Send flood mode - via all repeaters + /// + /// In en, this message translates to: + /// **'Send Flood'** + String get sendFlood; + + /// Description for send flood mode + /// + /// In en, this message translates to: + /// **'Send via all repeaters.'** + String get sendFloodDescription; + /// Logged in status badge /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index cfd8cb0..899d9d1 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -481,6 +481,24 @@ class AppLocalizationsDe extends AppLocalizations { @override String get flood => 'Flut'; + @override + String get autoSend => 'Auto Send'; + + @override + String get autoSendDescription => 'Send via current path.'; + + @override + String get sendDirect => 'Send Direct'; + + @override + String get sendDirectDescription => 'Send directly to this contact.'; + + @override + String get sendFlood => 'Send Flood'; + + @override + String get sendFloodDescription => 'Send via all repeaters.'; + @override String get loggedIn => 'Angemeldet'; diff --git a/lib/l10n/app_localizations_el.dart b/lib/l10n/app_localizations_el.dart index 536bc3f..4c921b8 100644 --- a/lib/l10n/app_localizations_el.dart +++ b/lib/l10n/app_localizations_el.dart @@ -483,6 +483,24 @@ class AppLocalizationsEl extends AppLocalizations { @override String get flood => 'Πλημμυρικό'; + @override + String get autoSend => 'Auto Send'; + + @override + String get autoSendDescription => 'Send via current path.'; + + @override + String get sendDirect => 'Send Direct'; + + @override + String get sendDirectDescription => 'Send directly to this contact.'; + + @override + String get sendFlood => 'Send Flood'; + + @override + String get sendFloodDescription => 'Send via all repeaters.'; + @override String get loggedIn => 'Συνδεδεμένος'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e92c807..f487014 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -478,6 +478,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get flood => 'Flood'; + @override + String get autoSend => 'Auto Send'; + + @override + String get autoSendDescription => 'Send via current path.'; + + @override + String get sendDirect => 'Send Direct'; + + @override + String get sendDirectDescription => 'Send directly to this contact.'; + + @override + String get sendFlood => 'Send Flood'; + + @override + String get sendFloodDescription => 'Send via all repeaters.'; + @override String get loggedIn => 'Logged In'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 369278f..b4c9f1f 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -481,6 +481,24 @@ class AppLocalizationsEs extends AppLocalizations { @override String get flood => 'Inundación'; + @override + String get autoSend => 'Auto Send'; + + @override + String get autoSendDescription => 'Send via current path.'; + + @override + String get sendDirect => 'Send Direct'; + + @override + String get sendDirectDescription => 'Send directly to this contact.'; + + @override + String get sendFlood => 'Send Flood'; + + @override + String get sendFloodDescription => 'Send via all repeaters.'; + @override String get loggedIn => 'Sesión iniciada'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 2f2bc71..bdf7492 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -483,6 +483,24 @@ class AppLocalizationsFr extends AppLocalizations { @override String get flood => 'Inondation'; + @override + String get autoSend => 'Auto Send'; + + @override + String get autoSendDescription => 'Send via current path.'; + + @override + String get sendDirect => 'Send Direct'; + + @override + String get sendDirectDescription => 'Send directly to this contact.'; + + @override + String get sendFlood => 'Send Flood'; + + @override + String get sendFloodDescription => 'Send via all repeaters.'; + @override String get loggedIn => 'Connecté'; diff --git a/lib/l10n/app_localizations_hr.dart b/lib/l10n/app_localizations_hr.dart index 5bda869..9bffdab 100644 --- a/lib/l10n/app_localizations_hr.dart +++ b/lib/l10n/app_localizations_hr.dart @@ -476,6 +476,24 @@ class AppLocalizationsHr extends AppLocalizations { @override String get flood => 'Preplavljanje'; + @override + String get autoSend => 'Auto Send'; + + @override + String get autoSendDescription => 'Send via current path.'; + + @override + String get sendDirect => 'Send Direct'; + + @override + String get sendDirectDescription => 'Send directly to this contact.'; + + @override + String get sendFlood => 'Send Flood'; + + @override + String get sendFloodDescription => 'Send via all repeaters.'; + @override String get loggedIn => 'Prijavljen'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index ca15f0b..7139c2a 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -480,6 +480,24 @@ class AppLocalizationsIt extends AppLocalizations { @override String get flood => 'Flood'; + @override + String get autoSend => 'Auto Send'; + + @override + String get autoSendDescription => 'Send via current path.'; + + @override + String get sendDirect => 'Send Direct'; + + @override + String get sendDirectDescription => 'Send directly to this contact.'; + + @override + String get sendFlood => 'Send Flood'; + + @override + String get sendFloodDescription => 'Send via all repeaters.'; + @override String get loggedIn => 'Connesso'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index f69f5f4..2ed4e31 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -481,6 +481,24 @@ class AppLocalizationsPl extends AppLocalizations { @override String get flood => 'Rozgłoszeniowo'; + @override + String get autoSend => 'Auto Send'; + + @override + String get autoSendDescription => 'Send via current path.'; + + @override + String get sendDirect => 'Send Direct'; + + @override + String get sendDirectDescription => 'Send directly to this contact.'; + + @override + String get sendFlood => 'Send Flood'; + + @override + String get sendFloodDescription => 'Send via all repeaters.'; + @override String get loggedIn => 'Zalogowano'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 198a305..884d7a7 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -482,6 +482,24 @@ class AppLocalizationsPt extends AppLocalizations { @override String get flood => 'Inundação'; + @override + String get autoSend => 'Auto Send'; + + @override + String get autoSendDescription => 'Send via current path.'; + + @override + String get sendDirect => 'Send Direct'; + + @override + String get sendDirectDescription => 'Send directly to this contact.'; + + @override + String get sendFlood => 'Send Flood'; + + @override + String get sendFloodDescription => 'Send via all repeaters.'; + @override String get loggedIn => 'Conectado'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 67b3129..138169d 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -480,6 +480,24 @@ class AppLocalizationsRu extends AppLocalizations { @override String get flood => 'Широковещательно'; + @override + String get autoSend => 'Auto Send'; + + @override + String get autoSendDescription => 'Send via current path.'; + + @override + String get sendDirect => 'Send Direct'; + + @override + String get sendDirectDescription => 'Send directly to this contact.'; + + @override + String get sendFlood => 'Send Flood'; + + @override + String get sendFloodDescription => 'Send via all repeaters.'; + @override String get loggedIn => 'Вход выполнен'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 22c5ee8..28d2590 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -477,6 +477,24 @@ class AppLocalizationsSl extends AppLocalizations { @override String get flood => 'Razpršitev'; + @override + String get autoSend => 'Auto Send'; + + @override + String get autoSendDescription => 'Send via current path.'; + + @override + String get sendDirect => 'Send Direct'; + + @override + String get sendDirectDescription => 'Send directly to this contact.'; + + @override + String get sendFlood => 'Send Flood'; + + @override + String get sendFloodDescription => 'Send via all repeaters.'; + @override String get loggedIn => 'Prijavljen'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 621f427..e24b214 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -480,6 +480,24 @@ class AppLocalizationsTr extends AppLocalizations { @override String get flood => 'Yayılım'; + @override + String get autoSend => 'Auto Send'; + + @override + String get autoSendDescription => 'Send via current path.'; + + @override + String get sendDirect => 'Send Direct'; + + @override + String get sendDirectDescription => 'Send directly to this contact.'; + + @override + String get sendFlood => 'Send Flood'; + + @override + String get sendFloodDescription => 'Send via all repeaters.'; + @override String get loggedIn => 'Giriş yapıldı'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 4df790b..701f4fd 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -480,6 +480,24 @@ class AppLocalizationsUk extends AppLocalizations { @override String get flood => 'Широкомовно'; + @override + String get autoSend => 'Auto Send'; + + @override + String get autoSendDescription => 'Send via current path.'; + + @override + String get sendDirect => 'Send Direct'; + + @override + String get sendDirectDescription => 'Send directly to this contact.'; + + @override + String get sendFlood => 'Send Flood'; + + @override + String get sendFloodDescription => 'Send via all repeaters.'; + @override String get loggedIn => 'Увійшли'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 30b2fd9..66465eb 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -464,6 +464,24 @@ class AppLocalizationsZh extends AppLocalizations { @override String get flood => '泛洪'; + @override + String get autoSend => 'Auto Send'; + + @override + String get autoSendDescription => 'Send via current path.'; + + @override + String get sendDirect => 'Send Direct'; + + @override + String get sendDirectDescription => 'Send directly to this contact.'; + + @override + String get sendFlood => 'Send Flood'; + + @override + String get sendFloodDescription => 'Send via all repeaters.'; + @override String get loggedIn => '已登录'; diff --git a/lib/l10n/untranslated.json b/lib/l10n/untranslated.json index b4389f4..48d17db 100644 --- a/lib/l10n/untranslated.json +++ b/lib/l10n/untranslated.json @@ -1,5 +1,11 @@ { "de": [ + "autoSend", + "autoSendDescription", + "sendDirect", + "sendDirectDescription", + "sendFlood", + "sendFloodDescription", "postConnectDiscoveryTitle", "postConnectDiscoveryDescription", "setRegionScope", @@ -16,6 +22,12 @@ ], "el": [ + "autoSend", + "autoSendDescription", + "sendDirect", + "sendDirectDescription", + "sendFlood", + "sendFloodDescription", "postConnectDiscoveryTitle", "postConnectDiscoveryDescription", "setRegionScope", @@ -32,6 +44,12 @@ ], "es": [ + "autoSend", + "autoSendDescription", + "sendDirect", + "sendDirectDescription", + "sendFlood", + "sendFloodDescription", "postConnectDiscoveryTitle", "postConnectDiscoveryDescription", "setRegionScope", @@ -48,6 +66,12 @@ ], "fr": [ + "autoSend", + "autoSendDescription", + "sendDirect", + "sendDirectDescription", + "sendFlood", + "sendFloodDescription", "postConnectDiscoveryTitle", "postConnectDiscoveryDescription", "setRegionScope", @@ -64,6 +88,12 @@ ], "hr": [ + "autoSend", + "autoSendDescription", + "sendDirect", + "sendDirectDescription", + "sendFlood", + "sendFloodDescription", "postConnectDiscoveryTitle", "postConnectDiscoveryDescription", "setRegionScope", @@ -80,6 +110,12 @@ ], "it": [ + "autoSend", + "autoSendDescription", + "sendDirect", + "sendDirectDescription", + "sendFlood", + "sendFloodDescription", "postConnectDiscoveryTitle", "postConnectDiscoveryDescription", "setRegionScope", @@ -96,6 +132,12 @@ ], "pl": [ + "autoSend", + "autoSendDescription", + "sendDirect", + "sendDirectDescription", + "sendFlood", + "sendFloodDescription", "postConnectDiscoveryTitle", "postConnectDiscoveryDescription", "setRegionScope", @@ -112,6 +154,12 @@ ], "pt": [ + "autoSend", + "autoSendDescription", + "sendDirect", + "sendDirectDescription", + "sendFlood", + "sendFloodDescription", "postConnectDiscoveryTitle", "postConnectDiscoveryDescription", "setRegionScope", @@ -128,6 +176,12 @@ ], "ru": [ + "autoSend", + "autoSendDescription", + "sendDirect", + "sendDirectDescription", + "sendFlood", + "sendFloodDescription", "postConnectDiscoveryTitle", "postConnectDiscoveryDescription", "setRegionScope", @@ -144,6 +198,12 @@ ], "sl": [ + "autoSend", + "autoSendDescription", + "sendDirect", + "sendDirectDescription", + "sendFlood", + "sendFloodDescription", "postConnectDiscoveryTitle", "postConnectDiscoveryDescription", "setRegionScope", @@ -160,6 +220,12 @@ ], "tr": [ + "autoSend", + "autoSendDescription", + "sendDirect", + "sendDirectDescription", + "sendFlood", + "sendFloodDescription", "postConnectDiscoveryTitle", "postConnectDiscoveryDescription", "setRegionScope", @@ -176,6 +242,12 @@ ], "uk": [ + "autoSend", + "autoSendDescription", + "sendDirect", + "sendDirectDescription", + "sendFlood", + "sendFloodDescription", "postConnectDiscoveryTitle", "postConnectDiscoveryDescription", "setRegionScope", @@ -192,6 +264,12 @@ ], "zh": [ + "autoSend", + "autoSendDescription", + "sendDirect", + "sendDirectDescription", + "sendFlood", + "sendFloodDescription", "postConnectDiscoveryTitle", "postConnectDiscoveryDescription", "setRegionScope", diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 208b584..1fbde18 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -2880,6 +2880,32 @@ class ConnectionProvider with ChangeNotifier { } } + /// Set a contact to zero-hop direct mode (no mesh forwarding). + /// + /// This forces the firmware to send directly to this contact without + /// using any repeaters, matching the "Send Direct" option in the + /// official MeshCore client. + Future setContactDirect(Contact contact) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + _error = null; + final updatedContact = contact.copyWith( + outPathLen: 0, // descriptor: hashSize=1, hopCount=0 → zero-hop direct + outPath: Uint8List(64), + ); + await _activeService.addOrUpdateContact(updatedContact); + } catch (e) { + _error = 'Failed to set direct path: $e'; + notifyListeners(); + rethrow; + } + } + /// Remove a contact from the companion radio /// /// Deletes the contact from the device's internal contact table. diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index bdfc478..57c0919 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -943,6 +943,69 @@ class _MessagesTabState extends State { } } + bool _isContactDestination() { + return _destinationType == + MessageDestinationPreferences.destinationTypeContact && + _selectedRecipient != null; + } + + void _showSendModeSheet() { + if (!mounted) return; + final l10n = AppLocalizations.of(context)!; + + showModalBottomSheet( + context: context, + builder: (sheetContext) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.route), + title: Text(l10n.autoSend), + subtitle: Text(l10n.autoSendDescription), + onTap: () { + Navigator.pop(sheetContext); + _sendMessage(); + }, + ), + ListTile( + leading: const Icon(Icons.near_me), + title: Text(l10n.sendDirect), + subtitle: Text(l10n.sendDirectDescription), + onTap: () async { + Navigator.pop(sheetContext); + final connectionProvider = context.read(); + if (_selectedRecipient != null && + connectionProvider.deviceInfo.isConnected) { + await connectionProvider + .setContactDirect(_selectedRecipient!); + } + await _sendMessage(); + }, + ), + ListTile( + leading: const Icon(Icons.cell_tower), + title: Text(l10n.sendFlood), + subtitle: Text(l10n.sendFloodDescription), + onTap: () async { + Navigator.pop(sheetContext); + final connectionProvider = context.read(); + if (_selectedRecipient != null && + connectionProvider.deviceInfo.isConnected) { + await connectionProvider + .resetPath(_selectedRecipient!.publicKey); + } + await _sendMessage(); + }, + ), + ], + ), + ); + }, + ); + } + Future _startTicTacToeGame() async { if (!mounted) return; if (_destinationType != @@ -2387,6 +2450,9 @@ class _MessagesTabState extends State { onStartVoiceRecording: _startVoiceRecording, onStopAndSendVoice: _stopAndSendVoice, onSendMessage: _sendMessage, + onLongPressSend: _isContactDestination() + ? _showSendModeSheet + : null, regionScopeName: _channelRegionScopeName, onRegionScopeTap: _channelRegionScopeName != null ? _showRegionScopeSheet diff --git a/lib/services/path_history_service.dart b/lib/services/path_history_service.dart index 6838f23..5fa10aa 100644 --- a/lib/services/path_history_service.dart +++ b/lib/services/path_history_service.dart @@ -290,6 +290,17 @@ class PathHistoryService { ContactPathHistory.empty(contactPublicKeyHex); } + Future clearHistoryFor(String contactPublicKeyHex) async { + await initialize(); + _cache.remove(contactPublicKeyHex); + final prefs = await SharedPreferences.getInstance(); + final payload = {}; + for (final entry in _cache.entries) { + payload[entry.key] = entry.value.toJson(); + } + await prefs.setString(_storageKey, jsonEncode(payload)); + } + ContactPathHistory _historyFor(String contactPublicKeyHex) { return _cache.putIfAbsent( contactPublicKeyHex, diff --git a/lib/widgets/contacts/contact_route_dialog.dart b/lib/widgets/contacts/contact_route_dialog.dart index f81a510..7e261bf 100644 --- a/lib/widgets/contacts/contact_route_dialog.dart +++ b/lib/widgets/contacts/contact_route_dialog.dart @@ -10,7 +10,6 @@ import '../../models/path_history.dart'; import '../../l10n/app_localizations.dart'; import '../../providers/app_provider.dart'; import '../../providers/connection_provider.dart'; -import '../../services/contact_route_resolver.dart'; import '../../services/path_history_service.dart'; import '../../services/relay_candidate_sorter.dart'; import '../../services/route_hash_preferences.dart'; @@ -80,7 +79,6 @@ class _ContactRouteDialogState extends State { ParsedContactRoute? _parsedRoute; String? _errorText; bool _showRoutingInfo = false; - bool _showManualEditor = false; List _selectedMapHops = const []; ContactPathHistory? _pathHistory; @@ -92,7 +90,6 @@ class _ContactRouteDialogState extends State { ); _relaySearchController = TextEditingController(); _controller.addListener(_reparse); - _showManualEditor = widget.contact.routeCanonicalText.isNotEmpty; _loadHashSizePreference(); _loadPathHistory(); _reparse(); @@ -240,19 +237,6 @@ class _ContactRouteDialogState extends State { }); } - void _applyResolvedPlan(ResolvedContactRoutePlan plan) { - setState(() { - _selectedMapHops = plan.selectedContacts; - _controller.text = plan.canonicalText; - _controller.selection = TextSelection.fromPosition( - TextPosition(offset: _controller.text.length), - ); - _errorText = null; - _showManualEditor = false; - }); - _reparse(); - } - void _applyHistoryRecord(PathRecord record) { final canonicalText = _canonicalRouteFromBytes( record.pathBytes, @@ -264,7 +248,6 @@ class _ContactRouteDialogState extends State { TextPosition(offset: _controller.text.length), ); _errorText = null; - _showManualEditor = true; }); _reparse(); } @@ -319,39 +302,6 @@ class _ContactRouteDialogState extends State { ); } - void _resolvePathAutomatically() { - final connectionProvider = context.read(); - final advLat = connectionProvider.deviceInfo.advLat; - final advLon = connectionProvider.deviceInfo.advLon; - final recipientLocation = widget.contact.displayLocation; - if (advLat == null || - advLon == null || - (advLat == 0 && advLon == 0) || - recipientLocation == null) { - setState(() { - _errorText = - 'Automatic resolve needs both your advertised location and the contact location.'; - }); - return; - } - - final plan = ContactRouteResolver.resolveAutomaticRoute( - senderLocation: LatLng(advLat / 1e6, advLon / 1e6), - recipient: widget.contact, - availableContacts: widget.availableContacts, - hashSize: _selectedHashSize, - ); - if (plan == null) { - setState(() { - _errorText = - 'Could not resolve a route from available repeater locations.'; - }); - return; - } - - _applyResolvedPlan(plan); - } - String _canonicalRouteFromBytes( List pathBytes, { required int hashSize, @@ -434,32 +384,32 @@ class _ContactRouteDialogState extends State { Widget _buildPreviewSection() { final previewRoute = _effectiveRoute; + if (previewRoute == null) { + return const SizedBox.shrink(); + } final colorScheme = Theme.of(context).colorScheme; return Container( width: double.infinity, - padding: const EdgeInsets.all(14), + padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(12), border: Border.all(color: colorScheme.outlineVariant), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - previewRoute == null ? 'Route preview' : previewRoute.summary, + previewRoute.summary, style: Theme.of(context).textTheme.titleSmall, ), - const SizedBox(height: 4), + const SizedBox(height: 2), Text( - previewRoute == null - ? 'Pick relays from the list below or open manual edit if you need exact hop tokens.' - : '${previewRoute.byteLength} bytes • descriptor 0x${previewRoute.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}', + '${previewRoute.byteLength}B • 0x${previewRoute.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}', style: Theme.of(context).textTheme.bodySmall, ), - if (previewRoute != null && - previewRoute.canonicalText.isNotEmpty) ...[ - const SizedBox(height: 10), + if (previewRoute.canonicalText.isNotEmpty) ...[ + const SizedBox(height: 8), SelectableText( previewRoute.canonicalText, style: Theme.of( @@ -474,18 +424,7 @@ class _ContactRouteDialogState extends State { Widget _buildSelectedHopSection() { if (_selectedMapHops.isEmpty) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Theme.of(context).dividerColor), - ), - child: Text( - 'No relays selected. Save now to use a direct path, or add repeaters below.', - style: Theme.of(context).textTheme.bodyMedium, - ), - ); + return const SizedBox.shrink(); } return Column( @@ -493,28 +432,21 @@ class _ContactRouteDialogState extends State { children: [ Text('Selected relays', style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 8), - ..._selectedMapHops.asMap().entries.map((entry) { - final index = entry.key; - final contact = entry.value; - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - leading: CircleAvatar(child: Text('${index + 1}')), - title: Text(contact.displayName), - subtitle: Text( - _tokenFor(contact, _selectedHashSize), - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), - ), - trailing: IconButton( - tooltip: 'Remove relay', - onPressed: () => _toggleHop(contact), - icon: const Icon(Icons.close), - ), - ), - ); - }), + Wrap( + spacing: 8, + runSpacing: 8, + children: _selectedMapHops.asMap().entries.map((entry) { + final index = entry.key; + final contact = entry.value; + return InputChip( + label: Text('${index + 1}. ${contact.displayName}'), + deleteIcon: const Icon(Icons.close), + onDeleted: () => _toggleHop(contact), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ); + }).toList(), + ), ], ); } @@ -563,6 +495,12 @@ class _ContactRouteDialogState extends State { return Card( margin: const EdgeInsets.only(bottom: 8), child: ListTile( + dense: true, + visualDensity: VisualDensity.compact, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), leading: Icon( isSelected ? Icons.check_circle @@ -593,41 +531,6 @@ class _ContactRouteDialogState extends State { ); } - Widget _buildManualEditor() { - return ExpansionTile( - tilePadding: EdgeInsets.zero, - childrenPadding: EdgeInsets.zero, - initiallyExpanded: _showManualEditor, - onExpansionChanged: (expanded) { - setState(() { - _showManualEditor = expanded; - }); - }, - title: Text(AppLocalizations.of(context)!.manualRouteEdit), - subtitle: const Text( - 'Use this when you need to paste or tweak hop tokens directly.', - ), - children: [ - TextField( - controller: _controller, - textCapitalization: TextCapitalization.characters, - decoration: InputDecoration( - labelText: 'Route', - hintText: _selectedHashSize == 1 - ? 'AA,BB,CC' - : _selectedHashSize == 2 - ? 'AABB,CCDD' - : 'AABBCC,DDEEFF', - helperText: - 'Comma-separated hops using the selected path size. Colon form like AA:BB is also accepted.', - errorText: _errorText, - border: const OutlineInputBorder(), - ), - ), - ], - ); - } - Widget _buildMapPreview({ required List routeCandidates, required List mapPoints, @@ -709,15 +612,14 @@ class _ContactRouteDialogState extends State { Widget _buildBuilderTab({ required List routeCandidates, - required List mapPoints, - required List routePoints, }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text('Path Size', style: Theme.of(context).textTheme.labelLarge), + Text('Path size', style: Theme.of(context).textTheme.labelLarge), const Spacer(), SegmentedButton( segments: [ @@ -734,36 +636,32 @@ class _ContactRouteDialogState extends State { ), ], ), + if (_errorText != null) ...[ + const SizedBox(height: 12), + Text( + _errorText!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + if (_selectedMapHops.isNotEmpty) ...[ + const SizedBox(height: 12), + _buildSelectedHopSection(), + ], const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - FilledButton.tonalIcon( - onPressed: _resolvePathAutomatically, - icon: Icon(Icons.auto_fix_high), - label: Text(AppLocalizations.of(context)!.autoResolve), - ), - OutlinedButton.icon( - onPressed: _selectedMapHops.isEmpty - ? null - : () { - setState(() { - _selectedMapHops = const []; - _syncControllerFromSelectedHops(); - }); - }, - icon: Icon(Icons.clear_all), - label: Text(AppLocalizations.of(context)!.clearRelays), - ), - ], - ), - const SizedBox(height: 16), - _buildPreviewSection(), - const SizedBox(height: 16), - _buildSelectedHopSection(), - const SizedBox(height: 16), _buildRelayPicker(routeCandidates), + ], + ); + } + + Widget _buildInfoTab({ + required AppProvider appProvider, + required List routeCandidates, + required List mapPoints, + required List routePoints, + }) { + return ListView( + children: [ + _buildPreviewSection(), const SizedBox(height: 16), _buildMapPreview( routeCandidates: routeCandidates, @@ -771,7 +669,18 @@ class _ContactRouteDialogState extends State { routePoints: routePoints, ), const SizedBox(height: 16), - _buildManualEditor(), + _AutomationRoutingInfo( + isExpanded: _showRoutingInfo, + onToggle: () { + setState(() { + _showRoutingInfo = !_showRoutingInfo; + }); + }, + autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled, + nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled, + clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry, + ), + const SizedBox(height: 24), ], ); } @@ -808,6 +717,24 @@ class _ContactRouteDialogState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () async { + await _pathHistoryService.clearHistoryFor( + widget.contact.publicKeyHex, + ); + if (!mounted) return; + setState(() { + _pathHistory = _pathHistoryService.historyFor( + widget.contact.publicKeyHex, + ); + }); + }, + child: const Text('Clear history'), + ), + ), + const SizedBox(height: 8), if (observedRecord != null) ...[ _buildHistoryRecordTile(observedRecord, title: AppLocalizations.of(context)!.observedMeshRoute), const SizedBox(height: 16), @@ -877,7 +804,7 @@ class _ContactRouteDialogState extends State { ]; return DefaultTabController( - length: 2, + length: 3, child: Scaffold( appBar: AppBar( title: Text('Set Path for ${widget.contact.displayName}'), @@ -885,56 +812,34 @@ class _ContactRouteDialogState extends State { tabs: [ Tab(text: 'Build'), Tab(text: 'History'), + Tab(text: 'Info'), ], ), ), body: SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: TabBarView( children: [ - Text( - 'Plan the route on its own screen, then save it once the preview looks right.', - style: Theme.of(context).textTheme.bodyMedium, + ListView( + children: [ + _buildBuilderTab( + routeCandidates: routeCandidates, + ), + const SizedBox(height: 24), + ], ), - const SizedBox(height: 16), - Expanded( - child: TabBarView( - children: [ - ListView( - children: [ - _buildBuilderTab( - routeCandidates: routeCandidates, - mapPoints: mapPoints, - routePoints: routePoints, - ), - const SizedBox(height: 16), - _AutomationRoutingInfo( - isExpanded: _showRoutingInfo, - onToggle: () { - setState(() { - _showRoutingInfo = !_showRoutingInfo; - }); - }, - autoRouteRotationEnabled: - appProvider.autoRouteRotationEnabled, - nearestRelayFallbackEnabled: - appProvider.nearestRelayFallbackEnabled, - clearPathOnMaxRetry: - appProvider.clearPathOnMaxRetry, - ), - const SizedBox(height: 24), - ], - ), - ListView( - children: [ - _buildHistoryTab(), - const SizedBox(height: 24), - ], - ), - ], - ), + ListView( + children: [ + _buildHistoryTab(), + const SizedBox(height: 24), + ], + ), + _buildInfoTab( + appProvider: appProvider, + routeCandidates: routeCandidates, + mapPoints: mapPoints, + routePoints: routePoints, ), ], ), diff --git a/lib/widgets/messages/messages_composer.dart b/lib/widgets/messages/messages_composer.dart index 1a72c62..45664ca 100644 --- a/lib/widgets/messages/messages_composer.dart +++ b/lib/widgets/messages/messages_composer.dart @@ -27,6 +27,7 @@ class MessagesComposer extends StatelessWidget { final Future Function() onStartVoiceRecording; final Future Function() onStopAndSendVoice; final Future Function() onSendMessage; + final VoidCallback? onLongPressSend; final String? regionScopeName; final VoidCallback? onRegionScopeTap; @@ -51,6 +52,7 @@ class MessagesComposer extends StatelessWidget { required this.onStartVoiceRecording, required this.onStopAndSendVoice, required this.onSendMessage, + this.onLongPressSend, this.regionScopeName, this.onRegionScopeTap, }); @@ -163,6 +165,7 @@ class MessagesComposer extends StatelessWidget { messageByteCount: messageByteCount, maxMessageBytes: maxMessageBytes, onSendMessage: onSendMessage, + onLongPressSend: onLongPressSend, onStartVoiceRecording: onStartVoiceRecording, onStopAndSendVoice: onStopAndSendVoice, ), @@ -433,6 +436,7 @@ class _SendButton extends StatelessWidget { final int messageByteCount; final int maxMessageBytes; final Future Function() onSendMessage; + final VoidCallback? onLongPressSend; final Future Function() onStartVoiceRecording; final Future Function() onStopAndSendVoice; @@ -445,6 +449,7 @@ class _SendButton extends StatelessWidget { required this.messageByteCount, required this.maxMessageBytes, required this.onSendMessage, + this.onLongPressSend, required this.onStartVoiceRecording, required this.onStopAndSendVoice, }); @@ -456,28 +461,32 @@ class _SendButton extends StatelessWidget { enabled: canSendText || (voiceSupported && !isSendingVoice), label: semanticsLabel, onTap: canSendText ? onSendMessage : null, - onLongPress: (voiceSupported && !isSendingVoice) - ? () { - if (isRecording) { - onStopAndSendVoice(); - return; - } - onStartVoiceRecording(); - } - : null, + onLongPress: canSendText && onLongPressSend != null + ? onLongPressSend + : (voiceSupported && !isSendingVoice) + ? () { + if (isRecording) { + onStopAndSendVoice(); + return; + } + onStartVoiceRecording(); + } + : null, child: Tooltip( message: semanticsLabel, excludeFromSemantics: true, child: GestureDetector( excludeFromSemantics: true, onTap: canSendText ? onSendMessage : null, - onLongPressStart: (voiceSupported && !isSendingVoice) - ? (_) => onStartVoiceRecording() - : null, - onLongPressEnd: (voiceSupported && isRecording) + onLongPressStart: canSendText && onLongPressSend != null + ? (_) => onLongPressSend!() + : (voiceSupported && !isSendingVoice) + ? (_) => onStartVoiceRecording() + : null, + onLongPressEnd: (!canSendText && voiceSupported && isRecording) ? (_) => onStopAndSendVoice() : null, - onLongPressCancel: (voiceSupported && isRecording) + onLongPressCancel: (!canSendText && voiceSupported && isRecording) ? onStopAndSendVoice : null, child: Column( diff --git a/test/services/path_history_service_test.dart b/test/services/path_history_service_test.dart index 0e3e8e7..8f16107 100644 --- a/test/services/path_history_service_test.dart +++ b/test/services/path_history_service_test.dart @@ -207,6 +207,22 @@ void main() { }, ); + test('clear history removes stored direct paths for one contact', () async { + final service = PathHistoryService(); + + await service.initialize(); + await service.recordReceivedBytePath('abc123', [0x01, 0x02], 1); + await service.recordReceivedBytePath('def456', [0x03, 0x04], 1); + + expect(service.historyFor('abc123').directPaths, hasLength(1)); + expect(service.historyFor('def456').directPaths, hasLength(1)); + + await service.clearHistoryFor('abc123'); + + expect(service.historyFor('abc123').directPaths, isEmpty); + expect(service.historyFor('def456').directPaths, hasLength(1)); + }); + test('last successful direct path is chosen by location fit', () async { final service = PathHistoryService(); final contact = _buildContact( diff --git a/test/widgets/sensor_telemetry_card_test.dart b/test/widgets/sensor_telemetry_card_test.dart index 465c776..9942cc4 100644 --- a/test/widgets/sensor_telemetry_card_test.dart +++ b/test/widgets/sensor_telemetry_card_test.dart @@ -415,8 +415,11 @@ void main() { ), ); + final scaffoldKey = GlobalKey(); + await tester.pumpWidget( MaterialApp( + scaffoldMessengerKey: scaffoldKey, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( @@ -431,15 +434,43 @@ void main() { ); await tester.tap(find.byIcon(Icons.more_vert)); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); expect(find.text('Copy raw response'), findsOneWidget); + // Capture clipboard writes via the test platform channel mock. + String? clipboardText; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall call) async { + if (call.method == 'Clipboard.setData') { + final args = call.arguments as Map; + clipboardText = args['text'] as String?; + } + if (call.method == 'Clipboard.getData') { + return {'text': clipboardText}; + } + return null; + }, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); + await tester.tap(find.text('Copy raw response')); await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); - final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); - expect(clipboardData?.text, '01 67 00 d7'); + expect(clipboardText, '01 67 00 d7'); expect(find.text('Raw response copied'), findsOneWidget); + + // Clear the SnackBar to prevent its timer from blocking teardown. + scaffoldKey.currentState?.clearSnackBars(); + await tester.pump(const Duration(seconds: 5)); + await tester.pump(const Duration(seconds: 5)); }); }