From a88c24f44fa4a1404b0831e497541cfaea5ffaaa Mon Sep 17 00:00:00 2001 From: Strycher Date: Wed, 9 Sep 2026 01:56:38 -0400 Subject: [PATCH] fix(#629): put Scan contact QR on the contacts menu, not only in the key field Owner feedback from testing: a scan button living only inside the public key field is awkward placement, because scanning is how most people will actually add someone and it should not be reachable only from inside a field they have to open first. Now in both places, routed to the right thing: - Contacts overflow menu, Scan contact QR, opens the scanner and hands the result to the add dialog already populated. Nothing to paste. - The key field keeps its scan button, which fills in place, for when the dialog is already open. Same scanner and same parser either way; only the entry point differs. The dialog gains an optional initialKeyText, and seeds it through the same handler as a paste, so a scanned link populates name and type rather than sitting there as raw text. Platform availability moves to a shared contactQrScanAvailable getter on the scanner screen, since it now has two consumers. Both the menu entry and the field button hide on Windows and Linux, where mobile_scanner has no support and the render half is the desktop path. Epic #619. Co-Authored-By: Claude Opus 5 --- lib/screens/contact_qr_scanner_screen.dart | 12 ++++++ lib/screens/contacts_screen.dart | 29 +++++++++++++ lib/widgets/add_contact_by_key_dialog.dart | 34 ++++++++++----- .../add_contact_by_key_dialog_test.dart | 43 +++++++++++++++++++ 4 files changed, 107 insertions(+), 11 deletions(-) diff --git a/lib/screens/contact_qr_scanner_screen.dart b/lib/screens/contact_qr_scanner_screen.dart index c0f0937..4a96095 100644 --- a/lib/screens/contact_qr_scanner_screen.dart +++ b/lib/screens/contact_qr_scanner_screen.dart @@ -3,8 +3,20 @@ import 'package:flutter/material.dart'; import '../helpers/snack_bar_builder.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; +import '../utils/platform_info.dart'; import '../widgets/qr_scanner_widget.dart'; +/// Whether a contact QR can be scanned on this platform. +/// +/// `mobile_scanner` covers Android, iOS, macOS and web, but not Windows or +/// Linux. On those the QR half of the exchange is the RENDER side: show your +/// own code from the desktop and let the other person scan it with a phone. +/// +/// Lives here rather than on a caller because both the key-entry field and the +/// contacts menu need it. (#629) +bool get contactQrScanAvailable => + PlatformInfo.isMobile || PlatformInfo.isMacOS || PlatformInfo.isWeb; + /// Scans a contact QR and pops the raw `meshcore://contact/add` string. (#629) /// /// A contact QR is only that URI rendered visually, so this validates with the diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index c29acb0..379b29d 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -41,6 +41,7 @@ import '../widgets/unread_badge.dart'; import '../helpers/snack_bar_builder.dart'; import 'channels_screen.dart'; import 'chat_screen.dart'; +import 'contact_qr_scanner_screen.dart'; import 'discovery_screen.dart'; import 'map_screen.dart'; import 'repeater_hub_screen.dart'; @@ -427,6 +428,20 @@ class _ContactsScreenState extends State ), onTap: () => showAddContactByKeyDialog(context), ), + // Scanning is a first-class way in, not just a button buried in + // the key field, so it gets its own entry here and routes + // straight into the add flow already filled in. (#629) + if (contactQrScanAvailable) + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.qr_code_scanner), + const SizedBox(width: 8), + Text(context.l10n.contacts_scanContactQr), + ], + ), + onTap: () => _scanContactQr(context), + ), PopupMenuItem( child: Row( children: [ @@ -942,6 +957,20 @@ class _ContactsScreenState extends State } } + /// Scan a contact QR from the contacts menu, then hand the result to the add + /// dialog already populated. (#629) + /// + /// Same scanner and same parser as the key-field button; only the entry point + /// differs. Scanning is how most people will actually add someone, so it + /// should not be reachable only from inside a field they have to open first. + Future _scanContactQr(BuildContext context) async { + final scanned = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ContactQrScannerScreen()), + ); + if (!context.mounted || scanned == null) return; + await showAddContactByKeyDialog(context, initialKeyText: scanned); + } + DateTime _resolveLastSeen(Contact contact) { if (contact.type != advTypeChat) return contact.lastSeen; return contact.lastMessageAt.isAfter(contact.lastSeen) diff --git a/lib/widgets/add_contact_by_key_dialog.dart b/lib/widgets/add_contact_by_key_dialog.dart index 044cf74..2004f50 100644 --- a/lib/widgets/add_contact_by_key_dialog.dart +++ b/lib/widgets/add_contact_by_key_dialog.dart @@ -7,7 +7,6 @@ import '../l10n/l10n.dart'; import '../helpers/snack_bar_builder.dart'; import '../models/contact.dart'; import '../screens/contact_qr_scanner_screen.dart'; -import '../utils/platform_info.dart'; /// Adds a contact from an identity alone: a public key, a name and a type. /// @@ -18,15 +17,22 @@ import '../utils/platform_info.dart'; /// The field also accepts a whole `meshcore://contact/add` link, since that is /// what someone is most likely to paste, and a QR is only that same link /// rendered visually. -Future showAddContactByKeyDialog(BuildContext context) { +/// [initialKeyText] seeds the key field, so a scan taken from somewhere else +/// can open this already filled in rather than making the user paste. (#629) +Future showAddContactByKeyDialog( + BuildContext context, { + String? initialKeyText, +}) { return showDialog( context: context, - builder: (_) => const _AddContactByKeyDialog(), + builder: (_) => _AddContactByKeyDialog(initialKeyText: initialKeyText), ); } class _AddContactByKeyDialog extends StatefulWidget { - const _AddContactByKeyDialog(); + const _AddContactByKeyDialog({this.initialKeyText}); + + final String? initialKeyText; @override State<_AddContactByKeyDialog> createState() => _AddContactByKeyDialogState(); @@ -39,6 +45,18 @@ class _AddContactByKeyDialogState extends State<_AddContactByKeyDialog> { String? _keyError; bool _submitting = false; + @override + void initState() { + super.initState(); + final seed = widget.initialKeyText; + if (seed != null && seed.isNotEmpty) { + _keyController.text = seed; + // Route the seed through the same handler as a paste, so a full link + // populates name and type instead of sitting there as raw text. + _onKeyChanged(seed); + } + } + @override void dispose() { _keyController.dispose(); @@ -75,12 +93,6 @@ class _AddContactByKeyDialogState extends State<_AddContactByKeyDialog> { if (_keyError != null) setState(() => _keyError = null); } - /// mobile_scanner covers Android, iOS, macOS and web, but not Windows or - /// Linux. On those the QR half of the exchange is the RENDER side: show your - /// own code from the desktop and let the other person scan it with a phone. - static bool get _cameraAvailable => - PlatformInfo.isMobile || PlatformInfo.isMacOS || PlatformInfo.isWeb; - Future _scan() async { final scanned = await Navigator.of(context).push( MaterialPageRoute(builder: (_) => const ContactQrScannerScreen()), @@ -157,7 +169,7 @@ class _AddContactByKeyDialogState extends State<_AddContactByKeyDialog> { errorText: _keyError, errorMaxLines: 2, border: const OutlineInputBorder(), - suffixIcon: _cameraAvailable + suffixIcon: contactQrScanAvailable ? IconButton( icon: const Icon(Icons.qr_code_scanner), tooltip: l10n.contacts_scanContactQr, diff --git a/test/widgets/add_contact_by_key_dialog_test.dart b/test/widgets/add_contact_by_key_dialog_test.dart index 1337a4b..e6780ad 100644 --- a/test/widgets/add_contact_by_key_dialog_test.dart +++ b/test/widgets/add_contact_by_key_dialog_test.dart @@ -137,6 +137,49 @@ void main() { ); }); + testWidgets('a scanned link opens the dialog already populated', ( + tester, + ) async { + // The contacts-menu scan route hands its result straight to the dialog, so + // the user should not have to paste anything. (#629) + final conn = _CapturingConn(); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: conn, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showAddContactByKeyDialog( + context, + initialKeyText: + 'meshcore://contact/add?name=Scanned+Node' + '&public_key=$_key&type=3', + ), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + // Straight to Add, with nothing typed by hand. + await tester.tap(find.widgetWithText(FilledButton, 'Add')); + await tester.pumpAndSettle(); + + expect(conn.added, hasLength(1)); + final stub = conn.added.single; + expect(stub.publicKeyHex, _key); + expect(stub.name, 'Scanned Node'); + expect(stub.type, advTypeRoom); + expect(stub.lastSeen, DateTime.fromMillisecondsSinceEpoch(0)); + }); + testWidgets('a missing name falls back rather than blocking the add', ( tester, ) async {