diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 05054c3..779f87a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2510,6 +2510,13 @@ } }, "contacts_addByKeyFailed": "Could not add the contact. Check the radio connection.", + "contacts_myContactQr": "My contact QR", + "contacts_myContactQrInstructions": "Have them scan this, or send them the link below. It works even if they have never heard your advert.", + "contacts_contactLinkCopied": "Contact link copied", + "contacts_qrNeedsConnection": "Connect to a radio first, so the app knows your public key.", + "contacts_scanContactQr": "Scan contact QR", + "contacts_scanContactQrInstructions": "Point the camera at a MeshCore contact QR", + "contacts_invalidContactQr": "That QR is not a MeshCore contact", "contacts_ShareContact": "Copy contact to Clipboard", "contacts_ShareContactZeroHop": "Share contact by advert", "contacts_zeroHopContactAdvertSent": "Sent contact by advert.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3d62feb..2fa3ea3 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -7510,6 +7510,48 @@ abstract class AppLocalizations { /// **'Could not add the contact. Check the radio connection.'** String get contacts_addByKeyFailed; + /// No description provided for @contacts_myContactQr. + /// + /// In en, this message translates to: + /// **'My contact QR'** + String get contacts_myContactQr; + + /// No description provided for @contacts_myContactQrInstructions. + /// + /// In en, this message translates to: + /// **'Have them scan this, or send them the link below. It works even if they have never heard your advert.'** + String get contacts_myContactQrInstructions; + + /// No description provided for @contacts_contactLinkCopied. + /// + /// In en, this message translates to: + /// **'Contact link copied'** + String get contacts_contactLinkCopied; + + /// No description provided for @contacts_qrNeedsConnection. + /// + /// In en, this message translates to: + /// **'Connect to a radio first, so the app knows your public key.'** + String get contacts_qrNeedsConnection; + + /// No description provided for @contacts_scanContactQr. + /// + /// In en, this message translates to: + /// **'Scan contact QR'** + String get contacts_scanContactQr; + + /// No description provided for @contacts_scanContactQrInstructions. + /// + /// In en, this message translates to: + /// **'Point the camera at a MeshCore contact QR'** + String get contacts_scanContactQrInstructions; + + /// No description provided for @contacts_invalidContactQr. + /// + /// In en, this message translates to: + /// **'That QR is not a MeshCore contact'** + String get contacts_invalidContactQr; + /// No description provided for @contacts_ShareContact. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 3448993..1539e42 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -4386,6 +4386,30 @@ class AppLocalizationsBg extends AppLocalizations { String get contacts_addByKeyFailed => 'Could not add the contact. Check the radio connection.'; + @override + String get contacts_myContactQr => 'My contact QR'; + + @override + String get contacts_myContactQrInstructions => + 'Have them scan this, or send them the link below. It works even if they have never heard your advert.'; + + @override + String get contacts_contactLinkCopied => 'Contact link copied'; + + @override + String get contacts_qrNeedsConnection => + 'Connect to a radio first, so the app knows your public key.'; + + @override + String get contacts_scanContactQr => 'Scan contact QR'; + + @override + String get contacts_scanContactQrInstructions => + 'Point the camera at a MeshCore contact QR'; + + @override + String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override String get contacts_ShareContact => 'Копирай контакт в клипборда'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 6d2927d..f7cac5a 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -4398,6 +4398,30 @@ class AppLocalizationsDe extends AppLocalizations { String get contacts_addByKeyFailed => 'Could not add the contact. Check the radio connection.'; + @override + String get contacts_myContactQr => 'My contact QR'; + + @override + String get contacts_myContactQrInstructions => + 'Have them scan this, or send them the link below. It works even if they have never heard your advert.'; + + @override + String get contacts_contactLinkCopied => 'Contact link copied'; + + @override + String get contacts_qrNeedsConnection => + 'Connect to a radio first, so the app knows your public key.'; + + @override + String get contacts_scanContactQr => 'Scan contact QR'; + + @override + String get contacts_scanContactQrInstructions => + 'Point the camera at a MeshCore contact QR'; + + @override + String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override String get contacts_ShareContact => 'Kontakt in die Zwischenablage kopieren'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a0ab4d3..7cf67ed 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -4320,6 +4320,30 @@ class AppLocalizationsEn extends AppLocalizations { String get contacts_addByKeyFailed => 'Could not add the contact. Check the radio connection.'; + @override + String get contacts_myContactQr => 'My contact QR'; + + @override + String get contacts_myContactQrInstructions => + 'Have them scan this, or send them the link below. It works even if they have never heard your advert.'; + + @override + String get contacts_contactLinkCopied => 'Contact link copied'; + + @override + String get contacts_qrNeedsConnection => + 'Connect to a radio first, so the app knows your public key.'; + + @override + String get contacts_scanContactQr => 'Scan contact QR'; + + @override + String get contacts_scanContactQrInstructions => + 'Point the camera at a MeshCore contact QR'; + + @override + String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override String get contacts_ShareContact => 'Copy contact to Clipboard'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 22c4a43..fe21ea9 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -4387,6 +4387,30 @@ class AppLocalizationsEs extends AppLocalizations { String get contacts_addByKeyFailed => 'Could not add the contact. Check the radio connection.'; + @override + String get contacts_myContactQr => 'My contact QR'; + + @override + String get contacts_myContactQrInstructions => + 'Have them scan this, or send them the link below. It works even if they have never heard your advert.'; + + @override + String get contacts_contactLinkCopied => 'Contact link copied'; + + @override + String get contacts_qrNeedsConnection => + 'Connect to a radio first, so the app knows your public key.'; + + @override + String get contacts_scanContactQr => 'Scan contact QR'; + + @override + String get contacts_scanContactQrInstructions => + 'Point the camera at a MeshCore contact QR'; + + @override + String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override String get contacts_ShareContact => 'Copiar contacto al Portapapeles'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index f82d58f..e6da2a2 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -4409,6 +4409,30 @@ class AppLocalizationsFr extends AppLocalizations { String get contacts_addByKeyFailed => 'Could not add the contact. Check the radio connection.'; + @override + String get contacts_myContactQr => 'My contact QR'; + + @override + String get contacts_myContactQrInstructions => + 'Have them scan this, or send them the link below. It works even if they have never heard your advert.'; + + @override + String get contacts_contactLinkCopied => 'Contact link copied'; + + @override + String get contacts_qrNeedsConnection => + 'Connect to a radio first, so the app knows your public key.'; + + @override + String get contacts_scanContactQr => 'Scan contact QR'; + + @override + String get contacts_scanContactQrInstructions => + 'Point the camera at a MeshCore contact QR'; + + @override + String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override String get contacts_ShareContact => 'Copier le contact dans le presse-papiers'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 1b32e33..1f1fe9f 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -4404,6 +4404,30 @@ class AppLocalizationsHu extends AppLocalizations { String get contacts_addByKeyFailed => 'Could not add the contact. Check the radio connection.'; + @override + String get contacts_myContactQr => 'My contact QR'; + + @override + String get contacts_myContactQrInstructions => + 'Have them scan this, or send them the link below. It works even if they have never heard your advert.'; + + @override + String get contacts_contactLinkCopied => 'Contact link copied'; + + @override + String get contacts_qrNeedsConnection => + 'Connect to a radio first, so the app knows your public key.'; + + @override + String get contacts_scanContactQr => 'Scan contact QR'; + + @override + String get contacts_scanContactQrInstructions => + 'Point the camera at a MeshCore contact QR'; + + @override + String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override String get contacts_ShareContact => 'Másolja a kapcsolatot a kiválasztóba'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index bab9a0f..eb9baac 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -4392,6 +4392,30 @@ class AppLocalizationsIt extends AppLocalizations { String get contacts_addByKeyFailed => 'Could not add the contact. Check the radio connection.'; + @override + String get contacts_myContactQr => 'My contact QR'; + + @override + String get contacts_myContactQrInstructions => + 'Have them scan this, or send them the link below. It works even if they have never heard your advert.'; + + @override + String get contacts_contactLinkCopied => 'Contact link copied'; + + @override + String get contacts_qrNeedsConnection => + 'Connect to a radio first, so the app knows your public key.'; + + @override + String get contacts_scanContactQr => 'Scan contact QR'; + + @override + String get contacts_scanContactQrInstructions => + 'Point the camera at a MeshCore contact QR'; + + @override + String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override String get contacts_ShareContact => 'Copia contatto negli Appunti'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index ba56ead..10c1b22 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -4189,6 +4189,30 @@ class AppLocalizationsJa extends AppLocalizations { String get contacts_addByKeyFailed => 'Could not add the contact. Check the radio connection.'; + @override + String get contacts_myContactQr => 'My contact QR'; + + @override + String get contacts_myContactQrInstructions => + 'Have them scan this, or send them the link below. It works even if they have never heard your advert.'; + + @override + String get contacts_contactLinkCopied => 'Contact link copied'; + + @override + String get contacts_qrNeedsConnection => + 'Connect to a radio first, so the app knows your public key.'; + + @override + String get contacts_scanContactQr => 'Scan contact QR'; + + @override + String get contacts_scanContactQrInstructions => + 'Point the camera at a MeshCore contact QR'; + + @override + String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override String get contacts_ShareContact => '連絡先をクリップボードにコピー'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 880f524..21924a7 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -4191,6 +4191,30 @@ class AppLocalizationsKo extends AppLocalizations { String get contacts_addByKeyFailed => 'Could not add the contact. Check the radio connection.'; + @override + String get contacts_myContactQr => 'My contact QR'; + + @override + String get contacts_myContactQrInstructions => + 'Have them scan this, or send them the link below. It works even if they have never heard your advert.'; + + @override + String get contacts_contactLinkCopied => 'Contact link copied'; + + @override + String get contacts_qrNeedsConnection => + 'Connect to a radio first, so the app knows your public key.'; + + @override + String get contacts_scanContactQr => 'Scan contact QR'; + + @override + String get contacts_scanContactQrInstructions => + 'Point the camera at a MeshCore contact QR'; + + @override + String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override String get contacts_ShareContact => '연락처를 복사'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index ddbbe3f..604fde2 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -4372,6 +4372,30 @@ class AppLocalizationsNl extends AppLocalizations { String get contacts_addByKeyFailed => 'Could not add the contact. Check the radio connection.'; + @override + String get contacts_myContactQr => 'My contact QR'; + + @override + String get contacts_myContactQrInstructions => + 'Have them scan this, or send them the link below. It works even if they have never heard your advert.'; + + @override + String get contacts_contactLinkCopied => 'Contact link copied'; + + @override + String get contacts_qrNeedsConnection => + 'Connect to a radio first, so the app knows your public key.'; + + @override + String get contacts_scanContactQr => 'Scan contact QR'; + + @override + String get contacts_scanContactQrInstructions => + 'Point the camera at a MeshCore contact QR'; + + @override + String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override String get contacts_ShareContact => 'Contact naar Klembord kopiëren'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 77e7f54..d2edb69 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -4402,6 +4402,30 @@ class AppLocalizationsPl extends AppLocalizations { String get contacts_addByKeyFailed => 'Could not add the contact. Check the radio connection.'; + @override + String get contacts_myContactQr => 'My contact QR'; + + @override + String get contacts_myContactQrInstructions => + 'Have them scan this, or send them the link below. It works even if they have never heard your advert.'; + + @override + String get contacts_contactLinkCopied => 'Contact link copied'; + + @override + String get contacts_qrNeedsConnection => + 'Connect to a radio first, so the app knows your public key.'; + + @override + String get contacts_scanContactQr => 'Scan contact QR'; + + @override + String get contacts_scanContactQrInstructions => + 'Point the camera at a MeshCore contact QR'; + + @override + String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override String get contacts_ShareContact => 'Kopiuj kontakt do schowka'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index a50e6b4..e8f5935 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -4384,6 +4384,30 @@ class AppLocalizationsPt extends AppLocalizations { String get contacts_addByKeyFailed => 'Could not add the contact. Check the radio connection.'; + @override + String get contacts_myContactQr => 'My contact QR'; + + @override + String get contacts_myContactQrInstructions => + 'Have them scan this, or send them the link below. It works even if they have never heard your advert.'; + + @override + String get contacts_contactLinkCopied => 'Contact link copied'; + + @override + String get contacts_qrNeedsConnection => + 'Connect to a radio first, so the app knows your public key.'; + + @override + String get contacts_scanContactQr => 'Scan contact QR'; + + @override + String get contacts_scanContactQrInstructions => + 'Point the camera at a MeshCore contact QR'; + + @override + String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override String get contacts_ShareContact => 'Copiar contato para Área de Transferência'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index cd0d8f1..40a8bbe 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -4394,6 +4394,30 @@ class AppLocalizationsRu extends AppLocalizations { String get contacts_addByKeyFailed => 'Could not add the contact. Check the radio connection.'; + @override + String get contacts_myContactQr => 'My contact QR'; + + @override + String get contacts_myContactQrInstructions => + 'Have them scan this, or send them the link below. It works even if they have never heard your advert.'; + + @override + String get contacts_contactLinkCopied => 'Contact link copied'; + + @override + String get contacts_qrNeedsConnection => + 'Connect to a radio first, so the app knows your public key.'; + + @override + String get contacts_scanContactQr => 'Scan contact QR'; + + @override + String get contacts_scanContactQrInstructions => + 'Point the camera at a MeshCore contact QR'; + + @override + String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override String get contacts_ShareContact => 'Копировать контакт в буфер обмена'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 826e796..f9558e4 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -4368,6 +4368,30 @@ class AppLocalizationsSk extends AppLocalizations { String get contacts_addByKeyFailed => 'Could not add the contact. Check the radio connection.'; + @override + String get contacts_myContactQr => 'My contact QR'; + + @override + String get contacts_myContactQrInstructions => + 'Have them scan this, or send them the link below. It works even if they have never heard your advert.'; + + @override + String get contacts_contactLinkCopied => 'Contact link copied'; + + @override + String get contacts_qrNeedsConnection => + 'Connect to a radio first, so the app knows your public key.'; + + @override + String get contacts_scanContactQr => 'Scan contact QR'; + + @override + String get contacts_scanContactQrInstructions => + 'Point the camera at a MeshCore contact QR'; + + @override + String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override String get contacts_ShareContact => 'Kopírovať kontakt do schránky'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index a53562a..98974c8 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -4362,6 +4362,30 @@ class AppLocalizationsSl extends AppLocalizations { String get contacts_addByKeyFailed => 'Could not add the contact. Check the radio connection.'; + @override + String get contacts_myContactQr => 'My contact QR'; + + @override + String get contacts_myContactQrInstructions => + 'Have them scan this, or send them the link below. It works even if they have never heard your advert.'; + + @override + String get contacts_contactLinkCopied => 'Contact link copied'; + + @override + String get contacts_qrNeedsConnection => + 'Connect to a radio first, so the app knows your public key.'; + + @override + String get contacts_scanContactQr => 'Scan contact QR'; + + @override + String get contacts_scanContactQrInstructions => + 'Point the camera at a MeshCore contact QR'; + + @override + String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override String get contacts_ShareContact => 'Kopiraj stik v Odložišče'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index c4ea817..2a839b0 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -4341,6 +4341,30 @@ class AppLocalizationsSv extends AppLocalizations { String get contacts_addByKeyFailed => 'Could not add the contact. Check the radio connection.'; + @override + String get contacts_myContactQr => 'My contact QR'; + + @override + String get contacts_myContactQrInstructions => + 'Have them scan this, or send them the link below. It works even if they have never heard your advert.'; + + @override + String get contacts_contactLinkCopied => 'Contact link copied'; + + @override + String get contacts_qrNeedsConnection => + 'Connect to a radio first, so the app knows your public key.'; + + @override + String get contacts_scanContactQr => 'Scan contact QR'; + + @override + String get contacts_scanContactQrInstructions => + 'Point the camera at a MeshCore contact QR'; + + @override + String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override String get contacts_ShareContact => 'Kopiera kontakt till Urklipp'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 239dc2d..5ad27c6 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -4393,6 +4393,30 @@ class AppLocalizationsUk extends AppLocalizations { String get contacts_addByKeyFailed => 'Could not add the contact. Check the radio connection.'; + @override + String get contacts_myContactQr => 'My contact QR'; + + @override + String get contacts_myContactQrInstructions => + 'Have them scan this, or send them the link below. It works even if they have never heard your advert.'; + + @override + String get contacts_contactLinkCopied => 'Contact link copied'; + + @override + String get contacts_qrNeedsConnection => + 'Connect to a radio first, so the app knows your public key.'; + + @override + String get contacts_scanContactQr => 'Scan contact QR'; + + @override + String get contacts_scanContactQrInstructions => + 'Point the camera at a MeshCore contact QR'; + + @override + String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override String get contacts_ShareContact => 'Копіювати контакт у буфер обміну'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index e83864f..f461447 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -4092,6 +4092,30 @@ class AppLocalizationsZh extends AppLocalizations { String get contacts_addByKeyFailed => 'Could not add the contact. Check the radio connection.'; + @override + String get contacts_myContactQr => 'My contact QR'; + + @override + String get contacts_myContactQrInstructions => + 'Have them scan this, or send them the link below. It works even if they have never heard your advert.'; + + @override + String get contacts_contactLinkCopied => 'Contact link copied'; + + @override + String get contacts_qrNeedsConnection => + 'Connect to a radio first, so the app knows your public key.'; + + @override + String get contacts_scanContactQr => 'Scan contact QR'; + + @override + String get contacts_scanContactQrInstructions => + 'Point the camera at a MeshCore contact QR'; + + @override + String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override String get contacts_ShareContact => '复制联系人信息到剪贴板'; diff --git a/lib/screens/contact_qr_scanner_screen.dart b/lib/screens/contact_qr_scanner_screen.dart new file mode 100644 index 0000000..c0f0937 --- /dev/null +++ b/lib/screens/contact_qr_scanner_screen.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import '../helpers/snack_bar_builder.dart'; +import '../l10n/l10n.dart'; +import '../models/contact.dart'; +import '../widgets/qr_scanner_widget.dart'; + +/// 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 +/// same [Contact.isValidShareUri] the paste path uses. There is no second +/// format and no second parser. +class ContactQrScannerScreen extends StatelessWidget { + const ContactQrScannerScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(context.l10n.contacts_scanContactQr), + centerTitle: true, + ), + body: QrScannerWidget( + instructions: context.l10n.contacts_scanContactQrInstructions, + validator: Contact.isValidShareUri, + onScanned: (data) => Navigator.of(context).pop(data), + onValidationFailed: (_) => showDismissibleSnackBar( + context, + content: Text(context.l10n.contacts_invalidContactQr), + ), + ), + ); + } +} diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index edd078b..3b5b956 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -35,6 +35,7 @@ import '../widgets/repeater_login_dialog.dart'; import '../widgets/room_login_dialog.dart'; import '../widgets/sync_progress_overlay.dart'; import '../widgets/add_contact_by_key_dialog.dart'; +import '../widgets/my_contact_qr_dialog.dart'; import '../widgets/unread_badge.dart'; import '../helpers/snack_bar_builder.dart'; import 'channels_screen.dart'; @@ -425,6 +426,16 @@ class _ContactsScreenState extends State ), onTap: () => showAddContactByKeyDialog(context), ), + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.qr_code_2), + const SizedBox(width: 8), + Text(context.l10n.contacts_myContactQr), + ], + ), + onTap: () => showMyContactQrDialog(context), + ), ], icon: const Icon(Icons.more_vert), ), diff --git a/lib/widgets/add_contact_by_key_dialog.dart b/lib/widgets/add_contact_by_key_dialog.dart index 4c5ee6b..044cf74 100644 --- a/lib/widgets/add_contact_by_key_dialog.dart +++ b/lib/widgets/add_contact_by_key_dialog.dart @@ -6,6 +6,8 @@ import '../connector/meshcore_protocol.dart'; 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. /// @@ -73,6 +75,23 @@ 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()), + ); + if (!mounted || scanned == null) return; + // Route the scan through the same handler as a paste. A QR is only the + // link rendered visually, so it must not get its own code path. + _keyController.text = scanned; + _onKeyChanged(scanned); + } + Future _submit() async { final l10n = context.l10n; if (!_keyLooksValid) { @@ -138,6 +157,13 @@ class _AddContactByKeyDialogState extends State<_AddContactByKeyDialog> { errorText: _keyError, errorMaxLines: 2, border: const OutlineInputBorder(), + suffixIcon: _cameraAvailable + ? IconButton( + icon: const Icon(Icons.qr_code_scanner), + tooltip: l10n.contacts_scanContactQr, + onPressed: _submitting ? null : _scan, + ) + : null, ), onChanged: _onKeyChanged, ), diff --git a/lib/widgets/my_contact_qr_dialog.dart b/lib/widgets/my_contact_qr_dialog.dart new file mode 100644 index 0000000..289e8a9 --- /dev/null +++ b/lib/widgets/my_contact_qr_dialog.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../connector/meshcore_connector.dart'; +import '../connector/meshcore_protocol.dart'; +import '../helpers/snack_bar_builder.dart'; +import '../l10n/l10n.dart'; +import '../models/contact.dart'; +import 'qr_code_display.dart'; + +/// Shows this device's own identity as a scannable QR plus a copyable link. +/// (#629) +/// +/// This is the fast, definitive way to hand someone your identity: it carries +/// the public key itself, so it cannot fail on a name mismatch and it does not +/// ask them to wait for an advert. It also costs the mesh no airtime, which +/// asking for a couple of flood adverts does. +/// +/// Rendering works on every platform, which matters on desktop: put this on +/// screen and the other person scans it with their phone. +Future showMyContactQrDialog(BuildContext context) { + final connector = Provider.of(context, listen: false); + final keyHex = connector.selfPublicKeyHex; + + // Without a connection we do not know our own key, so there is nothing + // truthful to render. + if (keyHex.length != pubKeySize * 2) { + showDismissibleSnackBar( + context, + content: Text(context.l10n.contacts_qrNeedsConnection), + ); + return Future.value(); + } + + final uri = Contact.buildShareUri( + publicKeyHex: keyHex, + name: connector.selfName ?? '', + // This device is a companion. + type: advTypeChat, + ); + + return showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(dialogContext.l10n.contacts_myContactQr), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + QrCodeDisplay( + data: uri, + instructions: dialogContext.l10n.contacts_myContactQrInstructions, + ), + const SizedBox(height: 8), + SelectableText( + uri, + style: Theme.of(dialogContext).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: Theme.of(dialogContext).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + actions: [ + TextButton.icon( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: uri)); + if (!dialogContext.mounted) return; + showDismissibleSnackBar( + dialogContext, + content: Text(dialogContext.l10n.contacts_contactLinkCopied), + ); + }, + icon: const Icon(Icons.copy), + label: Text(dialogContext.l10n.common_copy), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(dialogContext.l10n.common_close), + ), + ], + ), + ); +} diff --git a/lib/widgets/qr_code_display.dart b/lib/widgets/qr_code_display.dart index 584eabe..dca94c2 100644 --- a/lib/widgets/qr_code_display.dart +++ b/lib/widgets/qr_code_display.dart @@ -96,9 +96,18 @@ class QrCodeDisplay extends StatelessWidget { ), ], ), - child: embeddedImage != null - ? _buildQrWithEmbeddedImage(fgColor, bgColor) - : _buildSimpleQr(fgColor, bgColor), + // Bound the QR to a fixed square. QrImageView builds through a + // LayoutBuilder, which cannot answer intrinsic dimension queries, + // so an intrinsic-measuring parent (AlertDialog measures its + // content's max intrinsic height) throws without this. A tight + // SizedBox answers the intrinsic itself and never asks the child. + // (#629, first real use of this widget.) + child: SizedBox.square( + dimension: size, + child: embeddedImage != null + ? _buildQrWithEmbeddedImage(fgColor, bgColor) + : _buildSimpleQr(fgColor, bgColor), + ), ), if (instructions != null) ...[ diff --git a/test/widgets/my_contact_qr_dialog_test.dart b/test/widgets/my_contact_qr_dialog_test.dart new file mode 100644 index 0000000..7631a80 --- /dev/null +++ b/test/widgets/my_contact_qr_dialog_test.dart @@ -0,0 +1,114 @@ +// Widget tests for sharing this device's own identity as a QR (#629). +// +// The QR is only the meshcore://contact/add link rendered visually. qr_flutter +// keeps its payload private (`final String? _data`), so these assert on the +// link the dialog renders beside the code, which is the same string, and on +// that link parsing back into the right key, name and type. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:meshcore_open/l10n/app_localizations.dart'; +import 'package:meshcore_open/models/contact.dart'; +import 'package:meshcore_open/widgets/my_contact_qr_dialog.dart'; + +const _key = 'aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899'; + +class _FakeConn extends MeshCoreConnector { + _FakeConn({required this.keyHex, this.nodeName}); + + final String keyHex; + final String? nodeName; + + @override + String get selfPublicKeyHex => keyHex; + + @override + String? get selfName => nodeName; +} + +Future _open(WidgetTester tester, MeshCoreConnector conn) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: conn, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showMyContactQrDialog(context), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('renders a QR plus the link it encodes', (tester) async { + await _open(tester, _FakeConn(keyHex: _key, nodeName: 'Strycher-RK4')); + + final expected = Contact.buildShareUri( + publicKeyHex: _key, + name: 'Strycher-RK4', + type: advTypeChat, + ); + + expect(find.byType(QrImageView), findsOneWidget); + // The link is shown so it can be copied or pasted into a chat, which is + // the path that works when the other person is not in the room. + expect(find.text(expected), findsOneWidget); + }); + + testWidgets('the rendered link parses back to this device', (tester) async { + await _open(tester, _FakeConn(keyHex: _key, nodeName: 'Strycher-RK4')); + + final expected = Contact.buildShareUri( + publicKeyHex: _key, + name: 'Strycher-RK4', + type: advTypeChat, + ); + final parsed = Contact.fromShareUri(expected); + + expect(parsed, isNotNull); + expect(parsed!.publicKeyHex, _key); + expect(parsed.name, 'Strycher-RK4'); + // This device is a companion, not a repeater. + expect(parsed.type, advTypeChat); + }); + + testWidgets('a node with no name still produces an addable link', ( + tester, + ) async { + await _open(tester, _FakeConn(keyHex: _key)); + + final expected = Contact.buildShareUri( + publicKeyHex: _key, + name: '', + type: advTypeChat, + ); + expect(find.text(expected), findsOneWidget); + // The key is the identity, so a nameless link is still usable. + expect(Contact.fromShareUri(expected)?.publicKeyHex, _key); + }); + + testWidgets('with no connection nothing is rendered, since we lack our key', ( + tester, + ) async { + // An empty key would encode a QR that cannot be added, which is worse + // than refusing to show one. + await _open(tester, _FakeConn(keyHex: '')); + + expect(find.byType(QrImageView), findsNothing); + expect(find.byType(AlertDialog), findsNothing); + }); +} diff --git a/untranslated.json b/untranslated.json index da87696..65ba63e 100644 --- a/untranslated.json +++ b/untranslated.json @@ -104,6 +104,13 @@ "contacts_addByKeyUnverifiedNote", "contacts_addByKeyAdded", "contacts_addByKeyFailed", + "contacts_myContactQr", + "contacts_myContactQrInstructions", + "contacts_contactLinkCopied", + "contacts_qrNeedsConnection", + "contacts_scanContactQr", + "contacts_scanContactQrInstructions", + "contacts_invalidContactQr", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -234,6 +241,13 @@ "contacts_addByKeyUnverifiedNote", "contacts_addByKeyAdded", "contacts_addByKeyFailed", + "contacts_myContactQr", + "contacts_myContactQrInstructions", + "contacts_contactLinkCopied", + "contacts_qrNeedsConnection", + "contacts_scanContactQr", + "contacts_scanContactQrInstructions", + "contacts_invalidContactQr", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -364,6 +378,13 @@ "contacts_addByKeyUnverifiedNote", "contacts_addByKeyAdded", "contacts_addByKeyFailed", + "contacts_myContactQr", + "contacts_myContactQrInstructions", + "contacts_contactLinkCopied", + "contacts_qrNeedsConnection", + "contacts_scanContactQr", + "contacts_scanContactQrInstructions", + "contacts_invalidContactQr", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -494,6 +515,13 @@ "contacts_addByKeyUnverifiedNote", "contacts_addByKeyAdded", "contacts_addByKeyFailed", + "contacts_myContactQr", + "contacts_myContactQrInstructions", + "contacts_contactLinkCopied", + "contacts_qrNeedsConnection", + "contacts_scanContactQr", + "contacts_scanContactQrInstructions", + "contacts_invalidContactQr", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -624,6 +652,13 @@ "contacts_addByKeyUnverifiedNote", "contacts_addByKeyAdded", "contacts_addByKeyFailed", + "contacts_myContactQr", + "contacts_myContactQrInstructions", + "contacts_contactLinkCopied", + "contacts_qrNeedsConnection", + "contacts_scanContactQr", + "contacts_scanContactQrInstructions", + "contacts_invalidContactQr", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -754,6 +789,13 @@ "contacts_addByKeyUnverifiedNote", "contacts_addByKeyAdded", "contacts_addByKeyFailed", + "contacts_myContactQr", + "contacts_myContactQrInstructions", + "contacts_contactLinkCopied", + "contacts_qrNeedsConnection", + "contacts_scanContactQr", + "contacts_scanContactQrInstructions", + "contacts_invalidContactQr", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -884,6 +926,13 @@ "contacts_addByKeyUnverifiedNote", "contacts_addByKeyAdded", "contacts_addByKeyFailed", + "contacts_myContactQr", + "contacts_myContactQrInstructions", + "contacts_contactLinkCopied", + "contacts_qrNeedsConnection", + "contacts_scanContactQr", + "contacts_scanContactQrInstructions", + "contacts_invalidContactQr", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1014,6 +1063,13 @@ "contacts_addByKeyUnverifiedNote", "contacts_addByKeyAdded", "contacts_addByKeyFailed", + "contacts_myContactQr", + "contacts_myContactQrInstructions", + "contacts_contactLinkCopied", + "contacts_qrNeedsConnection", + "contacts_scanContactQr", + "contacts_scanContactQrInstructions", + "contacts_invalidContactQr", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1144,6 +1200,13 @@ "contacts_addByKeyUnverifiedNote", "contacts_addByKeyAdded", "contacts_addByKeyFailed", + "contacts_myContactQr", + "contacts_myContactQrInstructions", + "contacts_contactLinkCopied", + "contacts_qrNeedsConnection", + "contacts_scanContactQr", + "contacts_scanContactQrInstructions", + "contacts_invalidContactQr", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1274,6 +1337,13 @@ "contacts_addByKeyUnverifiedNote", "contacts_addByKeyAdded", "contacts_addByKeyFailed", + "contacts_myContactQr", + "contacts_myContactQrInstructions", + "contacts_contactLinkCopied", + "contacts_qrNeedsConnection", + "contacts_scanContactQr", + "contacts_scanContactQrInstructions", + "contacts_invalidContactQr", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1404,6 +1474,13 @@ "contacts_addByKeyUnverifiedNote", "contacts_addByKeyAdded", "contacts_addByKeyFailed", + "contacts_myContactQr", + "contacts_myContactQrInstructions", + "contacts_contactLinkCopied", + "contacts_qrNeedsConnection", + "contacts_scanContactQr", + "contacts_scanContactQrInstructions", + "contacts_invalidContactQr", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1534,6 +1611,13 @@ "contacts_addByKeyUnverifiedNote", "contacts_addByKeyAdded", "contacts_addByKeyFailed", + "contacts_myContactQr", + "contacts_myContactQrInstructions", + "contacts_contactLinkCopied", + "contacts_qrNeedsConnection", + "contacts_scanContactQr", + "contacts_scanContactQrInstructions", + "contacts_invalidContactQr", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1664,6 +1748,13 @@ "contacts_addByKeyUnverifiedNote", "contacts_addByKeyAdded", "contacts_addByKeyFailed", + "contacts_myContactQr", + "contacts_myContactQrInstructions", + "contacts_contactLinkCopied", + "contacts_qrNeedsConnection", + "contacts_scanContactQr", + "contacts_scanContactQrInstructions", + "contacts_invalidContactQr", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1794,6 +1885,13 @@ "contacts_addByKeyUnverifiedNote", "contacts_addByKeyAdded", "contacts_addByKeyFailed", + "contacts_myContactQr", + "contacts_myContactQrInstructions", + "contacts_contactLinkCopied", + "contacts_qrNeedsConnection", + "contacts_scanContactQr", + "contacts_scanContactQrInstructions", + "contacts_invalidContactQr", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1924,6 +2022,13 @@ "contacts_addByKeyUnverifiedNote", "contacts_addByKeyAdded", "contacts_addByKeyFailed", + "contacts_myContactQr", + "contacts_myContactQrInstructions", + "contacts_contactLinkCopied", + "contacts_qrNeedsConnection", + "contacts_scanContactQr", + "contacts_scanContactQrInstructions", + "contacts_invalidContactQr", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -2054,6 +2159,13 @@ "contacts_addByKeyUnverifiedNote", "contacts_addByKeyAdded", "contacts_addByKeyFailed", + "contacts_myContactQr", + "contacts_myContactQrInstructions", + "contacts_contactLinkCopied", + "contacts_qrNeedsConnection", + "contacts_scanContactQr", + "contacts_scanContactQrInstructions", + "contacts_invalidContactQr", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -2184,6 +2296,13 @@ "contacts_addByKeyUnverifiedNote", "contacts_addByKeyAdded", "contacts_addByKeyFailed", + "contacts_myContactQr", + "contacts_myContactQrInstructions", + "contacts_contactLinkCopied", + "contacts_qrNeedsConnection", + "contacts_scanContactQr", + "contacts_scanContactQrInstructions", + "contacts_invalidContactQr", "snrIndicator_noNeighbors", "block_block", "block_unblock",