better feedback in case a qr code was shown
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-06-15 18:04:14 +02:00
parent 0e377d0ebc
commit 75079a790c
9 changed files with 239 additions and 140 deletions

View file

@ -2567,7 +2567,7 @@ abstract class AppLocalizations {
/// No description provided for @verifiedPublicKey. /// No description provided for @verifiedPublicKey.
/// ///
/// In en, this message translates to: /// 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); String verifiedPublicKey(Object username);
/// No description provided for @memoriesAYearAgo. /// No description provided for @memoriesAYearAgo.

View file

@ -1429,7 +1429,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String verifiedPublicKey(Object username) { 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 @override

View file

@ -1418,7 +1418,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String verifiedPublicKey(Object username) { 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 @override

@ -1 +1 @@
Subproject commit 673f6d8c3036d64060b1114912bd5bf5515d5420 Subproject commit 2e608f2d5acc26ee001877b673e361884fb3d0ca

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:collection/collection.dart'; 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/services/signal/session.signal.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.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 { class KeyVerificationService {
static Future<List<int>> getNewSecretVerificationToken() async { static Future<List<int>> getNewSecretVerificationToken() async {
@ -77,12 +78,14 @@ class KeyVerificationService {
final contact = await twonlyDB.contactsDao.getContactById(fromUserId); final contact = await twonlyDB.contactsDao.getContactById(fromUserId);
final context = rootNavigatorKey.currentContext; final context = rootNavigatorKey.currentContext;
if (context != null && context.mounted && contact != null) { if (context != null && context.mounted && contact != null) {
showSnackbar( unawaited(
context, VerificationSuccessDialog.show(
context.lang.secretQrTokenVerifiedSnackbar( context,
getContactDisplayName(contact), contact,
message: context.lang.secretQrTokenVerifiedSnackbar(
getContactDisplayName(contact),
),
), ),
level: SnackbarLevel.success,
); );
} }
return; return;

View file

@ -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<bool?> show(BuildContext context, PublicProfile profile) {
return showDialog<bool>(
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),
),
),
],
),
],
),
),
);
}
}

View file

@ -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<void> show(BuildContext context, Contact contact, {String? message}) {
return showDialog<void>(
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),
),
],
),
),
);
}
}

View file

@ -1,11 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.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/database/daos/contacts.dao.dart';
import 'package:twonly/src/utils/misc.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:twonly/src/visual/views/camera/camera_preview_components/main_camera_controller.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
@ -27,113 +23,14 @@ class CameraScannedOverlay extends StatelessWidget {
width: 150, width: 150,
child: ListView( child: ListView(
children: [ children: [
...mainController.scannedNewProfiles.values.map( if (mainController.scannedUrl != null)
(c) => _buildScannedProfileTile(context, c), _buildScannedUrlTile(context, mainController.scannedUrl!),
),
...mainController.contactsVerified.values.map(
(c) => _buildVerifiedContactTile(context, c),
),
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) { Widget _buildScannedUrlTile(BuildContext context, String url) {
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {

View file

@ -12,13 +12,14 @@ import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/locator.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/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/qr.pb.dart'; import 'package:twonly/src/model/protobuf/client/generated/qr.pb.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/qr.utils.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/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/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/camera_preview_controller_view.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/face_filters.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/face_filters.dart';
@ -141,7 +142,8 @@ class MainCameraController {
if (init) { if (init) {
for (; cameraId < AppEnvironment.cameras.length; cameraId++) { for (; cameraId < AppEnvironment.cameras.length; cameraId++) {
if (AppEnvironment.cameras[cameraId].lensDirection == CameraLensDirection.back) { if (AppEnvironment.cameras[cameraId].lensDirection ==
CameraLensDirection.back) {
break; break;
} }
} }
@ -157,7 +159,9 @@ class MainCameraController {
AppEnvironment.cameras[cameraId], AppEnvironment.cameras[cameraId],
ResolutionPreset.high, ResolutionPreset.high,
enableAudio: hasMic, enableAudio: hasMic,
imageFormatGroup: Platform.isAndroid ? ImageFormatGroup.nv21 : ImageFormatGroup.bgra8888, imageFormatGroup: Platform.isAndroid
? ImageFormatGroup.nv21
: ImageFormatGroup.bgra8888,
); );
try { try {
_initializeFuture = cameraController?.initialize(); _initializeFuture = cameraController?.initialize();
@ -210,10 +214,14 @@ class MainCameraController {
selectedCameraDetails.isFlashOn ? FlashMode.always : FlashMode.off, selectedCameraDetails.isFlashOn ? FlashMode.always : FlashMode.off,
); );
if (cameraController == null) return; if (cameraController == null) return;
selectedCameraDetails.maxAvailableZoom = await cameraController?.getMaxZoomLevel() ?? 1; selectedCameraDetails.maxAvailableZoom =
selectedCameraDetails.minAvailableZoom = await cameraController?.getMinZoomLevel() ?? 1; await cameraController?.getMaxZoomLevel() ?? 1;
selectedCameraDetails.minAvailableZoom =
await cameraController?.getMinZoomLevel() ?? 1;
selectedCameraDetails selectedCameraDetails
..isZoomAble = selectedCameraDetails.maxAvailableZoom != selectedCameraDetails.minAvailableZoom ..isZoomAble =
selectedCameraDetails.maxAvailableZoom !=
selectedCameraDetails.minAvailableZoom
..cameraLoaded = true ..cameraLoaded = true
..cameraId = cameraId; ..cameraId = cameraId;
@ -235,7 +243,8 @@ class MainCameraController {
} }
Future<void> onTapDown(TapDownDetails details) async { Future<void> onTapDown(TapDownDetails details) async {
final box = cameraPreviewKey.currentContext?.findRenderObject() as RenderBox?; final box =
cameraPreviewKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null) return; if (box == null) return;
final localPosition = box.globalToLocal(details.globalPosition); final localPosition = box.globalToLocal(details.globalPosition);
@ -251,7 +260,8 @@ class MainCameraController {
await cameraController?.setFocusPoint(Offset(dx, dy)); await cameraController?.setFocusPoint(Offset(dx, dy));
await cameraController?.setFocusMode(FocusMode.auto); await cameraController?.setFocusMode(FocusMode.auto);
} catch (e) { } 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'); Log.info('Focus point or mode not supported on this device');
} else { } else {
Log.warn(e); Log.warn(e);
@ -292,7 +302,8 @@ class MainCameraController {
if (inputImage == null) return; if (inputImage == null) return;
_processBarcode(inputImage); _processBarcode(inputImage);
// check if front camera is selected // check if front camera is selected
if (cameraController?.description.lensDirection == CameraLensDirection.front) { if (cameraController?.description.lensDirection ==
CameraLensDirection.front) {
if (_currentFilterType != FaceFilterType.none) { if (_currentFilterType != FaceFilterType.none) {
_processFaces(inputImage); _processFaces(inputImage);
} }
@ -311,14 +322,16 @@ class MainCameraController {
if (Platform.isIOS) { if (Platform.isIOS) {
rotation = InputImageRotationValue.fromRawValue(sensorOrientation); rotation = InputImageRotationValue.fromRawValue(sensorOrientation);
} else if (Platform.isAndroid) { } else if (Platform.isAndroid) {
var rotationCompensation = _orientations[cameraController!.value.deviceOrientation]; var rotationCompensation =
_orientations[cameraController!.value.deviceOrientation];
if (rotationCompensation == null) return null; if (rotationCompensation == null) return null;
if (camera.lensDirection == CameraLensDirection.front) { if (camera.lensDirection == CameraLensDirection.front) {
// front-facing // front-facing
rotationCompensation = (sensorOrientation + rotationCompensation) % 360; rotationCompensation = (sensorOrientation + rotationCompensation) % 360;
} else { } else {
// back-facing // back-facing
rotationCompensation = (sensorOrientation - rotationCompensation + 360) % 360; rotationCompensation =
(sensorOrientation - rotationCompensation + 360) % 360;
} }
rotation = InputImageRotationValue.fromRawValue(rotationCompensation); rotation = InputImageRotationValue.fromRawValue(rotationCompensation);
} }
@ -360,7 +373,9 @@ class MainCameraController {
if (_isBusy) return; if (_isBusy) return;
_isBusy = true; _isBusy = true;
final barcodes = await _barcodeScanner.processImage(inputImage); 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( final painter = BarcodeDetectorPainter(
barcodes, barcodes,
inputImage.metadata!.size, inputImage.metadata!.size,
@ -397,11 +412,21 @@ class MainCameraController {
} }
if (contact == null || contact.deletedByUser) { if (contact == null || contact.deletedByUser) {
if (scannedNewProfiles[profile.userId.toInt()] == null) { final context = cameraPreviewKey.currentContext;
await HapticFeedback.heavyImpact(); if (context != null && context.mounted) {
scannedNewProfiles[profile.userId.toInt()] = ScannedNewProfile( unawaited(HapticFeedback.heavyImpact());
profile: profile, 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; continue;
} }
@ -412,16 +437,10 @@ class MainCameraController {
verificationOk: verificationOk, verificationOk: verificationOk,
); );
await HapticFeedback.heavyImpact(); unawaited(HapticFeedback.heavyImpact());
final context = cameraPreviewKey.currentContext; final context = cameraPreviewKey.currentContext;
if (verificationOk && context != null && context.mounted) { if (verificationOk && context != null && context.mounted) {
showSnackbar( await VerificationSuccessDialog.show(context, contact);
context,
context.lang.verifiedPublicKey(
getContactDisplayName(contact),
),
level: SnackbarLevel.success,
);
} }
} }
continue; continue;
@ -444,7 +463,9 @@ class MainCameraController {
if (_isBusyFaces) return; if (_isBusyFaces) return;
_isBusyFaces = true; _isBusyFaces = true;
final faces = await _faceDetector.processImage(inputImage); 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) { if (faces.isNotEmpty) {
CustomPainter? painter; CustomPainter? painter;
switch (_currentFilterType) { switch (_currentFilterType) {