diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index e30d9e52..c5f4bf5c 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -2567,7 +2567,7 @@ abstract class AppLocalizations { /// No description provided for @verifiedPublicKey. /// /// In en, this message translates to: - /// **'The public key of {username} has been verified and is valid.'** + /// **'The identity of {username} has been successfully verified.'** String verifiedPublicKey(Object username); /// No description provided for @memoriesAYearAgo. diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 8c863954..1077511a 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -1429,7 +1429,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String verifiedPublicKey(Object username) { - return 'Der öffentliche Schlüssel von $username wurde überprüft und ist gültig.'; + return 'Die Identität von $username wurde erfolgreich überprüft.'; } @override diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 6594af21..6f12abd8 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -1418,7 +1418,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String verifiedPublicKey(Object username) { - return 'The public key of $username has been verified and is valid.'; + return 'The identity of $username has been successfully verified.'; } @override diff --git a/lib/src/localization/translations b/lib/src/localization/translations index 673f6d8c..2e608f2d 160000 --- a/lib/src/localization/translations +++ b/lib/src/localization/translations @@ -1 +1 @@ -Subproject commit 673f6d8c3036d64060b1114912bd5bf5515d5420 +Subproject commit 2e608f2d5acc26ee001877b673e361884fb3d0ca diff --git a/lib/src/services/key_verification.service.dart b/lib/src/services/key_verification.service.dart index f3299ef2..60cc970c 100644 --- a/lib/src/services/key_verification.service.dart +++ b/lib/src/services/key_verification.service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:typed_data'; import 'package:collection/collection.dart'; @@ -13,7 +14,7 @@ import 'package:twonly/src/services/signal/identity.signal.dart'; import 'package:twonly/src/services/signal/session.signal.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; -import 'package:twonly/src/visual/components/snackbar.dart'; +import 'package:twonly/src/visual/components/verification_success_dialog.comp.dart'; class KeyVerificationService { static Future> getNewSecretVerificationToken() async { @@ -77,12 +78,14 @@ class KeyVerificationService { final contact = await twonlyDB.contactsDao.getContactById(fromUserId); final context = rootNavigatorKey.currentContext; if (context != null && context.mounted && contact != null) { - showSnackbar( - context, - context.lang.secretQrTokenVerifiedSnackbar( - getContactDisplayName(contact), + unawaited( + VerificationSuccessDialog.show( + context, + contact, + message: context.lang.secretQrTokenVerifiedSnackbar( + getContactDisplayName(contact), + ), ), - level: SnackbarLevel.success, ); } return; diff --git a/lib/src/visual/components/add_contact_dialog.comp.dart b/lib/src/visual/components/add_contact_dialog.comp.dart new file mode 100644 index 00000000..160a31e4 --- /dev/null +++ b/lib/src/visual/components/add_contact_dialog.comp.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:twonly/src/model/protobuf/client/generated/qr.pb.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; + +/// A premium popup dialog shown when a new user profile is scanned via QR code. +/// Allows the user to request connection ("Anfragen") or cancel ("Abbrechen"). +class AddContactDialog extends StatelessWidget { + const AddContactDialog({ + required this.profile, + super.key, + }); + + final PublicProfile profile; + + /// Utility method to easily present this dialog. + /// Returns `true` if the user chose to request the contact, `false` otherwise. + static Future show(BuildContext context, PublicProfile profile) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AddContactDialog(profile: profile), + ); + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + backgroundColor: Theme.of(context).colorScheme.surface, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const AvatarIcon( + fontSize: 16, + ), + const SizedBox(width: 12), + Flexible( + child: Text( + profile.username, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + ), + ], + ), + const SizedBox(height: 24), + Text( + context.lang.userFoundBody(profile.username), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + ), + ), + const SizedBox(height: 28), + Row( + children: [ + Expanded( + child: MyButton( + variant: MyButtonVariant.secondary, + onPressed: () => Navigator.pop(context, false), + child: Text(context.lang.cancel), + ), + ), + const SizedBox(width: 12), + Expanded( + child: MyButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.lang.friendSuggestionsRequest), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/visual/components/verification_success_dialog.comp.dart b/lib/src/visual/components/verification_success_dialog.comp.dart new file mode 100644 index 00000000..ee48d804 --- /dev/null +++ b/lib/src/visual/components/verification_success_dialog.comp.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:twonly/src/database/daos/contacts.dao.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; +import 'package:twonly/src/visual/components/verification_success_animation.comp.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; + +/// A premium popup dialog shown when a contact's public key has been successfully verified. +class VerificationSuccessDialog extends StatelessWidget { + const VerificationSuccessDialog({ + required this.contact, + this.message, + super.key, + }); + + final Contact contact; + final String? message; + + /// Utility method to easily present this dialog. + static Future show(BuildContext context, Contact contact, {String? message}) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => VerificationSuccessDialog(contact: contact, message: message), + ); + } + + @override + Widget build(BuildContext context) { + final displayName = getContactDisplayName(contact); + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + backgroundColor: Theme.of(context).colorScheme.surface, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 28, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AvatarIcon( + contactId: contact.userId, + fontSize: 16, + ), + const SizedBox(width: 12), + Flexible( + child: Text( + displayName, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + ), + ], + ), + const SizedBox(height: 24), + const VerificationSuccessAnimation( + size: 160, + ), + const SizedBox(height: 24), + Text( + message ?? context.lang.verifiedPublicKey(displayName), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + ), + ), + const SizedBox(height: 28), + MyButton( + onPressed: () => Navigator.pop(context), + child: Text(context.lang.close), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_components/camera_scanned_overlay.dart b/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_components/camera_scanned_overlay.dart index 6718ef41..eab361ef 100644 --- a/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_components/camera_scanned_overlay.dart +++ b/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_components/camera_scanned_overlay.dart @@ -1,11 +1,7 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:lottie/lottie.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/utils/misc.dart'; -import 'package:twonly/src/utils/qr.utils.dart'; -import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; -import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/main_camera_controller.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -27,113 +23,14 @@ class CameraScannedOverlay extends StatelessWidget { width: 150, child: ListView( children: [ - ...mainController.scannedNewProfiles.values.map( - (c) => _buildScannedProfileTile(context, c), - ), - ...mainController.contactsVerified.values.map( - (c) => _buildVerifiedContactTile(context, c), - ), - if (mainController.scannedUrl != null) _buildScannedUrlTile(context, mainController.scannedUrl!), + if (mainController.scannedUrl != null) + _buildScannedUrlTile(context, mainController.scannedUrl!), ], ), ), ); } - Widget _buildScannedProfileTile(BuildContext context, ScannedNewProfile c) { - if (c.isLoading) return Container(); - return GestureDetector( - onTap: () async { - c.isLoading = true; - mainController.setState?.call(); - - showSnackbar( - context, - context.lang.requestedUserToastText(c.profile.username), - level: SnackbarLevel.success, - ); - if (await addNewContactFromPublicProfile(c.profile) && context.mounted) { - // showSnackbar( - // context, - // context.lang.requestedUserToastText(c.profile.username), - // level: SnackbarLevel.success, - // ); - } - }, - child: Container( - padding: const EdgeInsets.all(12), - margin: const EdgeInsets.only(bottom: 10), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: context.color.surfaceContainer, - ), - child: Row( - children: [ - Text(c.profile.username), - Expanded(child: Container()), - if (c.isLoading) - const SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator.adaptive(strokeWidth: 2), - ) - else - ColoredBox( - color: Colors.transparent, - child: FaIcon( - FontAwesomeIcons.userPlus, - color: isDarkMode(context) ? Colors.white : Colors.black, - size: 17, - ), - ), - ], - ), - ), - ); - } - - Widget _buildVerifiedContactTile( - BuildContext context, - ScannedVerifiedContact c, - ) { - return Container( - padding: const EdgeInsets.all(8), - margin: const EdgeInsets.only(bottom: 10), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: context.color.surfaceContainer, - ), - child: Row( - children: [ - AvatarIcon( - contactId: c.contact.userId, - fontSize: 14, - ), - const SizedBox(width: 10), - Text( - getContactDisplayName(c.contact, maxLength: 9), - ), - Expanded(child: Container()), - ColoredBox( - color: Colors.transparent, - child: SizedBox( - width: 30, - child: Lottie.asset( - c.verificationOk ? 'assets/animations/success.lottie' : 'assets/animations/failed.lottie', - repeat: false, - onLoaded: (p0) { - Future.delayed(const Duration(seconds: 4), () { - mainController.setState?.call(); - }); - }, - ), - ), - ), - ], - ), - ); - } - Widget _buildScannedUrlTile(BuildContext context, String url) { return GestureDetector( onTap: () { diff --git a/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart b/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart index 1a2cae41..6020433f 100644 --- a/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart +++ b/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart @@ -12,13 +12,14 @@ import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; -import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/qr.pb.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/qr.utils.dart'; +import 'package:twonly/src/visual/components/add_contact_dialog.comp.dart'; import 'package:twonly/src/visual/components/snackbar.dart'; +import 'package:twonly/src/visual/components/verification_success_dialog.comp.dart'; import 'package:twonly/src/visual/helpers/screenshot.helper.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/face_filters.dart'; @@ -141,7 +142,8 @@ class MainCameraController { if (init) { for (; cameraId < AppEnvironment.cameras.length; cameraId++) { - if (AppEnvironment.cameras[cameraId].lensDirection == CameraLensDirection.back) { + if (AppEnvironment.cameras[cameraId].lensDirection == + CameraLensDirection.back) { break; } } @@ -157,7 +159,9 @@ class MainCameraController { AppEnvironment.cameras[cameraId], ResolutionPreset.high, enableAudio: hasMic, - imageFormatGroup: Platform.isAndroid ? ImageFormatGroup.nv21 : ImageFormatGroup.bgra8888, + imageFormatGroup: Platform.isAndroid + ? ImageFormatGroup.nv21 + : ImageFormatGroup.bgra8888, ); try { _initializeFuture = cameraController?.initialize(); @@ -210,10 +214,14 @@ class MainCameraController { selectedCameraDetails.isFlashOn ? FlashMode.always : FlashMode.off, ); if (cameraController == null) return; - selectedCameraDetails.maxAvailableZoom = await cameraController?.getMaxZoomLevel() ?? 1; - selectedCameraDetails.minAvailableZoom = await cameraController?.getMinZoomLevel() ?? 1; + selectedCameraDetails.maxAvailableZoom = + await cameraController?.getMaxZoomLevel() ?? 1; + selectedCameraDetails.minAvailableZoom = + await cameraController?.getMinZoomLevel() ?? 1; selectedCameraDetails - ..isZoomAble = selectedCameraDetails.maxAvailableZoom != selectedCameraDetails.minAvailableZoom + ..isZoomAble = + selectedCameraDetails.maxAvailableZoom != + selectedCameraDetails.minAvailableZoom ..cameraLoaded = true ..cameraId = cameraId; @@ -235,7 +243,8 @@ class MainCameraController { } Future onTapDown(TapDownDetails details) async { - final box = cameraPreviewKey.currentContext?.findRenderObject() as RenderBox?; + final box = + cameraPreviewKey.currentContext?.findRenderObject() as RenderBox?; if (box == null) return; final localPosition = box.globalToLocal(details.globalPosition); @@ -251,7 +260,8 @@ class MainCameraController { await cameraController?.setFocusPoint(Offset(dx, dy)); await cameraController?.setFocusMode(FocusMode.auto); } catch (e) { - if (e is CameraException && (e.code == 'setFocusPointFailed' || e.code == 'setFocusModeFailed')) { + if (e is CameraException && + (e.code == 'setFocusPointFailed' || e.code == 'setFocusModeFailed')) { Log.info('Focus point or mode not supported on this device'); } else { Log.warn(e); @@ -292,7 +302,8 @@ class MainCameraController { if (inputImage == null) return; _processBarcode(inputImage); // check if front camera is selected - if (cameraController?.description.lensDirection == CameraLensDirection.front) { + if (cameraController?.description.lensDirection == + CameraLensDirection.front) { if (_currentFilterType != FaceFilterType.none) { _processFaces(inputImage); } @@ -311,14 +322,16 @@ class MainCameraController { if (Platform.isIOS) { rotation = InputImageRotationValue.fromRawValue(sensorOrientation); } else if (Platform.isAndroid) { - var rotationCompensation = _orientations[cameraController!.value.deviceOrientation]; + var rotationCompensation = + _orientations[cameraController!.value.deviceOrientation]; if (rotationCompensation == null) return null; if (camera.lensDirection == CameraLensDirection.front) { // front-facing rotationCompensation = (sensorOrientation + rotationCompensation) % 360; } else { // back-facing - rotationCompensation = (sensorOrientation - rotationCompensation + 360) % 360; + rotationCompensation = + (sensorOrientation - rotationCompensation + 360) % 360; } rotation = InputImageRotationValue.fromRawValue(rotationCompensation); } @@ -360,7 +373,9 @@ class MainCameraController { if (_isBusy) return; _isBusy = true; final barcodes = await _barcodeScanner.processImage(inputImage); - if (inputImage.metadata?.size != null && inputImage.metadata?.rotation != null && cameraController != null) { + if (inputImage.metadata?.size != null && + inputImage.metadata?.rotation != null && + cameraController != null) { final painter = BarcodeDetectorPainter( barcodes, inputImage.metadata!.size, @@ -397,11 +412,21 @@ class MainCameraController { } if (contact == null || contact.deletedByUser) { - if (scannedNewProfiles[profile.userId.toInt()] == null) { - await HapticFeedback.heavyImpact(); - scannedNewProfiles[profile.userId.toInt()] = ScannedNewProfile( - profile: profile, + final context = cameraPreviewKey.currentContext; + if (context != null && context.mounted) { + unawaited(HapticFeedback.heavyImpact()); + final shouldRequest = await AddContactDialog.show( + context, + profile, ); + if (shouldRequest == true && context.mounted) { + showSnackbar( + context, + context.lang.requestedUserToastText(profile.username), + level: SnackbarLevel.success, + ); + await addNewContactFromPublicProfile(profile); + } } continue; } @@ -412,16 +437,10 @@ class MainCameraController { verificationOk: verificationOk, ); - await HapticFeedback.heavyImpact(); + unawaited(HapticFeedback.heavyImpact()); final context = cameraPreviewKey.currentContext; if (verificationOk && context != null && context.mounted) { - showSnackbar( - context, - context.lang.verifiedPublicKey( - getContactDisplayName(contact), - ), - level: SnackbarLevel.success, - ); + await VerificationSuccessDialog.show(context, contact); } } continue; @@ -444,7 +463,9 @@ class MainCameraController { if (_isBusyFaces) return; _isBusyFaces = true; final faces = await _faceDetector.processImage(inputImage); - if (inputImage.metadata?.size != null && inputImage.metadata?.rotation != null && cameraController != null) { + if (inputImage.metadata?.size != null && + inputImage.metadata?.rotation != null && + cameraController != null) { if (faces.isNotEmpty) { CustomPainter? painter; switch (_currentFilterType) {