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.
///
/// 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.

View file

@ -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

View file

@ -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

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

View file

@ -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<List<int>> 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;

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: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: () {

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: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<void> 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) {