diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 196bbe75..d766e4eb 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -94,6 +94,8 @@ "copiedToClipboardThankYou": "In Zwischenablage kopiert. Danke!", "shareProfileTitle": "Teilen und verbinden", "scanToConnect": "Zum Verbinden scannen", + "holdToShareQrCode": "Halten, um QR-Code zu teilen", + "shareQrCodeError": "QR-Code konnte nicht geteilt werden. Bitte versuche es erneut.", "signOutTitle": "Abmelden", "signOutConfirmation": "Möchtest du dich wirklich abmelden?", "signOutWarning": "Wenn du dich bei White Noise abmeldest, werden deine Chats von diesem Gerät gelöscht und können auf einem anderen Gerät nicht wiederhergestellt werden.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 820ac375..0b9f5f95 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -399,6 +399,14 @@ "@scanToConnect": { "description": "Text below QR code" }, + "holdToShareQrCode": "Hold to share QR code", + "@holdToShareQrCode": { + "description": "Text below QR code when user can long-press to share" + }, + "shareQrCodeError": "Unable to share QR code. Please try again.", + "@shareQrCodeError": { + "description": "Error shown when QR code sharing fails" + }, "signOutTitle": "Sign out", "@signOutTitle": { "description": "Sign out screen title" diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index d193a5bf..e40f1025 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -94,6 +94,8 @@ "copiedToClipboardThankYou": "Copiado al portapapeles. ¡Gracias!", "shareProfileTitle": "Compartir y conectar", "scanToConnect": "Escanear para conectar", + "holdToShareQrCode": "Mantén para compartir el código QR", + "shareQrCodeError": "No se pudo compartir el código QR. Inténtalo de nuevo.", "signOutTitle": "Cerrar sesión", "signOutConfirmation": "¿Estás seguro de que quieres cerrar sesión?", "signOutWarning": "Cuando cierres sesión en White Noise, tus chats se eliminarán de este dispositivo y no podrán restaurarse en otro dispositivo.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 7542c25e..c88e2904 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -94,6 +94,8 @@ "copiedToClipboardThankYou": "Copié dans le presse-papiers. Merci !", "shareProfileTitle": "Partager et connecter", "scanToConnect": "Scanner pour se connecter", + "holdToShareQrCode": "Maintenez pour partager le QR code", + "shareQrCodeError": "Impossible de partager le QR code. Veuillez réessayer.", "signOutTitle": "Déconnexion", "signOutConfirmation": "Êtes-vous sûr de vouloir vous déconnecter ?", "signOutWarning": "Lorsque vous vous déconnectez de White Noise, vos discussions seront supprimées de cet appareil et ne pourront pas être restaurées sur un autre appareil.", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 81919088..b18eb065 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -94,6 +94,8 @@ "copiedToClipboardThankYou": "Copiato negli appunti. Grazie!", "shareProfileTitle": "Condividi e connetti", "scanToConnect": "Scansiona per connetterti", + "holdToShareQrCode": "Tieni premuto per condividere il codice QR", + "shareQrCodeError": "Impossibile condividere il codice QR. Riprova.", "signOutTitle": "Esci", "signOutConfirmation": "Sei sicuro di voler uscire?", "signOutWarning": "Quando esci da White Noise, le tue chat verranno eliminate da questo dispositivo e non potranno essere ripristinate su un altro dispositivo.", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index c31e52ba..93322e98 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -94,6 +94,8 @@ "copiedToClipboardThankYou": "Copiado para a área de transferência. Obrigado!", "shareProfileTitle": "Compartilhar e conectar", "scanToConnect": "Escaneie para conectar", + "holdToShareQrCode": "Segure para compartilhar o QR code", + "shareQrCodeError": "Não foi possível compartilhar o QR code. Tente novamente.", "signOutTitle": "Sair", "signOutConfirmation": "Tem certeza de que deseja sair?", "signOutWarning": "Quando você sair do White Noise, suas conversas serão excluídas deste dispositivo e não poderão ser restauradas em outro dispositivo.", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index b95915c5..c76d1f19 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -94,6 +94,8 @@ "copiedToClipboardThankYou": "Скопировано в буфер обмена. Спасибо!", "shareProfileTitle": "Поделиться и подключиться", "scanToConnect": "Сканируйте для подключения", + "holdToShareQrCode": "Удерживайте для отправки QR-кода", + "shareQrCodeError": "Не удалось поделиться QR-кодом. Попробуйте ещё раз.", "signOutTitle": "Выход", "signOutConfirmation": "Вы уверены, что хотите выйти?", "signOutWarning": "Когда вы выходите из White Noise, ваши чаты будут удалены с этого устройства и не могут быть восстановлены на другом устройстве.", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 16042fda..baacef79 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -94,6 +94,8 @@ "copiedToClipboardThankYou": "Panoya kopyalandı. Teşekkürler!", "shareProfileTitle": "Paylaş ve bağlan", "scanToConnect": "Bağlanmak için tarayın", + "holdToShareQrCode": "QR kodu paylaşmak için basılı tutun", + "shareQrCodeError": "QR kodu paylaşılamadı. Lütfen tekrar deneyin.", "signOutTitle": "Çıkış yap", "signOutConfirmation": "Çıkış yapmak istediğinizden emin misiniz?", "signOutWarning": "White Noise'dan çıkış yaptığınızda, sohbetleriniz bu cihazdan silinecek ve başka bir cihazda geri yüklenemeyecektir.", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 883c9322..645ce77a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -94,6 +94,8 @@ "copiedToClipboardThankYou": "已复制到剪贴板。谢谢您的支持!", "shareProfileTitle": "分享与连接", "scanToConnect": "扫码连接", + "holdToShareQrCode": "长按分享二维码", + "shareQrCodeError": "无法分享二维码。请重试。", "signOutTitle": "退出登录", "signOutConfirmation": "确定要退出登录吗?", "signOutWarning": "当您退出 White Noise 时,您的聊天记录将从此设备中删除,且无法在其他设备上恢复。", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 5b95ab22..11191525 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -94,6 +94,8 @@ "copiedToClipboardThankYou": "已複製到剪貼簿。謝謝您的支持!", "shareProfileTitle": "分享與連結", "scanToConnect": "掃描即可連結", + "holdToShareQrCode": "長按以分享 QR 碼", + "shareQrCodeError": "無法分享 QR 碼。請再試一次。", "signOutTitle": "登出", "signOutConfirmation": "確定要登出嗎?", "signOutWarning": "登出 White Noise 後,您的聊天記錄會從這台裝置刪除,且無法在其他裝置上還原。", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index aff9af53..718e3b40 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -675,6 +675,18 @@ abstract class AppLocalizations { /// **'Scan to connect'** String get scanToConnect; + /// Text below QR code when user can long-press to share + /// + /// In en, this message translates to: + /// **'Hold to share QR code'** + String get holdToShareQrCode; + + /// Error shown when QR code sharing fails + /// + /// In en, this message translates to: + /// **'Unable to share QR code. Please try again.'** + String get shareQrCodeError; + /// Sign out screen title /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 4e1596e3..615dd3fe 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -326,6 +326,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get scanToConnect => 'Zum Verbinden scannen'; + @override + String get holdToShareQrCode => 'Halten, um QR-Code zu teilen'; + + @override + String get shareQrCodeError => 'QR-Code konnte nicht geteilt werden. Bitte versuche es erneut.'; + @override String get signOutTitle => 'Abmelden'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index ebd0211a..5b6a5afd 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -323,6 +323,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get scanToConnect => 'Scan to connect'; + @override + String get holdToShareQrCode => 'Hold to share QR code'; + + @override + String get shareQrCodeError => 'Unable to share QR code. Please try again.'; + @override String get signOutTitle => 'Sign out'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index ecde6541..de42deeb 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -324,6 +324,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get scanToConnect => 'Escanear para conectar'; + @override + String get holdToShareQrCode => 'Mantén para compartir el código QR'; + + @override + String get shareQrCodeError => 'No se pudo compartir el código QR. Inténtalo de nuevo.'; + @override String get signOutTitle => 'Cerrar sesión'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index b33ab97f..66d4a010 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -324,6 +324,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get scanToConnect => 'Scanner pour se connecter'; + @override + String get holdToShareQrCode => 'Maintenez pour partager le QR code'; + + @override + String get shareQrCodeError => 'Impossible de partager le QR code. Veuillez réessayer.'; + @override String get signOutTitle => 'Déconnexion'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index ba97c9e5..802b23f1 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -324,6 +324,12 @@ class AppLocalizationsIt extends AppLocalizations { @override String get scanToConnect => 'Scansiona per connetterti'; + @override + String get holdToShareQrCode => 'Tieni premuto per condividere il codice QR'; + + @override + String get shareQrCodeError => 'Impossibile condividere il codice QR. Riprova.'; + @override String get signOutTitle => 'Esci'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 5beab6a9..562eb226 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -324,6 +324,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get scanToConnect => 'Escaneie para conectar'; + @override + String get holdToShareQrCode => 'Segure para compartilhar o QR code'; + + @override + String get shareQrCodeError => 'Não foi possível compartilhar o QR code. Tente novamente.'; + @override String get signOutTitle => 'Sair'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index ed4d7e4f..bec8b069 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -324,6 +324,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get scanToConnect => 'Сканируйте для подключения'; + @override + String get holdToShareQrCode => 'Удерживайте для отправки QR-кода'; + + @override + String get shareQrCodeError => 'Не удалось поделиться QR-кодом. Попробуйте ещё раз.'; + @override String get signOutTitle => 'Выход'; diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index 1ff57db8..fc05b3f2 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -323,6 +323,12 @@ class AppLocalizationsTr extends AppLocalizations { @override String get scanToConnect => 'Bağlanmak için tarayın'; + @override + String get holdToShareQrCode => 'QR kodu paylaşmak için basılı tutun'; + + @override + String get shareQrCodeError => 'QR kodu paylaşılamadı. Lütfen tekrar deneyin.'; + @override String get signOutTitle => 'Çıkış yap'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 3b06f645..bff584c0 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -311,6 +311,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get scanToConnect => '扫码连接'; + @override + String get holdToShareQrCode => '长按分享二维码'; + + @override + String get shareQrCodeError => '无法分享二维码。请重试。'; + @override String get signOutTitle => '退出登录'; @@ -1770,6 +1776,12 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get scanToConnect => '掃描即可連結'; + @override + String get holdToShareQrCode => '長按以分享 QR 碼'; + + @override + String get shareQrCodeError => '無法分享 QR 碼。請再試一次。'; + @override String get signOutTitle => '登出'; diff --git a/lib/screens/share_profile_screen.dart b/lib/screens/share_profile_screen.dart index 2c181403..1d0c31af 100644 --- a/lib/screens/share_profile_screen.dart +++ b/lib/screens/share_profile_screen.dart @@ -1,8 +1,13 @@ +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:qr_flutter/qr_flutter.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:whitenoise/hooks/use_system_notice.dart'; import 'package:whitenoise/hooks/use_user_metadata.dart'; import 'package:whitenoise/l10n/l10n.dart'; @@ -41,6 +46,73 @@ class ShareProfileScreen extends HookConsumerWidget { final metadata = metadataSnapshot.data; final displayName = presentName(metadata); + final qrRepaintKey = useMemoized(GlobalKey.new); + final isHolding = useState(false); + final dotCount = useState(0); + final qrColorAnim = useAnimationController( + duration: const Duration(milliseconds: 150), + ); + final qrColor = ColorTween( + begin: colors.backgroundContentPrimary, + end: Colors.grey, + ).animate(CurvedAnimation(parent: qrColorAnim, curve: Curves.easeIn)); + + final isMounted = useRef(true); + final isCapturing = useRef(false); + useEffect( + () => + () => isMounted.value = false, + const [], + ); + + Future captureAndShareQr() async { + if (isCapturing.value) return; + isCapturing.value = true; + final boundary = qrRepaintKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; + if (boundary == null) { + isCapturing.value = false; + return; + } + final errorMessage = context.l10n.shareQrCodeError; + // Capture synchronously before any animation frame can render, so the + // image is always the full-color QR regardless of animation state. + dotCount.value = 1; + qrColorAnim.forward(); + final image = boundary.toImageSync(pixelRatio: 3); + try { + final captureFuture = image.toByteData(format: ui.ImageByteFormat.png); + // onLongPressStart fires at 500ms total; dots step every 500ms after that + await Future.delayed(const Duration(milliseconds: 500)); + if (!isMounted.value || !isHolding.value) return; + dotCount.value = 2; + await Future.delayed(const Duration(milliseconds: 500)); + if (!isMounted.value || !isHolding.value) return; + dotCount.value = 3; + final byteData = await captureFuture; + if (!isMounted.value || byteData == null || !isHolding.value) return; + await SharePlus.instance.share( + ShareParams( + files: [ + XFile.fromData( + byteData.buffer.asUint8List(), + name: 'qr_code.png', + mimeType: 'image/png', + ), + ], + ), + ); + } catch (e) { + if (isMounted.value) { + dotCount.value = 0; + qrColorAnim.reverse(); + showErrorNotice(errorMessage); + } + } finally { + image.dispose(); + isCapturing.value = false; + } + } + return Scaffold( backgroundColor: colors.backgroundPrimary, body: SafeArea( @@ -92,24 +164,46 @@ class ShareProfileScreen extends HookConsumerWidget { ), if (profileDeepLink != null) ...[ Gap(36.h), - ConstrainedBox( - constraints: BoxConstraints(maxWidth: 256.w), - child: AspectRatio( - aspectRatio: 1, - child: ClipRRect( - borderRadius: BorderRadius.circular(12.r), - child: QrImageView( - key: ValueKey(profileDeepLink), - data: profileDeepLink, - padding: EdgeInsets.zero, - backgroundColor: colors.backgroundSecondary, - eyeStyle: QrEyeStyle( - eyeShape: QrEyeShape.square, - color: colors.backgroundContentPrimary, - ), - dataModuleStyle: QrDataModuleStyle( - dataModuleShape: QrDataModuleShape.square, - color: colors.backgroundContentPrimary, + GestureDetector( + onLongPressStart: (_) { + isHolding.value = true; + captureAndShareQr(); + }, + onLongPressEnd: (_) { + isHolding.value = false; + dotCount.value = 0; + qrColorAnim.reverse(); + }, + onLongPressCancel: () { + isHolding.value = false; + dotCount.value = 0; + qrColorAnim.reverse(); + }, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 256.w), + child: AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(12.r), + child: RepaintBoundary( + key: qrRepaintKey, + child: AnimatedBuilder( + animation: qrColor, + builder: (context, _) => QrImageView( + key: ValueKey(profileDeepLink), + data: profileDeepLink, + padding: EdgeInsets.all(12.r), + backgroundColor: colors.backgroundSecondary, + eyeStyle: QrEyeStyle( + eyeShape: QrEyeShape.square, + color: qrColor.value ?? colors.backgroundContentPrimary, + ), + dataModuleStyle: QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: qrColor.value ?? colors.backgroundContentPrimary, + ), + ), + ), ), ), ), @@ -120,7 +214,9 @@ class ShareProfileScreen extends HookConsumerWidget { Gap(32.h), Gap(12.h), Text( - context.l10n.scanToConnect, + isHolding.value + ? '${context.l10n.holdToShareQrCode}${'.' * dotCount.value}' + : context.l10n.scanToConnect, textAlign: TextAlign.center, style: typography.medium14.copyWith( color: colors.backgroundContentSecondary, diff --git a/pubspec.lock b/pubspec.lock index 78f6cf0c..db8c4337 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1132,7 +1132,7 @@ packages: source: hosted version: "12.0.1" share_plus_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: share_plus_platform_interface sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" diff --git a/pubspec.yaml b/pubspec.yaml index 20f808c4..7f9a0495 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -86,6 +86,7 @@ dev_dependencies: plugin_platform_interface: ^2.1.8 flutter_secure_storage_platform_interface: ^2.0.1 url_launcher_platform_interface: ^2.3.2 + share_plus_platform_interface: ^6.1.0 # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is diff --git a/test/mocks/mock_share_plus.dart b/test/mocks/mock_share_plus.dart index 8af0c1f9..eeb8c721 100644 --- a/test/mocks/mock_share_plus.dart +++ b/test/mocks/mock_share_plus.dart @@ -1,35 +1,45 @@ import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; +import 'package:share_plus_platform_interface/share_plus_platform_interface.dart'; -const _sharePlusChannel = MethodChannel('dev.fluttercommunity.plus/share'); +class _MockSharePlatform extends SharePlatform { + bool shouldFail = false; -List _shareCalls = []; + @override + Future share(ShareParams params) async { + if (shouldFail) { + throw PlatformException(code: 'ERROR', message: 'Share failed'); + } + _shareCalls.add(params); + return ShareResult.unavailable; + } +} -List mockSharePlus() { +// Single shared instance — SharePlus.instance captures SharePlatform.instance +// once at static init time, so swapping the platform later has no effect. +// Mutating this object's shouldFail flag changes behavior via the already-captured ref. +final _mockSharePlatform = _MockSharePlatform(); +List _shareCalls = []; +SharePlatform? _originalInstance; + +List mockSharePlus() { _shareCalls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( - _sharePlusChannel, - (call) async { - _shareCalls.add(call); - return null; - }, - ); + _mockSharePlatform.shouldFail = false; + _originalInstance ??= SharePlatform.instance; + SharePlatform.instance = _mockSharePlatform; return _shareCalls; } void mockSharePlusFailing() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( - _sharePlusChannel, - (call) async { - throw PlatformException(code: 'ERROR', message: 'Share failed'); - }, - ); + _mockSharePlatform.shouldFail = true; + _originalInstance ??= SharePlatform.instance; + SharePlatform.instance = _mockSharePlatform; } void clearSharePlusMock() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( - _sharePlusChannel, - null, - ); + _mockSharePlatform.shouldFail = false; + if (_originalInstance != null) { + SharePlatform.instance = _originalInstance!; + _originalInstance = null; + } _shareCalls = []; } diff --git a/test/screens/share_profile_screen_test.dart b/test/screens/share_profile_screen_test.dart index 509404b9..8e8f08f9 100644 --- a/test/screens/share_profile_screen_test.dart +++ b/test/screens/share_profile_screen_test.dart @@ -16,6 +16,7 @@ import 'package:whitenoise/widgets/wn_copy_card.dart'; import '../mocks/mock_clipboard.dart' show clearClipboardMock, mockClipboard, mockClipboardFailing; import '../mocks/mock_secure_storage.dart'; +import '../mocks/mock_share_plus.dart'; import '../mocks/mock_wn_api.dart'; import '../test_helpers.dart'; @@ -27,7 +28,6 @@ class _MockApi extends MockWnApi { }) async => const FlutterMetadata( name: 'Test User', displayName: 'Test Display Name', - picture: 'https://example.com/picture.jpg', custom: {}, ); } @@ -238,5 +238,120 @@ void main() { expect(find.text("Scan a contact's QR code."), findsOneWidget); }); + + testWidgets('shows holding label when long pressing QR code', (tester) async { + mockSharePlus(); + addTearDown(clearSharePlusMock); + await pumpShareProfileScreen(tester); + + final gesture = await tester.startGesture(tester.getCenter(find.byType(QrImageView))); + // Long press fires at 500ms; pump 600ms to clear threshold + await tester.pump(const Duration(milliseconds: 600)); + expect(find.textContaining('Hold to share QR code'), findsOneWidget); + + await gesture.up(); + // Drain the first 500ms delay (started at t=500ms, still pending at t=600ms) + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + }); + + testWidgets('resets label to scan to connect on long press end', (tester) async { + mockSharePlus(); + addTearDown(clearSharePlusMock); + await pumpShareProfileScreen(tester); + + final gesture = await tester.startGesture(tester.getCenter(find.byType(QrImageView))); + await tester.pump(const Duration(milliseconds: 600)); + await gesture.up(); + // Drain first delay before asserting + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('Scan to connect'), findsOneWidget); + await tester.pumpAndSettle(); + }); + + testWidgets('resets state on long press cancel', (tester) async { + await pumpShareProfileScreen(tester); + + final gesture = await tester.startGesture(tester.getCenter(find.byType(QrImageView))); + await gesture.cancel(); + await tester.pumpAndSettle(); + + expect(find.text('Scan to connect'), findsOneWidget); + }); + + testWidgets('progresses dots during hold', (tester) async { + mockSharePlus(); + addTearDown(clearSharePlusMock); + await pumpShareProfileScreen(tester); + + final gesture = await tester.startGesture(tester.getCenter(find.byType(QrImageView))); + await tester.pump(const Duration(milliseconds: 600)); + expect(find.text('Hold to share QR code.'), findsOneWidget); + + // First delay (t=500ms start) completes at t=1000ms; 400ms more needed + await tester.pump(const Duration(milliseconds: 400)); + expect(find.text('Hold to share QR code..'), findsOneWidget); + + await gesture.up(); + // Drain second delay (started at t=1000ms) to prevent pending-timer failure + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + }); + + testWidgets('shares QR image after full hold', (tester) async { + final calls = mockSharePlus(); + addTearDown(clearSharePlusMock); + await pumpShareProfileScreen(tester); + + // Start the gesture OUTSIDE runAsync so the long-press timer and + // captureAndShareQr's Future.delayed timers stay on the fake clock. + final gesture = await tester.startGesture(tester.getCenter(find.byType(QrImageView))); + await tester.pump( + const Duration(milliseconds: 600), + ); // onLongPressStart fires at fake t=500ms + + await tester.runAsync(() async { + // Advance fake timers for captureAndShareQr's two 500ms delays. + await tester.pump(const Duration(milliseconds: 400)); // first delay (fake t=1000ms) + await tester.pump(const Duration(milliseconds: 500)); // second delay (fake t=1500ms) + // Poll until share() is called — toByteData(png) takes variable real time on CI. + for (var i = 0; i < 30; i++) { + await Future.delayed(const Duration(milliseconds: 100)); + await tester.pump(); + if (calls.isNotEmpty) break; + } + + expect(calls, isNotEmpty); + + await gesture.up(); + await tester.pumpAndSettle(); + }); + }); + + testWidgets('shows error notice when share fails', (tester) async { + mockSharePlusFailing(); + addTearDown(clearSharePlusMock); + await pumpShareProfileScreen(tester); + + final gesture = await tester.startGesture(tester.getCenter(find.byType(QrImageView))); + await tester.pump(const Duration(milliseconds: 600)); + + await tester.runAsync(() async { + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 500)); // second delay; dotCount=3 + // Poll until error notice appears — toByteData(png) takes variable real time on CI. + for (var i = 0; i < 30; i++) { + await Future.delayed(const Duration(milliseconds: 100)); + await tester.pump(); + if (find.text('Unable to share QR code. Please try again.').evaluate().isNotEmpty) break; + } + + expect(find.text('Unable to share QR code. Please try again.'), findsOneWidget); + + await gesture.up(); + await tester.pumpAndSettle(); + }); + }); }); } diff --git a/test/screens/user_profile_screen_test.dart b/test/screens/user_profile_screen_test.dart index a76fbc3d..e05d5724 100644 --- a/test/screens/user_profile_screen_test.dart +++ b/test/screens/user_profile_screen_test.dart @@ -552,13 +552,8 @@ void main() { await tester.pumpAndSettle(); expect(shareCalls.length, 1); - expect(shareCalls[0].method, 'share'); - - final args = shareCalls[0].arguments as Map; - expect( - args['text'], - contains('whitenoise.chat'), - ); + expect(shareCalls[0].text, contains('whitenoise.chat')); + expect(shareCalls[0].files, isNull, reason: 'invite share is text-only'); }); group('invite callout description', () {