From bdd02cf0e02ff78f6b7f7bd9c29c5fac9eead78d Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Wed, 20 May 2026 21:25:21 +0200 Subject: [PATCH 01/10] feat: long-press QR to share image --- lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 4 + lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_it.arb | 1 + lib/l10n/app_pt.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_tr.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/l10n/app_zh_Hant.arb | 1 + lib/l10n/generated/app_localizations.dart | 6 ++ lib/l10n/generated/app_localizations_de.dart | 3 + lib/l10n/generated/app_localizations_en.dart | 3 + lib/l10n/generated/app_localizations_es.dart | 3 + lib/l10n/generated/app_localizations_fr.dart | 3 + lib/l10n/generated/app_localizations_it.dart | 3 + lib/l10n/generated/app_localizations_pt.dart | 3 + lib/l10n/generated/app_localizations_ru.dart | 3 + lib/l10n/generated/app_localizations_tr.dart | 3 + lib/l10n/generated/app_localizations_zh.dart | 6 ++ lib/screens/share_profile_screen.dart | 102 +++++++++++++++---- 21 files changed, 132 insertions(+), 19 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 196bbe75..8f37eff5 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -94,6 +94,7 @@ "copiedToClipboardThankYou": "In Zwischenablage kopiert. Danke!", "shareProfileTitle": "Teilen und verbinden", "scanToConnect": "Zum Verbinden scannen", + "holdToShareQrCode": "Halten, um QR-Code zu teilen", "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..414243ee 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -399,6 +399,10 @@ "@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" + }, "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..1dbb8479 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -94,6 +94,7 @@ "copiedToClipboardThankYou": "Copiado al portapapeles. ¡Gracias!", "shareProfileTitle": "Compartir y conectar", "scanToConnect": "Escanear para conectar", + "holdToShareQrCode": "Mantén para compartir el código QR", "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..3244f9f5 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -94,6 +94,7 @@ "copiedToClipboardThankYou": "Copié dans le presse-papiers. Merci !", "shareProfileTitle": "Partager et connecter", "scanToConnect": "Scanner pour se connecter", + "holdToShareQrCode": "Maintenez pour partager le QR code", "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..7e7da89c 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -94,6 +94,7 @@ "copiedToClipboardThankYou": "Copiato negli appunti. Grazie!", "shareProfileTitle": "Condividi e connetti", "scanToConnect": "Scansiona per connetterti", + "holdToShareQrCode": "Tieni premuto per condividere il codice QR", "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..96864bc5 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -94,6 +94,7 @@ "copiedToClipboardThankYou": "Copiado para a área de transferência. Obrigado!", "shareProfileTitle": "Compartilhar e conectar", "scanToConnect": "Escaneie para conectar", + "holdToShareQrCode": "Segure para compartilhar o QR code", "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..f58a4c78 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -94,6 +94,7 @@ "copiedToClipboardThankYou": "Скопировано в буфер обмена. Спасибо!", "shareProfileTitle": "Поделиться и подключиться", "scanToConnect": "Сканируйте для подключения", + "holdToShareQrCode": "Удерживайте для отправки QR-кода", "signOutTitle": "Выход", "signOutConfirmation": "Вы уверены, что хотите выйти?", "signOutWarning": "Когда вы выходите из White Noise, ваши чаты будут удалены с этого устройства и не могут быть восстановлены на другом устройстве.", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 16042fda..f80a5303 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -94,6 +94,7 @@ "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", "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..a420d738 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -94,6 +94,7 @@ "copiedToClipboardThankYou": "已复制到剪贴板。谢谢您的支持!", "shareProfileTitle": "分享与连接", "scanToConnect": "扫码连接", + "holdToShareQrCode": "长按分享二维码", "signOutTitle": "退出登录", "signOutConfirmation": "确定要退出登录吗?", "signOutWarning": "当您退出 White Noise 时,您的聊天记录将从此设备中删除,且无法在其他设备上恢复。", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 5b95ab22..5920d1ae 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -94,6 +94,7 @@ "copiedToClipboardThankYou": "已複製到剪貼簿。謝謝您的支持!", "shareProfileTitle": "分享與連結", "scanToConnect": "掃描即可連結", + "holdToShareQrCode": "長按以分享QR碼", "signOutTitle": "登出", "signOutConfirmation": "確定要登出嗎?", "signOutWarning": "登出 White Noise 後,您的聊天記錄會從這台裝置刪除,且無法在其他裝置上還原。", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index aff9af53..71361636 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -675,6 +675,12 @@ 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; + /// 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..f0abf28b 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -326,6 +326,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get scanToConnect => 'Zum Verbinden scannen'; + @override + String get holdToShareQrCode => 'Halten, um QR-Code zu teilen'; + @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..1d321b26 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -323,6 +323,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get scanToConnect => 'Scan to connect'; + @override + String get holdToShareQrCode => 'Hold to share QR code'; + @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..3f6ff79c 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -324,6 +324,9 @@ 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 signOutTitle => 'Cerrar sesión'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index b33ab97f..00932b97 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -324,6 +324,9 @@ 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 signOutTitle => 'Déconnexion'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index ba97c9e5..3bd995bb 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -324,6 +324,9 @@ 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 signOutTitle => 'Esci'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 5beab6a9..7a902e0f 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -324,6 +324,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get scanToConnect => 'Escaneie para conectar'; + @override + String get holdToShareQrCode => 'Segure para compartilhar o QR code'; + @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..b9205607 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -324,6 +324,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get scanToConnect => 'Сканируйте для подключения'; + @override + String get holdToShareQrCode => 'Удерживайте для отправки 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..85aef230 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -323,6 +323,9 @@ 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 signOutTitle => 'Çıkış yap'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 3b06f645..c7c7ac87 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -311,6 +311,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get scanToConnect => '扫码连接'; + @override + String get holdToShareQrCode => '长按分享二维码'; + @override String get signOutTitle => '退出登录'; @@ -1770,6 +1773,9 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get scanToConnect => '掃描即可連結'; + @override + String get holdToShareQrCode => '長按以分享QR碼'; + @override String get signOutTitle => '登出'; diff --git a/lib/screens/share_profile_screen.dart b/lib/screens/share_profile_screen.dart index 2c181403..743bc284 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,41 @@ 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)); + + Future captureAndShareQr() async { + final boundary = qrRepaintKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; + if (boundary == null) return; + // Start dots and animation immediately; capture the last painted frame + // in parallel — it's still full-color since no new frame has rendered yet. + dotCount.value = 1; + qrColorAnim.forward(); + final captureFuture = boundary + .toImage(pixelRatio: 3) + .then((img) => img.toByteData(format: ui.ImageByteFormat.png)); + // onLongPressStart fires at 500ms total; dots step every 500ms after that + await Future.delayed(const Duration(milliseconds: 500)); + if (!isHolding.value) return; + dotCount.value = 2; + await Future.delayed(const Duration(milliseconds: 500)); + if (!isHolding.value) return; + dotCount.value = 3; + final byteData = await captureFuture; + if (byteData == null || !isHolding.value) return; + await SharePlus.instance.share( + ShareParams(files: [XFile.fromData(byteData.buffer.asUint8List(), mimeType: 'image/png')]), + ); + } + return Scaffold( backgroundColor: colors.backgroundPrimary, body: SafeArea( @@ -92,24 +132,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: RepaintBoundary( + key: qrRepaintKey, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 256.w), + child: AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(12.r), + child: AnimatedBuilder( + animation: qrColor, + builder: (context, _) => QrImageView( + key: ValueKey(profileDeepLink), + data: profileDeepLink, + padding: EdgeInsets.zero, + 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 +182,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, From 10e607e5738b130fd7bdde28df9f3ebada2378b2 Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Wed, 20 May 2026 21:41:39 +0200 Subject: [PATCH 02/10] fix: mounted guard and error handling in captureAndShareQr --- lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 4 +++ lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_it.arb | 1 + lib/l10n/app_pt.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_tr.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/l10n/app_zh_Hant.arb | 1 + lib/l10n/generated/app_localizations.dart | 6 +++++ lib/l10n/generated/app_localizations_de.dart | 3 +++ lib/l10n/generated/app_localizations_en.dart | 3 +++ lib/l10n/generated/app_localizations_es.dart | 3 +++ lib/l10n/generated/app_localizations_fr.dart | 3 +++ lib/l10n/generated/app_localizations_it.dart | 3 +++ lib/l10n/generated/app_localizations_pt.dart | 3 +++ lib/l10n/generated/app_localizations_ru.dart | 3 +++ lib/l10n/generated/app_localizations_tr.dart | 3 +++ lib/l10n/generated/app_localizations_zh.dart | 6 +++++ lib/screens/share_profile_screen.dart | 28 +++++++++++++++----- 21 files changed, 70 insertions(+), 7 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 8f37eff5..d766e4eb 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -95,6 +95,7 @@ "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 414243ee..0b9f5f95 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -403,6 +403,10 @@ "@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 1dbb8479..e40f1025 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -95,6 +95,7 @@ "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 3244f9f5..c88e2904 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -95,6 +95,7 @@ "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 7e7da89c..b18eb065 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -95,6 +95,7 @@ "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 96864bc5..93322e98 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -95,6 +95,7 @@ "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 f58a4c78..c76d1f19 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -95,6 +95,7 @@ "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 f80a5303..baacef79 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -95,6 +95,7 @@ "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 a420d738..645ce77a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -95,6 +95,7 @@ "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 5920d1ae..9c8c95cd 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -95,6 +95,7 @@ "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 71361636..718e3b40 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -681,6 +681,12 @@ abstract class AppLocalizations { /// **'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 f0abf28b..615dd3fe 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -329,6 +329,9 @@ class AppLocalizationsDe extends AppLocalizations { @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 1d321b26..5b6a5afd 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -326,6 +326,9 @@ class AppLocalizationsEn extends AppLocalizations { @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 3f6ff79c..de42deeb 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -327,6 +327,9 @@ class AppLocalizationsEs extends AppLocalizations { @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 00932b97..66d4a010 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -327,6 +327,9 @@ class AppLocalizationsFr extends AppLocalizations { @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 3bd995bb..802b23f1 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -327,6 +327,9 @@ class AppLocalizationsIt extends AppLocalizations { @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 7a902e0f..562eb226 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -327,6 +327,9 @@ class AppLocalizationsPt extends AppLocalizations { @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 b9205607..bec8b069 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -327,6 +327,9 @@ class AppLocalizationsRu extends AppLocalizations { @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 85aef230..fc05b3f2 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -326,6 +326,9 @@ class AppLocalizationsTr extends AppLocalizations { @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 c7c7ac87..de5c3518 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -314,6 +314,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get holdToShareQrCode => '长按分享二维码'; + @override + String get shareQrCodeError => '无法分享二维码。请重试。'; + @override String get signOutTitle => '退出登录'; @@ -1776,6 +1779,9 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @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 743bc284..0b0cff26 100644 --- a/lib/screens/share_profile_screen.dart +++ b/lib/screens/share_profile_screen.dart @@ -57,9 +57,17 @@ class ShareProfileScreen extends HookConsumerWidget { end: Colors.grey, ).animate(CurvedAnimation(parent: qrColorAnim, curve: Curves.easeIn)); + final isMounted = useRef(true); + useEffect( + () => + () => isMounted.value = false, + const [], + ); + Future captureAndShareQr() async { final boundary = qrRepaintKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; if (boundary == null) return; + final errorMessage = context.l10n.shareQrCodeError; // Start dots and animation immediately; capture the last painted frame // in parallel — it's still full-color since no new frame has rendered yet. dotCount.value = 1; @@ -69,16 +77,22 @@ class ShareProfileScreen extends HookConsumerWidget { .then((img) => img.toByteData(format: ui.ImageByteFormat.png)); // onLongPressStart fires at 500ms total; dots step every 500ms after that await Future.delayed(const Duration(milliseconds: 500)); - if (!isHolding.value) return; + if (!isMounted.value || !isHolding.value) return; dotCount.value = 2; await Future.delayed(const Duration(milliseconds: 500)); - if (!isHolding.value) return; + if (!isMounted.value || !isHolding.value) return; dotCount.value = 3; - final byteData = await captureFuture; - if (byteData == null || !isHolding.value) return; - await SharePlus.instance.share( - ShareParams(files: [XFile.fromData(byteData.buffer.asUint8List(), mimeType: 'image/png')]), - ); + try { + final byteData = await captureFuture; + if (!isMounted.value || byteData == null || !isHolding.value) return; + await SharePlus.instance.share( + ShareParams( + files: [XFile.fromData(byteData.buffer.asUint8List(), mimeType: 'image/png')], + ), + ); + } catch (e) { + if (isMounted.value) showErrorNotice(errorMessage); + } } return Scaffold( From 701e967042eb0e19c4fbb2ca55313b00a6c0c859 Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Wed, 20 May 2026 21:53:25 +0200 Subject: [PATCH 03/10] fix: XFile name, concurrent capture guard, catch resets state --- lib/screens/share_profile_screen.dart | 39 ++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/lib/screens/share_profile_screen.dart b/lib/screens/share_profile_screen.dart index 0b0cff26..d9ede8ba 100644 --- a/lib/screens/share_profile_screen.dart +++ b/lib/screens/share_profile_screen.dart @@ -58,6 +58,7 @@ class ShareProfileScreen extends HookConsumerWidget { ).animate(CurvedAnimation(parent: qrColorAnim, curve: Curves.easeIn)); final isMounted = useRef(true); + final isCapturing = useRef(false); useEffect( () => () => isMounted.value = false, @@ -65,8 +66,13 @@ class ShareProfileScreen extends HookConsumerWidget { ); Future captureAndShareQr() async { + if (isCapturing.value) return; + isCapturing.value = true; final boundary = qrRepaintKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; - if (boundary == null) return; + if (boundary == null) { + isCapturing.value = false; + return; + } final errorMessage = context.l10n.shareQrCodeError; // Start dots and animation immediately; capture the last painted frame // in parallel — it's still full-color since no new frame has rendered yet. @@ -77,21 +83,42 @@ class ShareProfileScreen extends HookConsumerWidget { .then((img) => img.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; + if (!isMounted.value || !isHolding.value) { + isCapturing.value = false; + return; + } dotCount.value = 2; await Future.delayed(const Duration(milliseconds: 500)); - if (!isMounted.value || !isHolding.value) return; + if (!isMounted.value || !isHolding.value) { + isCapturing.value = false; + return; + } dotCount.value = 3; try { final byteData = await captureFuture; - if (!isMounted.value || byteData == null || !isHolding.value) return; + if (!isMounted.value || byteData == null || !isHolding.value) { + isCapturing.value = false; + return; + } await SharePlus.instance.share( ShareParams( - files: [XFile.fromData(byteData.buffer.asUint8List(), mimeType: 'image/png')], + files: [ + XFile.fromData( + byteData.buffer.asUint8List(), + name: 'qr_code.png', + mimeType: 'image/png', + ), + ], ), ); } catch (e) { - if (isMounted.value) showErrorNotice(errorMessage); + if (isMounted.value) { + dotCount.value = 0; + qrColorAnim.reverse(); + showErrorNotice(errorMessage); + } + } finally { + isCapturing.value = false; } } From 0a8956441687b7fbeeb4e389ed7f2e3744c9c5cb Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Wed, 20 May 2026 22:11:31 +0200 Subject: [PATCH 04/10] fix: align zh_Hant QR strings with locale style --- lib/l10n/app_zh_Hant.arb | 4 ++-- lib/l10n/generated/app_localizations_zh.dart | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 9c8c95cd..11191525 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -94,8 +94,8 @@ "copiedToClipboardThankYou": "已複製到剪貼簿。謝謝您的支持!", "shareProfileTitle": "分享與連結", "scanToConnect": "掃描即可連結", - "holdToShareQrCode": "長按以分享QR碼", - "shareQrCodeError": "無法分享QR碼。請重試。", + "holdToShareQrCode": "長按以分享 QR 碼", + "shareQrCodeError": "無法分享 QR 碼。請再試一次。", "signOutTitle": "登出", "signOutConfirmation": "確定要登出嗎?", "signOutWarning": "登出 White Noise 後,您的聊天記錄會從這台裝置刪除,且無法在其他裝置上還原。", diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index de5c3518..bff584c0 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -1777,10 +1777,10 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get scanToConnect => '掃描即可連結'; @override - String get holdToShareQrCode => '長按以分享QR碼'; + String get holdToShareQrCode => '長按以分享 QR 碼'; @override - String get shareQrCodeError => '無法分享QR碼。請重試。'; + String get shareQrCodeError => '無法分享 QR 碼。請再試一次。'; @override String get signOutTitle => '登出'; From 2b9f1e5f83792f706c3655a5b0eed86cf431c1a3 Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Thu, 21 May 2026 00:04:38 +0200 Subject: [PATCH 05/10] test: fix share QR coverage tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch captureAndShareQr to toImageSync() so the full-color frame is captured synchronously before the fade animation advances - Rewrite mock_share_plus to replace SharePlatform.instance with a single shared mutable mock; SharePlus.instance is a static final that captures the platform at first access, so swapping the platform later has no effect — mutating shouldFail on the already-captured instance does - Restructure the two runAsync tests: start gesture + pump(600ms) outside runAsync so the long-press timer and Future.delayed timers stay on the fake clock; enter runAsync only to give toByteData(png) real time - Update start_chat_screen_test for new ShareParams-based mock return type --- lib/screens/share_profile_screen.dart | 8 +- test/mocks/mock_share_plus.dart | 52 +++++---- test/screens/share_profile_screen_test.dart | 112 +++++++++++++++++++- test/screens/user_profile_screen_test.dart | 8 +- 4 files changed, 147 insertions(+), 33 deletions(-) diff --git a/lib/screens/share_profile_screen.dart b/lib/screens/share_profile_screen.dart index d9ede8ba..c4d28843 100644 --- a/lib/screens/share_profile_screen.dart +++ b/lib/screens/share_profile_screen.dart @@ -74,13 +74,13 @@ class ShareProfileScreen extends HookConsumerWidget { return; } final errorMessage = context.l10n.shareQrCodeError; - // Start dots and animation immediately; capture the last painted frame - // in parallel — it's still full-color since no new frame has rendered yet. + // 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 captureFuture = boundary - .toImage(pixelRatio: 3) - .then((img) => img.toByteData(format: ui.ImageByteFormat.png)); + .toImageSync(pixelRatio: 3) + .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) { 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..300a0939 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,7 @@ class _MockApi extends MockWnApi { }) async => const FlutterMetadata( name: 'Test User', displayName: 'Test Display Name', - picture: 'https://example.com/picture.jpg', + picture: null, custom: {}, ); } @@ -238,5 +239,114 @@ 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) + // Give toByteData(png) real time to complete via the engine. + await Future.delayed(const Duration(milliseconds: 100)); + await tester.pump(); // captureFuture resolved → share() called + await tester.pump(); // SharePlus response + finally block + + 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 + await Future.delayed(const Duration(milliseconds: 300)); // toByteData completes + await tester.pump(); // captureFuture resolved → share() throws → catch fires + await tester.pump(); // showErrorNotice state applied + await tester.pump(); // widget rebuilt with error notice + + 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..c1d0555a 100644 --- a/test/screens/user_profile_screen_test.dart +++ b/test/screens/user_profile_screen_test.dart @@ -552,13 +552,7 @@ 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')); }); group('invite callout description', () { From c88b8ce129220bf6b171fa39540824c10d65274a Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Thu, 21 May 2026 00:07:28 +0200 Subject: [PATCH 06/10] format --- test/screens/share_profile_screen_test.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/screens/share_profile_screen_test.dart b/test/screens/share_profile_screen_test.dart index 300a0939..0e45b0cb 100644 --- a/test/screens/share_profile_screen_test.dart +++ b/test/screens/share_profile_screen_test.dart @@ -308,7 +308,9 @@ void main() { // 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.pump( + const Duration(milliseconds: 600), + ); // onLongPressStart fires at fake t=500ms await tester.runAsync(() async { // Advance fake timers for captureAndShareQr's two 500ms delays. From 7c71018f7f4e792d7326bb9590cd439f83781ce8 Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Thu, 21 May 2026 10:57:37 +0200 Subject: [PATCH 07/10] test: poll for toByteData completion instead of fixed delay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI is slower than local — toByteData(png) takes more than 100ms there. Both runAsync tests now poll every 100ms (up to 3s) until the expected state appears, so they pass regardless of machine speed. --- test/screens/share_profile_screen_test.dart | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/test/screens/share_profile_screen_test.dart b/test/screens/share_profile_screen_test.dart index 0e45b0cb..13d83eec 100644 --- a/test/screens/share_profile_screen_test.dart +++ b/test/screens/share_profile_screen_test.dart @@ -316,10 +316,12 @@ void main() { // 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) - // Give toByteData(png) real time to complete via the engine. - await Future.delayed(const Duration(milliseconds: 100)); - await tester.pump(); // captureFuture resolved → share() called - await tester.pump(); // SharePlus response + finally block + // 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); @@ -339,10 +341,12 @@ void main() { await tester.runAsync(() async { await tester.pump(const Duration(milliseconds: 400)); await tester.pump(const Duration(milliseconds: 500)); // second delay; dotCount=3 - await Future.delayed(const Duration(milliseconds: 300)); // toByteData completes - await tester.pump(); // captureFuture resolved → share() throws → catch fires - await tester.pump(); // showErrorNotice state applied - await tester.pump(); // widget rebuilt with error notice + // 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); From 23ae53dd7ea5483ad5c5340555a0a07bf75a0560 Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Thu, 21 May 2026 11:25:16 +0200 Subject: [PATCH 08/10] fix: add share_plus_platform_interface as direct dev dependency Lint rule depend_on_referenced_packages requires it since mock_share_plus.dart imports SharePlatform directly. --- pubspec.lock | 2 +- pubspec.yaml | 1 + test/screens/share_profile_screen_test.dart | 1 - 3 files changed, 2 insertions(+), 2 deletions(-) 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/screens/share_profile_screen_test.dart b/test/screens/share_profile_screen_test.dart index 13d83eec..8e8f08f9 100644 --- a/test/screens/share_profile_screen_test.dart +++ b/test/screens/share_profile_screen_test.dart @@ -28,7 +28,6 @@ class _MockApi extends MockWnApi { }) async => const FlutterMetadata( name: 'Test User', displayName: 'Test Display Name', - picture: null, custom: {}, ); } From 73ea9079a891f1071badeae553d68ce7ece4b653 Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Thu, 21 May 2026 12:04:58 +0200 Subject: [PATCH 09/10] fix: add padding inside QR to prevent corner clipping --- lib/screens/share_profile_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/screens/share_profile_screen.dart b/lib/screens/share_profile_screen.dart index c4d28843..b24ff20c 100644 --- a/lib/screens/share_profile_screen.dart +++ b/lib/screens/share_profile_screen.dart @@ -201,7 +201,7 @@ class ShareProfileScreen extends HookConsumerWidget { builder: (context, _) => QrImageView( key: ValueKey(profileDeepLink), data: profileDeepLink, - padding: EdgeInsets.zero, + padding: EdgeInsets.all(12.r), backgroundColor: colors.backgroundSecondary, eyeStyle: QrEyeStyle( eyeShape: QrEyeShape.square, From 7d692448d65de3056bcc5b81f14c1a62c00b74d7 Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Thu, 21 May 2026 17:04:15 +0200 Subject: [PATCH 10/10] fix: address agent review on QR share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1: Hold ui.Image reference and dispose it in finally — toImageSync returns a caller-owned image (~2.4MB at pixelRatio: 3) that was leaking on every long-press. M2: Move RepaintBoundary inside ClipRRect so the captured PNG contains the unclipped rectangular QR. Previously the rounded-corner regions were encoded as transparent pixels, which renders as dark blobs on dark backgrounds and can halo the corner finder patterns when alpha-blended. M4: Assert shareCalls[0].files is null in the invite-share test to catch accidental file-attachment regressions in the text-only path. --- lib/screens/share_profile_screen.dart | 47 +++++++++------------- test/screens/user_profile_screen_test.dart | 1 + 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/lib/screens/share_profile_screen.dart b/lib/screens/share_profile_screen.dart index b24ff20c..1d0c31af 100644 --- a/lib/screens/share_profile_screen.dart +++ b/lib/screens/share_profile_screen.dart @@ -78,28 +78,18 @@ class ShareProfileScreen extends HookConsumerWidget { // image is always the full-color QR regardless of animation state. dotCount.value = 1; qrColorAnim.forward(); - final captureFuture = boundary - .toImageSync(pixelRatio: 3) - .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) { - isCapturing.value = false; - return; - } - dotCount.value = 2; - await Future.delayed(const Duration(milliseconds: 500)); - if (!isMounted.value || !isHolding.value) { - isCapturing.value = false; - return; - } - dotCount.value = 3; + 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) { - isCapturing.value = false; - return; - } + if (!isMounted.value || byteData == null || !isHolding.value) return; await SharePlus.instance.share( ShareParams( files: [ @@ -118,6 +108,7 @@ class ShareProfileScreen extends HookConsumerWidget { showErrorNotice(errorMessage); } } finally { + image.dispose(); isCapturing.value = false; } } @@ -188,14 +179,14 @@ class ShareProfileScreen extends HookConsumerWidget { dotCount.value = 0; qrColorAnim.reverse(); }, - child: RepaintBoundary( - key: qrRepaintKey, - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: 256.w), - child: AspectRatio( - aspectRatio: 1, - child: ClipRRect( - borderRadius: BorderRadius.circular(12.r), + 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( diff --git a/test/screens/user_profile_screen_test.dart b/test/screens/user_profile_screen_test.dart index c1d0555a..e05d5724 100644 --- a/test/screens/user_profile_screen_test.dart +++ b/test/screens/user_profile_screen_test.dart @@ -553,6 +553,7 @@ void main() { expect(shareCalls.length, 1); expect(shareCalls[0].text, contains('whitenoise.chat')); + expect(shareCalls[0].files, isNull, reason: 'invite share is text-only'); }); group('invite callout description', () {