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 {