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 <noreply@anthropic.com>
fix/652-release-prerelease-flag
Strycher 3 weeks ago
parent c27bef0f73
commit a88c24f44f

@ -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

@ -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<ContactsScreen>
),
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<ContactsScreen>
}
}
/// 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<void> _scanContactQr(BuildContext context) async {
final scanned = await Navigator.of(context).push<String>(
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)

@ -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<void> 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<void> showAddContactByKeyDialog(
BuildContext context, {
String? initialKeyText,
}) {
return showDialog<void>(
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<void> _scan() async {
final scanned = await Navigator.of(context).push<String>(
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,

@ -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<MeshCoreConnector>.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 {

Loading…
Cancel
Save

Powered by TurnKey Linux.