mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-07-18 02:24:07 +00:00
modify passwordles recovery settings
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
This commit is contained in:
parent
4d943eca1c
commit
00b2b25252
24 changed files with 1579 additions and 552 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
68
lib/src/visual/elements/contact_chip.element.dart
Normal file
68
lib/src/visual/elements/contact_chip.element.dart
Normal 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
108
lib/src/visual/elements/reactive_tap_feedback.element.dart
Normal file
108
lib/src/visual/elements/reactive_tap_feedback.element.dart
Normal 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue