modify passwordles recovery settings
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-06-21 03:40:35 +02:00
parent 4d943eca1c
commit 00b2b25252
24 changed files with 1579 additions and 552 deletions

View file

@ -3671,6 +3671,294 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Reset'** /// **'Reset'**
String get avatarCustomizeReset; String get avatarCustomizeReset;
/// No description provided for @passwordlessRecovery.
///
/// In en, this message translates to:
/// **'Passwordless Recovery'**
String get passwordlessRecovery;
/// No description provided for @passwordlessRecoveryNotConfigured.
///
/// In en, this message translates to:
/// **'Not configured'**
String get passwordlessRecoveryNotConfigured;
/// No description provided for @passwordlessRecoveryTestPin.
///
/// In en, this message translates to:
/// **'Test PIN'**
String get passwordlessRecoveryTestPin;
/// No description provided for @passwordlessRecoveryTestPinTitle.
///
/// In en, this message translates to:
/// **'Test PIN'**
String get passwordlessRecoveryTestPinTitle;
/// No description provided for @passwordlessRecoveryTestPinHint.
///
/// In en, this message translates to:
/// **'Enter your PIN'**
String get passwordlessRecoveryTestPinHint;
/// No description provided for @passwordlessRecoveryTestPinCorrect.
///
/// In en, this message translates to:
/// **'PIN is correct!'**
String get passwordlessRecoveryTestPinCorrect;
/// No description provided for @passwordlessRecoveryTestPinIncorrect.
///
/// In en, this message translates to:
/// **'Incorrect PIN.'**
String get passwordlessRecoveryTestPinIncorrect;
/// No description provided for @passwordlessRecoveryTest.
///
/// In en, this message translates to:
/// **'Test'**
String get passwordlessRecoveryTest;
/// No description provided for @passwordlessRecoverySecondFactorNone.
///
/// In en, this message translates to:
/// **'None'**
String get passwordlessRecoverySecondFactorNone;
/// No description provided for @passwordlessRecoverySecondFactorEmailLabel.
///
/// In en, this message translates to:
/// **'Email ({email})'**
String passwordlessRecoverySecondFactorEmailLabel(Object email);
/// No description provided for @passwordlessRecoverySecondFactorPin.
///
/// In en, this message translates to:
/// **'PIN'**
String get passwordlessRecoverySecondFactorPin;
/// No description provided for @passwordlessRecoverySecondFactorEmail.
///
/// In en, this message translates to:
/// **'Email'**
String get passwordlessRecoverySecondFactorEmail;
/// No description provided for @passwordlessRecoveryModify.
///
/// In en, this message translates to:
/// **'Modify Recovery Settings'**
String get passwordlessRecoveryModify;
/// No description provided for @passwordlessRecoveryModifyDesc.
///
/// In en, this message translates to:
/// **'Update your recovery configuration'**
String get passwordlessRecoveryModifyDesc;
/// No description provided for @passwordlessRecoverySecondFactor.
///
/// In en, this message translates to:
/// **'Second Factor'**
String get passwordlessRecoverySecondFactor;
/// No description provided for @passwordlessRecoveryNoFriendsFound.
///
/// In en, this message translates to:
/// **'No trusted friends found.'**
String get passwordlessRecoveryNoFriendsFound;
/// No description provided for @passwordlessRecoveryActiveFriends.
///
/// In en, this message translates to:
/// **'Active Friends'**
String get passwordlessRecoveryActiveFriends;
/// No description provided for @passwordlessRecoveryActiveFriendsDesc.
///
/// In en, this message translates to:
/// **'These trusted friends are actively using twonly and can probably help you recover your account.'**
String get passwordlessRecoveryActiveFriendsDesc;
/// No description provided for @passwordlessRecoveryInactiveFriends.
///
/// In en, this message translates to:
/// **'Inactive Friends'**
String get passwordlessRecoveryInactiveFriends;
/// No description provided for @passwordlessRecoveryInactiveFriendsDesc.
///
/// In en, this message translates to:
/// **'These friends either have not yet received their share or do not use twonly actively anymore, which could mean that they maybe cannot help.'**
String get passwordlessRecoveryInactiveFriendsDesc;
/// No description provided for @passwordlessRecoveryNotEnoughFriends.
///
/// In en, this message translates to:
/// **'Not enough friends selected. You need at least 3.'**
String get passwordlessRecoveryNotEnoughFriends;
/// No description provided for @passwordlessRecoveryLoading.
///
/// In en, this message translates to:
/// **'Loading...'**
String get passwordlessRecoveryLoading;
/// No description provided for @passwordlessRecoveryEnableSuccess.
///
/// In en, this message translates to:
/// **'Passwordless recovery successfully enabled!'**
String get passwordlessRecoveryEnableSuccess;
/// No description provided for @passwordlessRecoveryEnableFailed.
///
/// In en, this message translates to:
/// **'Failed to enable passwordless recovery.'**
String get passwordlessRecoveryEnableFailed;
/// No description provided for @passwordlessRecoveryNeedAtLeast3.
///
/// In en, this message translates to:
/// **'You need at least 3 trusted friends.'**
String get passwordlessRecoveryNeedAtLeast3;
/// No description provided for @passwordlessRecoveryInvalidPin.
///
/// In en, this message translates to:
/// **'Invalid PIN'**
String get passwordlessRecoveryInvalidPin;
/// No description provided for @passwordlessRecoveryEnterPin.
///
/// In en, this message translates to:
/// **'Please enter a PIN.'**
String get passwordlessRecoveryEnterPin;
/// No description provided for @passwordlessRecoveryPinMinLength.
///
/// In en, this message translates to:
/// **'PIN must be at least 4 digits.'**
String get passwordlessRecoveryPinMinLength;
/// No description provided for @passwordlessRecoveryEnterEmail.
///
/// In en, this message translates to:
/// **'Please enter an email address.'**
String get passwordlessRecoveryEnterEmail;
/// No description provided for @passwordlessRecoveryEnableBtn.
///
/// In en, this message translates to:
/// **'Enable Passwordless Recovery'**
String get passwordlessRecoveryEnableBtn;
/// No description provided for @passwordlessRecoveryModifyBtn.
///
/// In en, this message translates to:
/// **'Modify Passwordless Recovery'**
String get passwordlessRecoveryModifyBtn;
/// No description provided for @passwordlessRecoveryStatusEnabled.
///
/// In en, this message translates to:
/// **'Enabled • {count} trusted friends'**
String passwordlessRecoveryStatusEnabled(num count);
/// No description provided for @passwordlessRecoveryInfoHowItWorks.
///
/// In en, this message translates to:
/// **'How it works'**
String get passwordlessRecoveryInfoHowItWorks;
/// No description provided for @passwordlessRecoveryInfoHowItWorksDesc.
///
/// In en, this message translates to:
/// **'Because twonly operates without central user accounts or phone numbers to maximize privacy, we rely on a decentralized recovery mechanism. Using Shamir\'s Secret Sharing, your cryptographic identity is split into independent shares and distributed among your trusted friends. To restore access, a predefined threshold of these friends must combine their shares.'**
String get passwordlessRecoveryInfoHowItWorksDesc;
/// No description provided for @passwordlessRecoveryInfoWhySecondFactor.
///
/// In en, this message translates to:
/// **'Why a Second Factor?'**
String get passwordlessRecoveryInfoWhySecondFactor;
/// No description provided for @passwordlessRecoveryInfoWhySecondFactorDesc.
///
/// In en, this message translates to:
/// **'The second factor (Email or PIN) serves as a vital cryptographic safeguard against malicious collusion. If your trusted friends were to coordinate their shares behind your back, they still wouldn\'t be able to decrypt your identity without the second factor key.'**
String get passwordlessRecoveryInfoWhySecondFactorDesc;
/// No description provided for @passwordlessRecoveryInfoGotIt.
///
/// In en, this message translates to:
/// **'Got it'**
String get passwordlessRecoveryInfoGotIt;
/// No description provided for @passwordlessRecoveryMethod.
///
/// In en, this message translates to:
/// **'Second factor method'**
String get passwordlessRecoveryMethod;
/// No description provided for @passwordlessRecoveryMethodNoneDesc.
///
/// In en, this message translates to:
/// **'Without second-factor, your friends could collaborate to recover your account. Therefore, it is recommended to configure a second-factor.'**
String get passwordlessRecoveryMethodNoneDesc;
/// No description provided for @passwordlessRecoveryMethodPinHint.
///
/// In en, this message translates to:
/// **'Enter PIN'**
String get passwordlessRecoveryMethodPinHint;
/// No description provided for @passwordlessRecoveryMethodEmailHint.
///
/// In en, this message translates to:
/// **'Enter recovery email address'**
String get passwordlessRecoveryMethodEmailHint;
/// No description provided for @passwordlessRecoveryMethodEmailDesc.
///
/// In en, this message translates to:
/// **'Your email address is *never stored on the server* and is only sent to it in the event of a recovery.'**
String get passwordlessRecoveryMethodEmailDesc;
/// No description provided for @passwordlessRecoveryThresholdDesc.
///
/// In en, this message translates to:
/// **'To recover your account you need {count} of your selected trusted friends.'**
String passwordlessRecoveryThresholdDesc(num count);
/// No description provided for @passwordlessRecoveryThresholdTitle.
///
/// In en, this message translates to:
/// **'Required trusted friends for recovery'**
String get passwordlessRecoveryThresholdTitle;
/// No description provided for @passwordlessRecoverySelectFriendsNeeded.
///
/// In en, this message translates to:
/// **'Select friends ({count} more needed)'**
String passwordlessRecoverySelectFriendsNeeded(num count);
/// No description provided for @passwordlessRecoverySelectFriends.
///
/// In en, this message translates to:
/// **'Select trusted friends'**
String get passwordlessRecoverySelectFriends;
/// No description provided for @passwordlessRecoveryNoFriendsSelected.
///
/// In en, this message translates to:
/// **'No trusted friends selected yet'**
String get passwordlessRecoveryNoFriendsSelected;
/// No description provided for @passwordlessRecoverySubtitle.
///
/// In en, this message translates to:
/// **'Recover your identity without a password.'**
String get passwordlessRecoverySubtitle;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -2109,4 +2109,176 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get avatarCustomizeReset => 'Zurücksetzen'; String get avatarCustomizeReset => 'Zurücksetzen';
@override
String get passwordlessRecovery => 'Passwortloses Backup';
@override
String get passwordlessRecoveryNotConfigured => 'Nicht konfiguriert';
@override
String get passwordlessRecoveryTestPin => 'PIN testen';
@override
String get passwordlessRecoveryTestPinTitle => 'PIN testen';
@override
String get passwordlessRecoveryTestPinHint => 'Gib deine PIN ein';
@override
String get passwordlessRecoveryTestPinCorrect => 'PIN ist korrekt!';
@override
String get passwordlessRecoveryTestPinIncorrect => 'Falsche PIN.';
@override
String get passwordlessRecoveryTest => 'Testen';
@override
String get passwordlessRecoverySecondFactorNone => 'Keiner';
@override
String passwordlessRecoverySecondFactorEmailLabel(Object email) {
return 'E-Mail ($email)';
}
@override
String get passwordlessRecoverySecondFactorPin => 'PIN';
@override
String get passwordlessRecoverySecondFactorEmail => 'E-Mail';
@override
String get passwordlessRecoveryModify => 'Einstellungen bearbeiten';
@override
String get passwordlessRecoveryModifyDesc =>
'Aktualisiere deine Backupkonfiguration';
@override
String get passwordlessRecoverySecondFactor => 'Zweiter Faktor';
@override
String get passwordlessRecoveryNoFriendsFound =>
'Keine vertrauenswürdigen Freunde gefunden.';
@override
String get passwordlessRecoveryActiveFriends => 'Aktive Freunde';
@override
String get passwordlessRecoveryActiveFriendsDesc =>
'Diese vertrauenswürdigen Freunde nutzen twonly aktiv und können dir wahrscheinlich helfen, dein Konto wiederherzustellen.';
@override
String get passwordlessRecoveryInactiveFriends => 'Inaktive Freunde';
@override
String get passwordlessRecoveryInactiveFriendsDesc =>
'Diese Freunde haben entweder ihren Teil noch nicht erhalten oder nutzen twonly nicht mehr aktiv, was bedeutet, dass sie möglicherweise nicht helfen können.';
@override
String get passwordlessRecoveryNotEnoughFriends =>
'Nicht genügend Freunde ausgewählt. Du brauchst mindestens 3.';
@override
String get passwordlessRecoveryLoading => 'Wird geladen...';
@override
String get passwordlessRecoveryEnableSuccess =>
'Passwortloses Backup erfolgreich aktiviert!';
@override
String get passwordlessRecoveryEnableFailed =>
'Fehler beim Aktivieren des passwortlosen Backups.';
@override
String get passwordlessRecoveryNeedAtLeast3 =>
'Du brauchst mindestens 3 vertrauenswürdige Freunde.';
@override
String get passwordlessRecoveryInvalidPin => 'Ungültige PIN';
@override
String get passwordlessRecoveryEnterPin => 'Bitte gib eine PIN ein.';
@override
String get passwordlessRecoveryPinMinLength =>
'Die PIN muss mindestens 4 Ziffern lang sein.';
@override
String get passwordlessRecoveryEnterEmail =>
'Bitte gib eine E-Mail-Adresse ein.';
@override
String get passwordlessRecoveryEnableBtn => 'Passwortloses Backup aktivieren';
@override
String get passwordlessRecoveryModifyBtn => 'Passwortloses Backup bearbeiten';
@override
String passwordlessRecoveryStatusEnabled(num count) {
return 'Aktiviert • $count vertrauenswürdige Freunde';
}
@override
String get passwordlessRecoveryInfoHowItWorks => 'Wie es funktioniert';
@override
String get passwordlessRecoveryInfoHowItWorksDesc =>
'Da twonly ohne zentrale Benutzerkonten oder Telefonnummern funktioniert, um deine Privatsphäre zu maximieren, setzen wir auf einen dezentralen Wiederherstellungsmechanismus. Mit Shamir\'s Secret Sharing wird deine kryptografische Identität in unabhängige Teile aufgeteilt und an deine vertrauenswürdigen Freunde verteilt. Um den Zugriff wiederherzustellen, muss ein vorher festgelegter Schwellenwert dieser Freunde ihre Teile kombinieren.';
@override
String get passwordlessRecoveryInfoWhySecondFactor =>
'Warum ein zweiter Faktor?';
@override
String get passwordlessRecoveryInfoWhySecondFactorDesc =>
'Der zweite Faktor (E-Mail oder PIN) dient als wichtiger kryptografischer Schutz vor bösartiger Absprache. Wenn sich deine vertrauenswürdigen Freunde hinter deinem Rücken absprechen würden, könnten sie deine Identität ohne den Schlüssel für den zweiten Faktor trotzdem nicht entschlüsseln.';
@override
String get passwordlessRecoveryInfoGotIt => 'Verstanden';
@override
String get passwordlessRecoveryMethod => 'Methode für den zweiten Faktor';
@override
String get passwordlessRecoveryMethodNoneDesc =>
'Ohne zweiten Faktor könnten sich deine Freunde absprechen, um dein Konto wiederherzustellen. Daher wird empfohlen, einen zweiten Faktor zu konfigurieren.';
@override
String get passwordlessRecoveryMethodPinHint => 'PIN eingeben';
@override
String get passwordlessRecoveryMethodEmailHint =>
'Wiederherstellungs-E-Mail-Adresse eingeben';
@override
String get passwordlessRecoveryMethodEmailDesc =>
'Deine E-Mail-Adresse wird *niemals auf dem Server gespeichert* und nur im Falle einer Wiederherstellung an ihn gesendet.';
@override
String passwordlessRecoveryThresholdDesc(num count) {
return 'Um dein Konto wiederherzustellen, brauchst du $count deiner ausgewählten vertrauenswürdigen Freunde.';
}
@override
String get passwordlessRecoveryThresholdTitle =>
'Benötigte vertrauenswürdige Freunde für das Backup';
@override
String passwordlessRecoverySelectFriendsNeeded(num count) {
return 'Freunde auswählen ($count weitere benötigt)';
}
@override
String get passwordlessRecoverySelectFriends =>
'Vertrauenswürdige Freunde auswählen';
@override
String get passwordlessRecoveryNoFriendsSelected =>
'Noch keine vertrauenswürdigen Freunde ausgewählt';
@override
String get passwordlessRecoverySubtitle =>
'Stelle deine Identität ohne Passwort wieder her.';
} }

View file

@ -2094,4 +2094,172 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get avatarCustomizeReset => 'Reset'; String get avatarCustomizeReset => 'Reset';
@override
String get passwordlessRecovery => 'Passwordless Recovery';
@override
String get passwordlessRecoveryNotConfigured => 'Not configured';
@override
String get passwordlessRecoveryTestPin => 'Test PIN';
@override
String get passwordlessRecoveryTestPinTitle => 'Test PIN';
@override
String get passwordlessRecoveryTestPinHint => 'Enter your PIN';
@override
String get passwordlessRecoveryTestPinCorrect => 'PIN is correct!';
@override
String get passwordlessRecoveryTestPinIncorrect => 'Incorrect PIN.';
@override
String get passwordlessRecoveryTest => 'Test';
@override
String get passwordlessRecoverySecondFactorNone => 'None';
@override
String passwordlessRecoverySecondFactorEmailLabel(Object email) {
return 'Email ($email)';
}
@override
String get passwordlessRecoverySecondFactorPin => 'PIN';
@override
String get passwordlessRecoverySecondFactorEmail => 'Email';
@override
String get passwordlessRecoveryModify => 'Modify Recovery Settings';
@override
String get passwordlessRecoveryModifyDesc =>
'Update your recovery configuration';
@override
String get passwordlessRecoverySecondFactor => 'Second Factor';
@override
String get passwordlessRecoveryNoFriendsFound => 'No trusted friends found.';
@override
String get passwordlessRecoveryActiveFriends => 'Active Friends';
@override
String get passwordlessRecoveryActiveFriendsDesc =>
'These trusted friends are actively using twonly and can probably help you recover your account.';
@override
String get passwordlessRecoveryInactiveFriends => 'Inactive Friends';
@override
String get passwordlessRecoveryInactiveFriendsDesc =>
'These friends either have not yet received their share or do not use twonly actively anymore, which could mean that they maybe cannot help.';
@override
String get passwordlessRecoveryNotEnoughFriends =>
'Not enough friends selected. You need at least 3.';
@override
String get passwordlessRecoveryLoading => 'Loading...';
@override
String get passwordlessRecoveryEnableSuccess =>
'Passwordless recovery successfully enabled!';
@override
String get passwordlessRecoveryEnableFailed =>
'Failed to enable passwordless recovery.';
@override
String get passwordlessRecoveryNeedAtLeast3 =>
'You need at least 3 trusted friends.';
@override
String get passwordlessRecoveryInvalidPin => 'Invalid PIN';
@override
String get passwordlessRecoveryEnterPin => 'Please enter a PIN.';
@override
String get passwordlessRecoveryPinMinLength =>
'PIN must be at least 4 digits.';
@override
String get passwordlessRecoveryEnterEmail => 'Please enter an email address.';
@override
String get passwordlessRecoveryEnableBtn => 'Enable Passwordless Recovery';
@override
String get passwordlessRecoveryModifyBtn => 'Modify Passwordless Recovery';
@override
String passwordlessRecoveryStatusEnabled(num count) {
return 'Enabled • $count trusted friends';
}
@override
String get passwordlessRecoveryInfoHowItWorks => 'How it works';
@override
String get passwordlessRecoveryInfoHowItWorksDesc =>
'Because twonly operates without central user accounts or phone numbers to maximize privacy, we rely on a decentralized recovery mechanism. Using Shamir\'s Secret Sharing, your cryptographic identity is split into independent shares and distributed among your trusted friends. To restore access, a predefined threshold of these friends must combine their shares.';
@override
String get passwordlessRecoveryInfoWhySecondFactor => 'Why a Second Factor?';
@override
String get passwordlessRecoveryInfoWhySecondFactorDesc =>
'The second factor (Email or PIN) serves as a vital cryptographic safeguard against malicious collusion. If your trusted friends were to coordinate their shares behind your back, they still wouldn\'t be able to decrypt your identity without the second factor key.';
@override
String get passwordlessRecoveryInfoGotIt => 'Got it';
@override
String get passwordlessRecoveryMethod => 'Second factor method';
@override
String get passwordlessRecoveryMethodNoneDesc =>
'Without second-factor, your friends could collaborate to recover your account. Therefore, it is recommended to configure a second-factor.';
@override
String get passwordlessRecoveryMethodPinHint => 'Enter PIN';
@override
String get passwordlessRecoveryMethodEmailHint =>
'Enter recovery email address';
@override
String get passwordlessRecoveryMethodEmailDesc =>
'Your email address is *never stored on the server* and is only sent to it in the event of a recovery.';
@override
String passwordlessRecoveryThresholdDesc(num count) {
return 'To recover your account you need $count of your selected trusted friends.';
}
@override
String get passwordlessRecoveryThresholdTitle =>
'Required trusted friends for recovery';
@override
String passwordlessRecoverySelectFriendsNeeded(num count) {
return 'Select friends ($count more needed)';
}
@override
String get passwordlessRecoverySelectFriends => 'Select trusted friends';
@override
String get passwordlessRecoveryNoFriendsSelected =>
'No trusted friends selected yet';
@override
String get passwordlessRecoverySubtitle =>
'Recover your identity without a password.';
} }

@ -1 +1 @@
Subproject commit aa2ac144d3663f476f19ba28ced318b1a64c7a73 Subproject commit efb09c059d5885ec58e78189988ecbb52f00628b

View file

@ -34,6 +34,7 @@ import 'package:twonly/src/services/flame.service.dart';
import 'package:twonly/src/services/group.service.dart'; import 'package:twonly/src/services/group.service.dart';
import 'package:twonly/src/services/notifications/fcm.notifications.dart'; import 'package:twonly/src/services/notifications/fcm.notifications.dart';
import 'package:twonly/src/services/notifications/pushkeys.notifications.dart'; import 'package:twonly/src/services/notifications/pushkeys.notifications.dart';
import 'package:twonly/src/services/passwordless_recovery.service.dart';
import 'package:twonly/src/services/signal/identity.signal.dart'; import 'package:twonly/src/services/signal/identity.signal.dart';
import 'package:twonly/src/services/signal/protocol_state.signal.dart'; import 'package:twonly/src/services/signal/protocol_state.signal.dart';
import 'package:twonly/src/services/signal/utils.signal.dart'; import 'package:twonly/src/services/signal/utils.signal.dart';
@ -139,6 +140,7 @@ class ApiService {
unawaited(fetchGroupStatesForUnjoinedGroups()); unawaited(fetchGroupStatesForUnjoinedGroups());
unawaited(fetchMissingGroupPublicKey()); unawaited(fetchMissingGroupPublicKey());
unawaited(checkForDeletedUsernames()); unawaited(checkForDeletedUsernames());
unawaited(PasswordlessRecoveryService.performHeartbeat());
unawaited(UserDiscoveryService.checkForNewAnnouncedUsers()); unawaited(UserDiscoveryService.checkForNewAnnouncedUsers());

View file

@ -6,7 +6,8 @@ import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart' hide Hmac; import 'package:crypto/crypto.dart' hide Hmac;
import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart' import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart'
show FlutterChacha20; show FlutterChacha20;
import 'package:cryptography_plus/cryptography_plus.dart' show Hmac, SecretKey; import 'package:cryptography_plus/cryptography_plus.dart'
show Hmac, Mac, SecretBox, SecretKey;
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:fixnum/fixnum.dart'; import 'package:fixnum/fixnum.dart';
import 'package:twonly/core/bridge/wrapper.dart'; import 'package:twonly/core/bridge/wrapper.dart';
@ -84,7 +85,7 @@ class PasswordlessRecoveryService {
// - Server can only learn the email during recovery. Ensured as the server gets the NONCE and MAC to decrpyt during recovery. // - Server can only learn the email during recovery. Ensured as the server gets the NONCE and MAC to decrpyt during recovery.
// - Trusted-friends: Server key is send to the mail, they whould need access to the user's mail account. // - Trusted-friends: Server key is send to the mail, they whould need access to the user's mail account.
secondFactorEncryptedServerKeyKey = SecretKey( secondFactorEncryptedServerKeyKey = SecretKey(
Uint8List.fromList(utf8.encode(config.email!)), Uint8List.fromList(sha256.convert(utf8.encode(config.email!)).bytes),
); );
case SecondFactorType.pin: case SecondFactorType.pin:
@ -187,6 +188,8 @@ class PasswordlessRecoveryService {
return false; return false;
} }
await UserService.update((u) => u.passwordLessRecovery = config);
// 3.4. Store the shares in the contact's rows // 3.4. Store the shares in the contact's rows
for (final contactId in trustedFriendIds) { for (final contactId in trustedFriendIds) {
await twonlyDB.contactsDao.updateContact( await twonlyDB.contactsDao.updateContact(
@ -198,10 +201,49 @@ class PasswordlessRecoveryService {
), ),
); );
} }
unawaited(performHeartbeat());
// The passwordless is configured sucessfully. // The passwordless is configured sucessfully.
return true; return true;
} }
static Future<bool> testPin(String pin) async {
final config = userService.currentUser.passwordLessRecovery;
if (config?.pinSeed == null || config?.encryptedServerKey == null) {
return false;
}
try {
final pinProtectionKey = await Hmac.sha256().calculateMac(
Uint8List.fromList(utf8.encode(pin)),
secretKey: SecretKey(config!.pinSeed!),
);
final secondFactorEncryptedServerKeyKey = SecretKey(
pinProtectionKey.bytes,
);
final chacha20 = FlutterChacha20.poly1305Aead();
final secretBox = SecretBox(
config.encryptedServerKey!,
nonce: config.encryptedServerKeyNonce!,
mac: Mac(config.encryptedServerKeyMac!),
);
await chacha20.decrypt(
secretBox,
secretKey: secondFactorEncryptedServerKeyKey,
);
return true;
} catch (e) {
Log.error('Failed to test pin: $e');
return false;
}
}
static Future<void> performHeartbeat() async { static Future<void> performHeartbeat() async {
final config = userService.currentUser.passwordLessRecovery; final config = userService.currentUser.passwordLessRecovery;
@ -335,6 +377,7 @@ class PasswordlessRecoveryService {
), ),
); );
} }
unawaited(performHeartbeat());
} }
static Future<void> handlePasswordlessRecoveryHeartbeat( static Future<void> handlePasswordlessRecoveryHeartbeat(

View file

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.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_badge.comp.dart';
import 'package:twonly/src/visual/elements/reactive_tap_feedback.element.dart';
import 'package:twonly/src/visual/views/contact/contact.view.dart';
class ContactChip extends StatelessWidget {
const ContactChip({
required this.contact,
this.onTap,
super.key,
});
final Contact contact;
final void Function(int)? onTap;
@override
Widget build(BuildContext context) {
return ReactiveTapFeedback(
onTap: () {
if (onTap != null) {
onTap!(contact.userId);
} else {
context.navPush(
ContactView(
contact.userId,
key: ValueKey(contact.userId),
),
);
}
},
child: Chip(
avatar: AvatarIcon(
contactId: contact.userId,
fontSize: 10,
),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
getContactDisplayName(contact),
style: const TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis,
),
const SizedBox(width: 6),
VerificationBadgeComp(
contact: contact,
size: 12,
clickable: false,
),
if (onTap != null) ...[
const SizedBox(width: 15),
const FaIcon(
FontAwesomeIcons.xmark,
color: Colors.grey,
size: 12,
),
],
],
),
),
);
}
}

View file

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/reactive_tap_feedback.element.dart';
import 'package:twonly/src/visual/themes/light.dart'; import 'package:twonly/src/visual/themes/light.dart';
enum MyButtonVariant { enum MyButtonVariant {
@ -32,72 +32,10 @@ class MyButton extends StatefulWidget {
State<MyButton> createState() => _MyButtonState(); State<MyButton> createState() => _MyButtonState();
} }
class _MyButtonState extends State<MyButton> class _MyButtonState extends State<MyButton> {
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller =
AnimationController(
vsync: this,
lowerBound: double.negativeInfinity,
upperBound: double.infinity,
value: 0,
)..addListener(() {
setState(() {});
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onTapDown(TapDownDetails details) {
if (widget.onPressed != null || widget.onLongPress != null) {
_controller.animateTo(
1,
duration: const Duration(milliseconds: 60),
curve: Curves.easeOut,
);
}
}
void _onTapUp(TapUpDetails details) {
if (widget.onPressed != null || widget.onLongPress != null) {
_bounce();
}
}
void _onTapCancel() {
if (widget.onPressed != null || widget.onLongPress != null) {
_bounce();
}
}
void _bounce() {
const spring = SpringDescription(
mass: 1,
stiffness: 400,
damping: 15,
);
final simulation = SpringSimulation(
spring,
_controller.value,
0,
_controller.velocity,
);
_controller.animateWith(simulation);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 0 (unpressed) -> scale 1.0
// 1 (pressed) -> scale 0.98 (subtle bounce)
final scale = 1.0 - (_controller.value * 0.02);
final isEnabled = widget.onPressed != null || widget.onLongPress != null; final isEnabled = widget.onPressed != null || widget.onLongPress != null;
final isDark = isDarkMode(context); final isDark = isDarkMode(context);
final disabledBgColor = isDark final disabledBgColor = isDark
@ -265,19 +203,12 @@ class _MyButtonState extends State<MyButton>
child: widget.child, child: widget.child,
); );
return GestureDetector( return ReactiveTapFeedback(
behavior: HitTestBehavior.opaque,
onTapDown: isEnabled ? _onTapDown : null,
onTapUp: isEnabled ? _onTapUp : null,
onTapCancel: isEnabled ? _onTapCancel : null,
onTap: widget.onPressed, onTap: widget.onPressed,
onLongPress: widget.onLongPress, onLongPress: widget.onLongPress,
child: Transform.scale(
scale: scale,
child: AbsorbPointer( child: AbsorbPointer(
child: childButton, child: childButton,
), ),
),
); );
} }
} }

View file

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/reactive_tap_feedback.element.dart';
import 'package:twonly/src/visual/themes/light.dart'; import 'package:twonly/src/visual/themes/light.dart';
enum MyIconButtonVariant { enum MyIconButtonVariant {
@ -26,69 +26,10 @@ class MyIconButton extends StatefulWidget {
State<MyIconButton> createState() => _MyIconButtonState(); State<MyIconButton> createState() => _MyIconButtonState();
} }
class _MyIconButtonState extends State<MyIconButton> class _MyIconButtonState extends State<MyIconButton> {
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
lowerBound: double.negativeInfinity,
upperBound: double.infinity,
value: 0,
)..addListener(() {
setState(() {});
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onTapDown(TapDownDetails details) {
if (widget.onPressed != null || widget.onLongPress != null) {
_controller.animateTo(
1,
duration: const Duration(milliseconds: 60),
curve: Curves.easeOut,
);
}
}
void _onTapUp(TapUpDetails details) {
if (widget.onPressed != null || widget.onLongPress != null) {
_bounce();
}
}
void _onTapCancel() {
if (widget.onPressed != null || widget.onLongPress != null) {
_bounce();
}
}
void _bounce() {
const spring = SpringDescription(
mass: 1,
stiffness: 400,
damping: 15,
);
final simulation = SpringSimulation(
spring,
_controller.value,
0,
_controller.velocity,
);
_controller.animateWith(simulation);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scale = 1.0 - (_controller.value * 0.02);
final isEnabled = widget.onPressed != null || widget.onLongPress != null; final isEnabled = widget.onPressed != null || widget.onLongPress != null;
final isDark = isDarkMode(context); final isDark = isDarkMode(context);
final disabledBgColor = isDark final disabledBgColor = isDark
@ -127,19 +68,12 @@ class _MyIconButtonState extends State<MyIconButton>
child: widget.icon, child: widget.icon,
); );
return GestureDetector( return ReactiveTapFeedback(
behavior: HitTestBehavior.opaque,
onTapDown: isEnabled ? _onTapDown : null,
onTapUp: isEnabled ? _onTapUp : null,
onTapCancel: isEnabled ? _onTapCancel : null,
onTap: widget.onPressed, onTap: widget.onPressed,
onLongPress: widget.onLongPress, onLongPress: widget.onLongPress,
child: Transform.scale(
scale: scale,
child: AbsorbPointer( child: AbsorbPointer(
child: childButton, child: childButton,
), ),
),
); );
} }
} }

View file

@ -1,8 +1,8 @@
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/reactive_tap_feedback.element.dart';
class MyInput extends StatefulWidget { class MyInput extends StatefulWidget {
const MyInput({ const MyInput({
@ -38,65 +38,9 @@ class MyInput extends StatefulWidget {
State<MyInput> createState() => _MyInputState(); State<MyInput> createState() => _MyInputState();
} }
class _MyInputState extends State<MyInput> with SingleTickerProviderStateMixin { class _MyInputState extends State<MyInput> {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller =
AnimationController(
vsync: this,
lowerBound: double.negativeInfinity,
upperBound: double.infinity,
value: 0,
)..addListener(() {
setState(() {});
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onTapDown(TapDownDetails details) {
_controller.animateTo(
1,
duration: const Duration(milliseconds: 60),
curve: Curves.easeOut,
);
}
void _onTapUp(TapUpDetails details) {
_bounce();
}
void _onTapCancel() {
_bounce();
}
void _bounce() {
const spring = SpringDescription(
mass: 1,
stiffness: 400,
damping: 15,
);
final simulation = SpringSimulation(
spring,
_controller.value,
0,
_controller.velocity,
);
_controller.animateWith(simulation);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 0 (unpressed) -> scale 1.0
// 1 (pressed) -> scale 0.98 (subtle bounce)
final scale = 1.0 - (_controller.value * 0.02);
final isDark = isDarkMode(context); final isDark = isDarkMode(context);
final inputFillColor = isDark final inputFillColor = isDark
@ -115,13 +59,9 @@ class _MyInputState extends State<MyInput> with SingleTickerProviderStateMixin {
? Colors.white.withValues(alpha: 0.6) ? Colors.white.withValues(alpha: 0.6)
: Colors.black.withValues(alpha: 0.6); : Colors.black.withValues(alpha: 0.6);
return GestureDetector( return ReactiveTapFeedback(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
onTapDown: _onTapDown, alwaysAnimate: true,
onTapUp: _onTapUp,
onTapCancel: _onTapCancel,
child: Transform.scale(
scale: scale,
child: TextField( child: TextField(
controller: widget.controller, controller: widget.controller,
onChanged: widget.onChanged, onChanged: widget.onChanged,
@ -235,7 +175,6 @@ class _MyInputState extends State<MyInput> with SingleTickerProviderStateMixin {
: null, : null,
), ),
), ),
),
); );
} }
} }

View file

@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
class ReactiveTapFeedback extends StatefulWidget {
const ReactiveTapFeedback({
required this.child,
this.onTap,
this.onLongPress,
this.behavior = HitTestBehavior.opaque,
this.alwaysAnimate = false,
super.key,
});
final Widget child;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final HitTestBehavior behavior;
final bool alwaysAnimate;
@override
State<ReactiveTapFeedback> createState() => _ReactiveTapFeedbackState();
}
class _ReactiveTapFeedbackState extends State<ReactiveTapFeedback>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
lowerBound: double.negativeInfinity,
upperBound: double.infinity,
value: 0,
)..addListener(() {
setState(() {});
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
bool get _shouldAnimate =>
widget.alwaysAnimate ||
widget.onTap != null ||
widget.onLongPress != null;
void _onTapDown(TapDownDetails details) {
if (_shouldAnimate) {
_controller.animateTo(
1,
duration: const Duration(milliseconds: 60),
curve: Curves.easeOut,
);
}
}
void _onTapUp(TapUpDetails details) {
if (_shouldAnimate) {
_bounce();
}
}
void _onTapCancel() {
if (_shouldAnimate) {
_bounce();
}
}
void _bounce() {
const spring = SpringDescription(
mass: 1,
stiffness: 400,
damping: 15,
);
final simulation = SpringSimulation(
spring,
_controller.value,
0,
_controller.velocity,
);
_controller.animateWith(simulation);
}
@override
Widget build(BuildContext context) {
// 0 (unpressed) -> scale 1.0
// 1 (pressed) -> scale 0.98 (subtle bounce)
final scale = 1.0 - (_controller.value * 0.02);
return GestureDetector(
behavior: widget.behavior,
onTapDown: _shouldAnimate ? _onTapDown : null,
onTapUp: _shouldAnimate ? _onTapUp : null,
onTapCancel: _shouldAnimate ? _onTapCancel : null,
onTap: widget.onTap,
onLongPress: widget.onLongPress,
child: Transform.scale(
scale: scale,
child: widget.child,
),
);
}
}

View file

@ -14,6 +14,7 @@ import 'package:twonly/src/visual/components/flame_counter.comp.dart';
import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/context_menu/user.context_menu.dart'; import 'package:twonly/src/visual/context_menu/user.context_menu.dart';
import 'package:twonly/src/visual/decorations/input_text.decoration.dart'; import 'package:twonly/src/visual/decorations/input_text.decoration.dart';
import 'package:twonly/src/visual/elements/contact_chip.element.dart';
import 'package:twonly/src/visual/views/groups/group_create_select_group_name.view.dart'; import 'package:twonly/src/visual/views/groups/group_create_select_group_name.view.dart';
class GroupCreateSelectMembersView extends StatefulWidget { class GroupCreateSelectMembersView extends StatefulWidget {
@ -184,10 +185,12 @@ class _StartNewChatView extends State<GroupCreateSelectMembersView> {
return Wrap( return Wrap(
spacing: 8, spacing: 8,
children: selected.map((w) { children: selected.map((w) {
return _Chip( final contact = allContacts.firstWhere(
contact: allContacts.firstWhere(
(t) => t.userId == w, (t) => t.userId == w,
), );
return ContactChip(
key: ValueKey(contact.userId),
contact: contact,
onTap: toggleSelectedUser, onTap: toggleSelectedUser,
); );
}).toList(), }).toList(),
@ -257,41 +260,3 @@ class _StartNewChatView extends State<GroupCreateSelectMembersView> {
); );
} }
} }
class _Chip extends StatelessWidget {
const _Chip({
required this.contact,
required this.onTap,
});
final Contact contact;
final void Function(int) onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onTap(contact.userId),
child: Chip(
avatar: AvatarIcon(
contactId: contact.userId,
fontSize: 10,
),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
getContactDisplayName(contact),
style: const TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis,
),
const SizedBox(width: 15),
const FaIcon(
FontAwesomeIcons.xmark,
color: Colors.grey,
size: 12,
),
],
),
),
);
}
}

View file

@ -9,6 +9,7 @@ import 'package:twonly/src/model/json/backup.model.dart';
import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/services/backup.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/status.passwordless_recovery.comp.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart'; import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart';
class BackupView extends StatefulWidget { class BackupView extends StatefulWidget {
@ -103,6 +104,10 @@ class _BackupViewState extends State<BackupView> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (userService.currentUser.passwordLessRecovery != null)
const PasswordLessRecoveryStatus(),
if (userService.currentUser.isBackupEnabled) if (userService.currentUser.isBackupEnabled)
Column( Column(
children: [ children: [

View file

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
void showPasswordlessRecoveryInfoSheet(BuildContext context) {
// ignore: inference_failure_on_function_invocation
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) {
return const PasswordlessRecoveryInfoSheet();
},
);
}
class PasswordlessRecoveryInfoSheet extends StatelessWidget {
const PasswordlessRecoveryInfoSheet({super.key});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: context.color.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
Icons.info_outline_rounded,
color: context.color.primary,
size: 28,
),
const SizedBox(width: 12),
Text(
context.lang.passwordlessRecoveryInfoHowItWorks,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 24),
Text(
context.lang.passwordlessRecoveryInfoHowItWorksDesc,
),
const SizedBox(height: 16),
Text(
context.lang.passwordlessRecoveryInfoWhySecondFactor,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
context.lang.passwordlessRecoveryInfoWhySecondFactorDesc,
),
const SizedBox(height: 32),
MyButton(
onPressed: () => Navigator.pop(context),
child: Text(context.lang.passwordlessRecoveryInfoGotIt),
),
],
),
),
);
}
}

View file

@ -9,32 +9,38 @@ class _FactorOption {
const _FactorOption({ const _FactorOption({
required this.type, required this.type,
required this.icon, required this.icon,
required this.label,
}); });
final SecondFactorType type; final SecondFactorType type;
final IconData icon; final IconData icon;
final String label;
} }
const _options = [ const _options = [
_FactorOption( _FactorOption(
type: SecondFactorType.none, type: SecondFactorType.none,
icon: Icons.block_rounded, icon: Icons.block_rounded,
label: 'None',
), ),
_FactorOption( _FactorOption(
type: SecondFactorType.pin, type: SecondFactorType.pin,
icon: Icons.dialpad_rounded, icon: Icons.dialpad_rounded,
label: 'PIN',
), ),
_FactorOption( _FactorOption(
type: SecondFactorType.email, type: SecondFactorType.email,
icon: Icons.email_rounded, icon: Icons.email_rounded,
label: 'Email',
), ),
]; ];
String _getLabel(BuildContext context, SecondFactorType type) {
switch (type) {
case SecondFactorType.none:
return context.lang.passwordlessRecoverySecondFactorNone;
case SecondFactorType.pin:
return context.lang.passwordlessRecoverySecondFactorPin;
case SecondFactorType.email:
return context.lang.passwordlessRecoverySecondFactorEmail;
}
}
class SecondFactorPicker extends StatelessWidget { class SecondFactorPicker extends StatelessWidget {
const SecondFactorPicker({ const SecondFactorPicker({
required this.selected, required this.selected,
@ -55,8 +61,8 @@ class SecondFactorPicker extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
children: [ children: [
const Text( Text(
'Second factor method', context.lang.passwordlessRecoveryMethod,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@ -120,7 +126,7 @@ class SecondFactorPicker extends StatelessWidget {
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
option.label, _getLabel(context, option.type),
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: isSelected color: isSelected
@ -144,8 +150,8 @@ class SecondFactorPicker extends StatelessWidget {
child: Column( child: Column(
children: switch (selected) { children: switch (selected) {
SecondFactorType.none => [ SecondFactorType.none => [
const Text( Text(
'Without second-factor, your friends could collaborate to recover your account. Therefore, it is recommended to configure a second-factor.', context.lang.passwordlessRecoveryMethodNoneDesc,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
], ],
@ -153,7 +159,7 @@ class SecondFactorPicker extends StatelessWidget {
MyInput( MyInput(
key: const ValueKey('pin_input'), key: const ValueKey('pin_input'),
controller: pinController, controller: pinController,
hintText: 'Enter PIN', hintText: context.lang.passwordlessRecoveryMethodPinHint,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly], inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (_) => onInputChanged(), onChanged: (_) => onInputChanged(),
@ -163,7 +169,7 @@ class SecondFactorPicker extends StatelessWidget {
MyInput( MyInput(
key: const ValueKey('email_input'), key: const ValueKey('email_input'),
controller: emailController, controller: emailController,
hintText: 'Enter recovery email address', hintText: context.lang.passwordlessRecoveryMethodEmailHint,
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
onChanged: (_) => onInputChanged(), onChanged: (_) => onInputChanged(),
), ),
@ -172,7 +178,7 @@ class SecondFactorPicker extends StatelessWidget {
text: TextSpan( text: TextSpan(
children: formattedText( children: formattedText(
context, context,
'Your email address is *never stored on the server* and is only sent to it in the event of a recovery.', context.lang.passwordlessRecoveryMethodEmailDesc,
), ),
style: TextStyle( style: TextStyle(
color: context.color.onSurface, color: context.color.onSurface,

View file

@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/settings.passwordless_recovery.view.dart';
class PasswordLessRecoveryStatus extends StatelessWidget {
const PasswordLessRecoveryStatus({super.key});
@override
Widget build(BuildContext context) {
return StreamBuilder<List<Contact>>(
stream: (twonlyDB.select(
twonlyDB.contacts,
)..where((t) => t.recoveryIsTrustedFriend.equals(true))).watch(),
builder: (context, snapshot) {
final trustedFriendsCount = snapshot.data?.length ?? 0;
return Card(
elevation: 0,
color: context.color.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
context.navPush(const PasswordLessRecoverySettings());
},
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: context.color.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: FaIcon(
FontAwesomeIcons.shieldHeart,
color: context.color.primary,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.lang.passwordlessRecovery,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
context.lang.passwordlessRecoveryStatusEnabled(trustedFriendsCount),
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: context.color.onSurfaceVariant,
),
),
],
),
),
Icon(
Icons.chevron_right_rounded,
color: context.color.onSurfaceVariant,
),
],
),
),
),
);
},
);
}
}

View file

@ -44,7 +44,7 @@ class ThresholdPicker extends StatelessWidget {
if (options.length == 1) { if (options.length == 1) {
return Text( return Text(
'To recover your account you need ${options.first} of your selected trusted friends.', context.lang.passwordlessRecoveryThresholdDesc(options.first),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: context.color.onSurfaceVariant, color: context.color.onSurfaceVariant,
@ -56,7 +56,7 @@ class ThresholdPicker extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(
'Required trusted friends for recovery', context.lang.passwordlessRecoveryThresholdTitle,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelMedium?.copyWith( style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: context.color.onSurfaceVariant, color: context.color.onSurfaceVariant,

View file

@ -1,10 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.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/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/elements/contact_chip.element.dart';
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart';
class TrustedFriendsCard extends StatelessWidget { class TrustedFriendsCard extends StatelessWidget {
@ -44,10 +41,10 @@ class TrustedFriendsCard extends StatelessWidget {
spacing: 4, spacing: 4,
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
children: selectedContacts.map((contact) { children: selectedContacts.map((contact) {
return _ContactChip( return ContactChip(
key: ValueKey(contact.userId), key: ValueKey(contact.userId),
contact: contact, contact: contact,
onRemove: onRemoveContact, onTap: onRemoveContact,
); );
}).toList(), }).toList(),
), ),
@ -64,8 +61,10 @@ class TrustedFriendsCard extends StatelessWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
needsMoreFriends needsMoreFriends
? 'Select friends ($missingCount more needed)' ? context.lang.passwordlessRecoverySelectFriendsNeeded(
: 'Select trusted friends', missingCount,
)
: context.lang.passwordlessRecoverySelectFriends,
), ),
], ],
), ),
@ -87,7 +86,7 @@ class TrustedFriendsCard extends StatelessWidget {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'No trusted friends selected yet', context.lang.passwordlessRecoveryNoFriendsSelected,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: context.color.onSurfaceVariant, color: context.color.onSurfaceVariant,
@ -100,49 +99,3 @@ class TrustedFriendsCard extends StatelessWidget {
); );
} }
} }
class _ContactChip extends StatelessWidget {
const _ContactChip({
required this.contact,
required this.onRemove,
super.key,
});
final Contact contact;
final void Function(int) onRemove;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onRemove(contact.userId),
child: Chip(
avatar: AvatarIcon(
contactId: contact.userId,
fontSize: 10,
),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
getContactDisplayName(contact),
style: const TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis,
),
const SizedBox(width: 6),
VerificationBadgeComp(
contact: contact,
size: 12,
clickable: false,
),
const SizedBox(width: 15),
const FaIcon(
FontAwesomeIcons.xmark,
color: Colors.grey,
size: 12,
),
],
),
),
);
}
}

View file

@ -0,0 +1,329 @@
import 'package:flutter/material.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/passwordless_recovery.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/elements/contact_chip.element.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/elements/my_input.element.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/passwordless_recovery_info_sheet.comp.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart';
class PasswordLessRecoverySettings extends StatelessWidget {
const PasswordLessRecoverySettings({super.key});
Future<void> _testPin(
BuildContext context,
) async {
final pinController = TextEditingController();
final result = await showDialog<String>(
context: context,
builder: (context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
context.lang.passwordlessRecoveryTestPinTitle,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
MyInput(
controller: pinController,
obscureText: true,
hintText: context.lang.passwordlessRecoveryTestPinHint,
keyboardType: TextInputType.number,
autofocus: true,
),
const SizedBox(height: 32),
Row(
children: [
Expanded(
child: MyButton(
variant: MyButtonVariant.secondary,
onPressed: () => Navigator.pop(context),
child: Text(context.lang.cancel),
),
),
const SizedBox(width: 12),
Expanded(
child: MyButton(
onPressed: () =>
Navigator.pop(context, pinController.text),
child: Text(context.lang.passwordlessRecoveryTest),
),
),
],
),
],
),
),
);
},
);
if (result == null || result.isEmpty) return;
if (!context.mounted) return;
final success = await PasswordlessRecoveryService.testPin(result);
if (!context.mounted) return;
if (success) {
showSnackbar(
context,
context.lang.passwordlessRecoveryTestPinCorrect,
level: SnackbarLevel.success,
);
} else {
showSnackbar(context, context.lang.passwordlessRecoveryTestPinIncorrect);
}
}
@override
Widget build(BuildContext context) {
final config = userService.currentUser.passwordLessRecovery;
if (config == null) {
return Scaffold(
appBar: AppBar(title: Text(context.lang.passwordlessRecovery)),
body: Center(
child: Text(context.lang.passwordlessRecoveryNotConfigured),
),
);
}
var secondFactorLabel = context.lang.passwordlessRecoverySecondFactorNone;
var secondFactorIcon = Icons.lock_outline_rounded;
Widget? actionButton;
if (config.email != null) {
secondFactorLabel = context.lang
.passwordlessRecoverySecondFactorEmailLabel(config.email!);
secondFactorIcon = Icons.email_outlined;
} else if (config.pinSeed != null) {
secondFactorLabel = context.lang.passwordlessRecoverySecondFactorPin;
secondFactorIcon = Icons.pin_outlined;
actionButton = MyButton(
variant: MyButtonVariant.secondaryDense,
onPressed: () => _testPin(context),
child: Text(context.lang.passwordlessRecoveryTestPin),
);
}
return Scaffold(
appBar: AppBar(
title: Text(context.lang.passwordlessRecovery),
actions: [
IconButton(
onPressed: () => showPasswordlessRecoveryInfoSheet(context),
icon: const Icon(Icons.info_outline_rounded),
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
MyButton(
variant: MyButtonVariant.secondary,
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PasswordLessRecoverySetup(),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.manage_accounts_rounded, size: 18),
const SizedBox(width: 8),
Text(
context.lang.passwordlessRecoveryModify,
style: const TextStyle(fontSize: 14),
),
],
),
const SizedBox(height: 2),
Text(
context.lang.passwordlessRecoveryModifyDesc,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: context.color.onSurfaceVariant,
fontSize: 12,
),
textAlign: TextAlign.center,
),
],
),
),
const SizedBox(height: 24),
_buildInfoCard(
context,
title: context.lang.passwordlessRecoverySecondFactor,
value: secondFactorLabel,
icon: secondFactorIcon,
action: actionButton,
),
const SizedBox(height: 24),
StreamBuilder<List<Contact>>(
stream: (twonlyDB.select(
twonlyDB.contacts,
)..where((t) => t.recoveryIsTrustedFriend.equals(true))).watch(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
}
final friends = snapshot.data!;
if (friends.isEmpty) {
return Text(context.lang.passwordlessRecoveryNoFriendsFound);
}
final activeFriends = friends.where((c) {
final lastHeartbeat = c.recoveryLastHeartbeat;
if (lastHeartbeat == null) return false;
return DateTime.now().difference(lastHeartbeat).inDays <= 14;
}).toList();
final inactiveFriends = friends.where((c) {
final lastHeartbeat = c.recoveryLastHeartbeat;
if (lastHeartbeat == null) return true;
return DateTime.now().difference(lastHeartbeat).inDays > 14;
}).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (activeFriends.isNotEmpty) ...[
Text(
context.lang.passwordlessRecoveryActiveFriends,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
context.lang.passwordlessRecoveryActiveFriendsDesc,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: context.color.onSurfaceVariant,
),
),
const SizedBox(height: 16),
_buildFriendsGridCard(
context,
friends: activeFriends,
isActive: true,
),
const SizedBox(height: 24),
],
if (inactiveFriends.isNotEmpty) ...[
Text(
context.lang.passwordlessRecoveryInactiveFriends,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
context.lang.passwordlessRecoveryInactiveFriendsDesc,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: context.color.onSurfaceVariant,
),
),
const SizedBox(height: 16),
_buildFriendsGridCard(
context,
friends: inactiveFriends,
isActive: false,
),
],
],
);
},
),
],
),
);
}
Widget _buildInfoCard(
BuildContext context, {
required String title,
required String value,
required IconData icon,
Widget? action,
}) {
return Card(
elevation: 0,
color: context.color.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(icon, color: context.color.primary),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: context.color.onSurfaceVariant,
),
),
Text(
value,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
),
?action,
],
),
),
);
}
Widget _buildFriendsGridCard(
BuildContext context, {
required List<Contact> friends,
required bool isActive,
}) {
return SizedBox(
width: double.infinity,
child: Wrap(
spacing: 8,
runSpacing: 8,
children: friends.map((contact) {
return Opacity(
opacity: isActive ? 1.0 : 0.5,
child: ContactChip(
contact: contact,
),
);
}).toList(),
),
);
}
}

View file

@ -1,6 +1,7 @@
import 'dart:math'; import 'dart:math';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
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:twonly/locator.dart'; import 'package:twonly/locator.dart';
@ -9,6 +10,7 @@ import 'package:twonly/src/services/passwordless_recovery.service.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/snackbar.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/passwordless_recovery_info_sheet.comp.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/second_factor_picker.comp.dart'; import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/second_factor_picker.comp.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/threshold_picker.comp.dart'; import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/threshold_picker.comp.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/trusted_friends_card.comp.dart'; import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/trusted_friends_card.comp.dart';
@ -29,6 +31,7 @@ class _PasswordLessRecoverySetupState extends State<PasswordLessRecoverySetup> {
final _emailController = TextEditingController(); final _emailController = TextEditingController();
bool _isLoading = false; bool _isLoading = false;
int _threshold = 2; int _threshold = 2;
bool _isModify = false;
@override @override
void initState() { void initState() {
@ -53,7 +56,27 @@ class _PasswordLessRecoverySetupState extends State<PasswordLessRecoverySetup> {
if (!mounted) return; if (!mounted) return;
final verified = await _loadVerifiedContacts(); final verified = await _loadVerifiedContacts();
final config = userService.currentUser.passwordLessRecovery;
if (config != null) {
final selectedContacts = contacts
.where((c) => c.recoveryIsTrustedFriend)
.toList();
setState(() {
_isModify = true;
_selectedContacts = selectedContacts;
if (config.email != null) {
_secondFactor = SecondFactorType.email;
_emailController.text = config.email!;
} else if (config.pinSeed != null) {
_secondFactor = SecondFactorType.pin;
} else {
_secondFactor = SecondFactorType.none;
}
_threshold = max(2, (selectedContacts.length / 2).ceil());
});
} else {
contacts.sortBy((c) => c.mediaSendCounter); contacts.sortBy((c) => c.mediaSendCounter);
final verifiedContacts = contacts final verifiedContacts = contacts
.where( .where(
@ -72,6 +95,7 @@ class _PasswordLessRecoverySetupState extends State<PasswordLessRecoverySetup> {
); );
}); });
} }
}
@override @override
void dispose() { void dispose() {
@ -90,7 +114,7 @@ class _PasswordLessRecoverySetupState extends State<PasswordLessRecoverySetup> {
contactCount: _selectedContacts.length, contactCount: _selectedContacts.length,
); );
int get _minSelectedFriends => _validThreshold + 2; int get _minSelectedFriends => _validThreshold + (kReleaseMode ? 2 : 0);
// --- Validation --- // --- Validation ---
@ -161,7 +185,7 @@ class _PasswordLessRecoverySetupState extends State<PasswordLessRecoverySetup> {
if (success) { if (success) {
showSnackbar( showSnackbar(
context, context,
'Passwordless recovery enabled successfully!', context.lang.passwordlessRecoveryEnableSuccess,
level: SnackbarLevel.success, level: SnackbarLevel.success,
); );
Navigator.pop(context); Navigator.pop(context);
@ -178,14 +202,20 @@ class _PasswordLessRecoverySetupState extends State<PasswordLessRecoverySetup> {
onTap: () => FocusScope.of(context).unfocus(), onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Passwordless Recovery'), title: Text(context.lang.passwordlessRecovery),
actions: [
IconButton(
onPressed: () => showPasswordlessRecoveryInfoSheet(context),
icon: const Icon(Icons.info_outline_rounded),
),
],
), ),
body: SafeArea( body: SafeArea(
child: ListView( child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
children: [ children: [
Text( Text(
'Recover your identity without a password.', context.lang.passwordlessRecoverySubtitle,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: context.color.onSurfaceVariant, color: context.color.onSurfaceVariant,
@ -242,9 +272,15 @@ class _PasswordLessRecoverySetupState extends State<PasswordLessRecoverySetup> {
), ),
) )
else else
const Icon(Icons.check_circle_outline_rounded, size: 20), const FaIcon(
FontAwesomeIcons.shieldHeart,
),
const SizedBox(width: 8), const SizedBox(width: 8),
const Text('Enable Passwordless Recovery'), Text(
_isModify
? context.lang.passwordlessRecoveryModifyBtn
: context.lang.passwordlessRecoveryEnableBtn,
),
], ],
), ),
), ),

View file

@ -13,6 +13,7 @@ import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/flame_counter.comp.dart'; import 'package:twonly/src/visual/components/flame_counter.comp.dart';
import 'package:twonly/src/visual/context_menu/user.context_menu.dart'; import 'package:twonly/src/visual/context_menu/user.context_menu.dart';
import 'package:twonly/src/visual/decorations/input_text.decoration.dart'; import 'package:twonly/src/visual/decorations/input_text.decoration.dart';
import 'package:twonly/src/visual/elements/contact_chip.element.dart';
class SelectAdditionalUsers extends StatefulWidget { class SelectAdditionalUsers extends StatefulWidget {
const SelectAdditionalUsers({ const SelectAdditionalUsers({
@ -156,10 +157,12 @@ class _SelectAdditionalUsers extends State<SelectAdditionalUsers> {
return Wrap( return Wrap(
spacing: 8, spacing: 8,
children: selected.map((w) { children: selected.map((w) {
return _Chip( final contact = allContacts.firstWhere(
contact: allContacts.firstWhere(
(t) => t.userId == w, (t) => t.userId == w,
), );
return ContactChip(
key: ValueKey(contact.userId),
contact: contact,
onTap: toggleSelectedUser, onTap: toggleSelectedUser,
); );
}).toList(), }).toList(),
@ -229,41 +232,3 @@ class _SelectAdditionalUsers extends State<SelectAdditionalUsers> {
); );
} }
} }
class _Chip extends StatelessWidget {
const _Chip({
required this.contact,
required this.onTap,
});
final Contact contact;
final void Function(int) onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onTap(contact.userId),
child: Chip(
avatar: AvatarIcon(
contactId: contact.userId,
fontSize: 10,
),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
getContactDisplayName(contact),
style: const TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis,
),
const SizedBox(width: 15),
const FaIcon(
FontAwesomeIcons.xmark,
color: Colors.grey,
size: 12,
),
],
),
),
);
}
}

View file

@ -14,6 +14,7 @@ import 'package:twonly/src/visual/components/flame_counter.comp.dart';
import 'package:twonly/src/visual/components/verification_badge.comp.dart'; import 'package:twonly/src/visual/components/verification_badge.comp.dart';
import 'package:twonly/src/visual/context_menu/user.context_menu.dart'; import 'package:twonly/src/visual/context_menu/user.context_menu.dart';
import 'package:twonly/src/visual/decorations/input_text.decoration.dart'; import 'package:twonly/src/visual/decorations/input_text.decoration.dart';
import 'package:twonly/src/visual/elements/contact_chip.element.dart';
class SelectedContactView { class SelectedContactView {
const SelectedContactView({ const SelectedContactView({
@ -86,9 +87,9 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
Future<void> _loadVerifiedContacts() async { Future<void> _loadVerifiedContacts() async {
final kvs = await twonlyDB.select(twonlyDB.keyVerifications).get(); final kvs = await twonlyDB.select(twonlyDB.keyVerifications).get();
final urs = await (twonlyDB.select(twonlyDB.userDiscoveryUserRelations) final urs = await (twonlyDB.select(
..where((u) => u.publicKeyVerifiedTimestamp.isNotNull())) twonlyDB.userDiscoveryUserRelations,
.get(); )..where((u) => u.publicKeyVerifiedTimestamp.isNotNull())).get();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@ -223,7 +224,7 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
if (contact == null) { if (contact == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
return _Chip( return ContactChip(
key: ValueKey(contact.userId), key: ValueKey(contact.userId),
contact: contact, contact: contact,
onTap: toggleSelectedUser, onTap: toggleSelectedUser,
@ -242,7 +243,8 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
} }
final user = contacts[i]; final user = contacts[i];
final isVerified = verifiedUserIds.contains(user.userId); final isVerified = verifiedUserIds.contains(user.userId);
final isSelectionDisabled = widget.onlyVerified && !isVerified; final isSelectionDisabled =
widget.onlyVerified && !isVerified;
return UserContextMenu( return UserContextMenu(
key: ValueKey(user.userId), key: ValueKey(user.userId),
contact: user, contact: user,
@ -276,7 +278,9 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
? Text( ? Text(
context.lang.contactNotVerified, context.lang.contactNotVerified,
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.error, color: Theme.of(
context,
).colorScheme.error,
), ),
) )
: null), : null),
@ -322,48 +326,3 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
); );
} }
} }
class _Chip extends StatelessWidget {
const _Chip({
required this.contact,
required this.onTap,
super.key,
});
final Contact contact;
final void Function(int) onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onTap(contact.userId),
child: Chip(
avatar: AvatarIcon(
contactId: contact.userId,
fontSize: 10,
),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
getContactDisplayName(contact),
style: const TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis,
),
const SizedBox(width: 4),
VerificationBadgeComp(
contact: contact,
size: 12,
clickable: false,
),
const SizedBox(width: 15),
const FaIcon(
FontAwesomeIcons.xmark,
color: Colors.grey,
size: 12,
),
],
),
),
);
}
}

View file

@ -350,7 +350,7 @@ void main() {
final mockApi = apiService as MockApiService; final mockApi = apiService as MockApiService;
mockApi.sentTextMessages.clear(); mockApi.sentTextMessages.clear();
final baseTime = DateTime(2026, 6, 20, 12, 0); final baseTime = DateTime(2026, 6, 20, 12);
await withClock(Clock.fixed(baseTime), () async { await withClock(Clock.fixed(baseTime), () async {
await PasswordlessRecoveryService.performHeartbeat(); await PasswordlessRecoveryService.performHeartbeat();
@ -394,7 +394,7 @@ void main() {
final mockApi = apiService as MockApiService; final mockApi = apiService as MockApiService;
mockApi.sentTextMessages.clear(); mockApi.sentTextMessages.clear();
final baseTime = DateTime(2026, 6, 20, 12, 0); final baseTime = DateTime(2026, 6, 20, 12);
// Case 1: recoveryContactsLastHeartbeat is null -> should send // Case 1: recoveryContactsLastHeartbeat is null -> should send
await withClock(Clock.fixed(baseTime), () async { await withClock(Clock.fixed(baseTime), () async {
@ -449,7 +449,7 @@ void main() {
final mockApi = apiService as MockApiService; final mockApi = apiService as MockApiService;
mockApi.sentTextMessages.clear(); mockApi.sentTextMessages.clear();
final baseTime = DateTime(2026, 6, 20, 12, 0); final baseTime = DateTime(2026, 6, 20, 12);
await withClock(Clock.fixed(baseTime), () async { await withClock(Clock.fixed(baseTime), () async {
await PasswordlessRecoveryService.performHeartbeat(); await PasswordlessRecoveryService.performHeartbeat();
@ -563,7 +563,7 @@ void main() {
final msg = pb.EncryptedContent_PasswordLessRecoveryHeartbeat() final msg = pb.EncryptedContent_PasswordLessRecoveryHeartbeat()
..hash = hashBytes; ..hash = hashBytes;
final now = DateTime(2026, 6, 20, 12, 0); final now = DateTime(2026, 6, 20, 12);
await withClock(Clock.fixed(now), () async { await withClock(Clock.fixed(now), () async {
await PasswordlessRecoveryService.handlePasswordlessRecoveryHeartbeat( await PasswordlessRecoveryService.handlePasswordlessRecoveryHeartbeat(