mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-07-18 01:14:07 +00:00
make the passwordless backup visible
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
aeea5c9c8b
commit
d45ffbf597
20 changed files with 626 additions and 286 deletions
|
|
@ -1,5 +1,10 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
- New: Passwordless Backup
|
||||||
|
- Fix: Performance issues
|
||||||
|
|
||||||
## 0.3.7
|
## 0.3.7
|
||||||
|
|
||||||
- Fix: Multiple UI issues
|
- Fix: Multiple UI issues
|
||||||
|
|
|
||||||
48
lib/app.dart
48
lib/app.dart
|
|
@ -1,7 +1,10 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:app_links/app_links.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'package:flutter_sharing_intent/model/sharing_file.dart' show SharedFile;
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:twonly/globals.dart';
|
import 'package:twonly/globals.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
|
|
@ -11,6 +14,8 @@ import 'package:twonly/src/localization/generated/app_localizations.dart';
|
||||||
import 'package:twonly/src/model/json/onboarding_state.model.dart';
|
import 'package:twonly/src/model/json/onboarding_state.model.dart';
|
||||||
import 'package:twonly/src/providers/routing.provider.dart';
|
import 'package:twonly/src/providers/routing.provider.dart';
|
||||||
import 'package:twonly/src/providers/settings.provider.dart';
|
import 'package:twonly/src/providers/settings.provider.dart';
|
||||||
|
import 'package:twonly/src/services/intent/links.intent.dart';
|
||||||
|
import 'package:twonly/src/services/passwordless_recovery.service.dart';
|
||||||
import 'package:twonly/src/utils/keyvalue.dart';
|
import 'package:twonly/src/utils/keyvalue.dart';
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
import 'package:twonly/src/utils/pow.dart';
|
import 'package:twonly/src/utils/pow.dart';
|
||||||
|
|
@ -143,6 +148,10 @@ class _AppMainWidgetState extends State<AppMainWidget> {
|
||||||
bool _wasLogged = true;
|
bool _wasLogged = true;
|
||||||
late int _initialPage;
|
late int _initialPage;
|
||||||
|
|
||||||
|
StreamSubscription<Uri>? _deepLinkSub;
|
||||||
|
StreamSubscription<List<SharedFile>>? _intentStreamSub;
|
||||||
|
StreamSubscription<String>? _emailTokenSub;
|
||||||
|
|
||||||
(Future<int>?, bool) _proofOfWork = (null, false);
|
(Future<int>?, bool) _proofOfWork = (null, false);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -151,6 +160,33 @@ class _AppMainWidgetState extends State<AppMainWidget> {
|
||||||
_initialPage = widget.initialPage;
|
_initialPage = widget.initialPage;
|
||||||
Log.info('AppWidgetState: initState started');
|
Log.info('AppWidgetState: initState started');
|
||||||
initAsync();
|
initAsync();
|
||||||
|
|
||||||
|
void handleShareLink(Uri uri) {
|
||||||
|
routerProvider.go(Routes.home);
|
||||||
|
HomeViewState.streamHomeViewPageIndex.add(1);
|
||||||
|
HomeViewState.streamSharedLink.add(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to all events (initial link and further)
|
||||||
|
_deepLinkSub = AppLinks().uriLinkStream.listen((uri) async {
|
||||||
|
if (!mounted) return;
|
||||||
|
Log.info('Got link via app links: ${uri.scheme}');
|
||||||
|
if (!await handleIntentUrl(context, uri)) {
|
||||||
|
if (uri.scheme.startsWith('http')) {
|
||||||
|
handleShareLink(uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_intentStreamSub = initIntentStreams(
|
||||||
|
context,
|
||||||
|
handleShareLink,
|
||||||
|
);
|
||||||
|
|
||||||
|
_emailTokenSub = PasswordlessRecoveryService.onEmailTokenReceived.stream
|
||||||
|
.listen((token) {
|
||||||
|
routerProvider.go(Routes.recoverPasswordless, extra: token);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initAsync() async {
|
Future<void> initAsync() async {
|
||||||
|
|
@ -197,10 +233,6 @@ class _AppMainWidgetState extends State<AppMainWidget> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// await PasswordlessRecoveryService.handleRecoveryLink(
|
|
||||||
// 'https://me.twonly.eu/r/#7fdb8f08-0927-4e44-8761-038993414e48/1p7SKEzpxE3wSW9FQw60EUI4OpSW2U4EskdXLw8xg48',
|
|
||||||
// );
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoaded = true;
|
_isLoaded = true;
|
||||||
});
|
});
|
||||||
|
|
@ -264,4 +296,12 @@ class _AppMainWidgetState extends State<AppMainWidget> {
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_deepLinkSub?.cancel();
|
||||||
|
_intentStreamSub?.cancel();
|
||||||
|
_emailTokenSub?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3891,7 +3891,7 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @passwordlessRecoveryInfoHowItWorksDesc.
|
/// No description provided for @passwordlessRecoveryInfoHowItWorksDesc.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// 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.'**
|
/// **'twonly operates without phone numbers, which is why 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 number of these friends must combine their shares.'**
|
||||||
String get passwordlessRecoveryInfoHowItWorksDesc;
|
String get passwordlessRecoveryInfoHowItWorksDesc;
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryInfoWhySecondFactor.
|
/// No description provided for @passwordlessRecoveryInfoWhySecondFactor.
|
||||||
|
|
@ -3903,7 +3903,7 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @passwordlessRecoveryInfoWhySecondFactorDesc.
|
/// No description provided for @passwordlessRecoveryInfoWhySecondFactorDesc.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// 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.'**
|
/// **'The second factor (Email or PIN) provides important additional protection. Even if your friends were to secretly team up, they still couldn\'t access your account without this second factor.'**
|
||||||
String get passwordlessRecoveryInfoWhySecondFactorDesc;
|
String get passwordlessRecoveryInfoWhySecondFactorDesc;
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryInfoGotIt.
|
/// No description provided for @passwordlessRecoveryInfoGotIt.
|
||||||
|
|
@ -3966,6 +3966,18 @@ abstract class AppLocalizations {
|
||||||
/// **'Select trusted friends'**
|
/// **'Select trusted friends'**
|
||||||
String get passwordlessRecoverySelectFriends;
|
String get passwordlessRecoverySelectFriends;
|
||||||
|
|
||||||
|
/// No description provided for @passwordlessRecoveryTrustedFriends.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Trusted Friends'**
|
||||||
|
String get passwordlessRecoveryTrustedFriends;
|
||||||
|
|
||||||
|
/// No description provided for @passwordlessRecoveryDoneBtn.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Done ({count})'**
|
||||||
|
String passwordlessRecoveryDoneBtn(num count);
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryNoFriendsSelected.
|
/// No description provided for @passwordlessRecoveryNoFriendsSelected.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -4032,10 +4044,16 @@ abstract class AppLocalizations {
|
||||||
/// **'No recovery share stored for this contact.'**
|
/// **'No recovery share stored for this contact.'**
|
||||||
String get passwordlessRecoveryNoShareStored;
|
String get passwordlessRecoveryNoShareStored;
|
||||||
|
|
||||||
|
/// No description provided for @passwordlessRecoveryEmailSent.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Recovery email sent!'**
|
||||||
|
String get passwordlessRecoveryEmailSent;
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryShareSent.
|
/// No description provided for @passwordlessRecoveryShareSent.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Recovery share successfully sent!'**
|
/// **'Recovery share sent!'**
|
||||||
String get passwordlessRecoveryShareSent;
|
String get passwordlessRecoveryShareSent;
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryNetworkError.
|
/// No description provided for @passwordlessRecoveryNetworkError.
|
||||||
|
|
@ -4083,19 +4101,19 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @passwordlessRecoveryCantHelpHim.
|
/// No description provided for @passwordlessRecoveryCantHelpHim.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'You can\'t help him'**
|
/// **'You can\'t help this person'**
|
||||||
String get passwordlessRecoveryCantHelpHim;
|
String get passwordlessRecoveryCantHelpHim;
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryDoesAskedYou.
|
/// No description provided for @passwordlessRecoveryDoesAskedYou.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Does {username} has asked you?'**
|
/// **'Has {username} asked you?'**
|
||||||
String passwordlessRecoveryDoesAskedYou(Object username);
|
String passwordlessRecoveryDoesAskedYou(Object username);
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryVerifySourceDesc.
|
/// No description provided for @passwordlessRecoveryVerifySourceDesc.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Please ensure that you actualy received the link/qr code from your friend! If you are unsure, please verify again and then click again on the link or come back.'**
|
/// **'Please make sure this request really came from this person. If in doubt, ask them via another channel!'**
|
||||||
String get passwordlessRecoveryVerifySourceDesc;
|
String get passwordlessRecoveryVerifySourceDesc;
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryNo.
|
/// No description provided for @passwordlessRecoveryNo.
|
||||||
|
|
@ -4125,7 +4143,7 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @recoverPasswordlessSharesReceived.
|
/// No description provided for @recoverPasswordlessSharesReceived.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'{received} of {threshold} friends have shared'**
|
/// **'{received} out of {threshold} friends have helped'**
|
||||||
String recoverPasswordlessSharesReceived(Object received, Object threshold);
|
String recoverPasswordlessSharesReceived(Object received, Object threshold);
|
||||||
|
|
||||||
/// No description provided for @recoverPasswordlessRecoverNowBtn.
|
/// No description provided for @recoverPasswordlessRecoverNowBtn.
|
||||||
|
|
@ -4133,6 +4151,24 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Recover now'**
|
/// **'Recover now'**
|
||||||
String get recoverPasswordlessRecoverNowBtn;
|
String get recoverPasswordlessRecoverNowBtn;
|
||||||
|
|
||||||
|
/// No description provided for @missingRecoveryContactsCardTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Recovery Contacts'**
|
||||||
|
String get missingRecoveryContactsCardTitle;
|
||||||
|
|
||||||
|
/// No description provided for @missingRecoveryContactsCardDesc.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Select Recovery Contacts to help you recover your account without requiring a password.'**
|
||||||
|
String get missingRecoveryContactsCardDesc;
|
||||||
|
|
||||||
|
/// No description provided for @missingRecoveryContactsCardAction.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Select Contacts'**
|
||||||
|
String get missingRecoveryContactsCardAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -2242,7 +2242,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryInfoHowItWorksDesc =>
|
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 Freunde verteilt. Um den Zugriff wiederherzustellen, muss ein vorher festgelegter Schwellenwert dieser Freunde ihre Teile kombinieren.';
|
'twonly funktioniert ohne Telefonnummern, weshalb wir auf einen dezentralen Wiederherstellungsmechanismus setzen. Mit Shamir\'s Secret Sharing wird deine kryptografische Identität in unabhängige Teile aufgeteilt und an deine Freunde verteilt. Um den Zugriff wiederherzustellen, muss eine vorher festgelegte Anzahl dieser Freunde ihre Teile kombinieren.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryInfoWhySecondFactor =>
|
String get passwordlessRecoveryInfoWhySecondFactor =>
|
||||||
|
|
@ -2250,7 +2250,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryInfoWhySecondFactorDesc =>
|
String get passwordlessRecoveryInfoWhySecondFactorDesc =>
|
||||||
'Der zweite Faktor (E-Mail oder PIN) dient als wichtiger kryptografischer Schutz vor bösartiger Absprache. Wenn sich deine 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.';
|
'Der zweite Faktor (E-Mail oder PIN) ist ein wichtiger zusätzlicher Schutz. Selbst wenn sich deine Freunde heimlich zusammentun, können sie ohne diesen zweiten Faktor nicht auf dein Konto zugreifen.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryInfoGotIt => 'Verstanden';
|
String get passwordlessRecoveryInfoGotIt => 'Verstanden';
|
||||||
|
|
@ -2290,6 +2290,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoverySelectFriends => 'Freunde auswählen';
|
String get passwordlessRecoverySelectFriends => 'Freunde auswählen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordlessRecoveryTrustedFriends => 'Vertrauenswürdige Freunde';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String passwordlessRecoveryDoneBtn(num count) {
|
||||||
|
return 'Fertig ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryNoFriendsSelected =>
|
String get passwordlessRecoveryNoFriendsSelected =>
|
||||||
'Noch keine Freunde ausgewählt';
|
'Noch keine Freunde ausgewählt';
|
||||||
|
|
@ -2332,9 +2340,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get passwordlessRecoveryNoShareStored =>
|
String get passwordlessRecoveryNoShareStored =>
|
||||||
'Kein Wiederherstellungs-Teil für diesen Kontakt gespeichert.';
|
'Kein Wiederherstellungs-Teil für diesen Kontakt gespeichert.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordlessRecoveryEmailSent =>
|
||||||
|
'Wiederherstellungs-E-Mail gesendet!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryShareSent =>
|
String get passwordlessRecoveryShareSent =>
|
||||||
'Wiederherstellungs-Teil erfolgreich gesendet!';
|
'Wiederherstellungs-Teil gesendet!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryNetworkError =>
|
String get passwordlessRecoveryNetworkError =>
|
||||||
|
|
@ -2361,7 +2373,8 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get passwordlessRecoveryNoContactsFound => 'Keine Kontakte gefunden';
|
String get passwordlessRecoveryNoContactsFound => 'Keine Kontakte gefunden';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryCantHelpHim => 'Du kannst ihm nicht helfen';
|
String get passwordlessRecoveryCantHelpHim =>
|
||||||
|
'Du kannst dieser Person nicht helfen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String passwordlessRecoveryDoesAskedYou(Object username) {
|
String passwordlessRecoveryDoesAskedYou(Object username) {
|
||||||
|
|
@ -2370,7 +2383,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryVerifySourceDesc =>
|
String get passwordlessRecoveryVerifySourceDesc =>
|
||||||
'Bitte stelle sicher, dass du den Link/QR-Code tatsächlich von deinem Freund erhalten hast! Wenn du dir unsicher bist, verifiziere dies bitte erneut und klicke dann noch einmal auf den Link oder kehre zurück.';
|
'Bitte stelle sicher, dass diese Anfrage wirklich von dieser Person stammt. Im Zweifel frage kurz über einen anderen Kanal nach!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryNo => 'Nein';
|
String get passwordlessRecoveryNo => 'Nein';
|
||||||
|
|
@ -2389,9 +2402,19 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String recoverPasswordlessSharesReceived(Object received, Object threshold) {
|
String recoverPasswordlessSharesReceived(Object received, Object threshold) {
|
||||||
return '$received von $threshold Freunden haben geteilt';
|
return '$received von $threshold Freunden haben geholfen';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recoverPasswordlessRecoverNowBtn => 'Jetzt wiederherstellen';
|
String get recoverPasswordlessRecoverNowBtn => 'Jetzt wiederherstellen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get missingRecoveryContactsCardTitle => 'Vertrauenswürdige Kontakte';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get missingRecoveryContactsCardDesc =>
|
||||||
|
'Wähle Kontakte für die Kontowiederherstellung aus, die dir helfen, dein Konto ohne Passwort wiederherzustellen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get missingRecoveryContactsCardAction => 'Kontakte auswählen';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2226,14 +2226,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryInfoHowItWorksDesc =>
|
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.';
|
'twonly operates without phone numbers, which is why 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 number of these friends must combine their shares.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryInfoWhySecondFactor => 'Why a Second Factor?';
|
String get passwordlessRecoveryInfoWhySecondFactor => 'Why a Second Factor?';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryInfoWhySecondFactorDesc =>
|
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.';
|
'The second factor (Email or PIN) provides important additional protection. Even if your friends were to secretly team up, they still couldn\'t access your account without this second factor.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryInfoGotIt => 'Got it';
|
String get passwordlessRecoveryInfoGotIt => 'Got it';
|
||||||
|
|
@ -2273,6 +2273,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoverySelectFriends => 'Select trusted friends';
|
String get passwordlessRecoverySelectFriends => 'Select trusted friends';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordlessRecoveryTrustedFriends => 'Trusted Friends';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String passwordlessRecoveryDoneBtn(num count) {
|
||||||
|
return 'Done ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryNoFriendsSelected =>
|
String get passwordlessRecoveryNoFriendsSelected =>
|
||||||
'No trusted friends selected yet';
|
'No trusted friends selected yet';
|
||||||
|
|
@ -2314,8 +2322,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
'No recovery share stored for this contact.';
|
'No recovery share stored for this contact.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryShareSent =>
|
String get passwordlessRecoveryEmailSent => 'Recovery email sent!';
|
||||||
'Recovery share successfully sent!';
|
|
||||||
|
@override
|
||||||
|
String get passwordlessRecoveryShareSent => 'Recovery share sent!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryNetworkError =>
|
String get passwordlessRecoveryNetworkError =>
|
||||||
|
|
@ -2342,16 +2352,16 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get passwordlessRecoveryNoContactsFound => 'No contacts found';
|
String get passwordlessRecoveryNoContactsFound => 'No contacts found';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryCantHelpHim => 'You can\'t help him';
|
String get passwordlessRecoveryCantHelpHim => 'You can\'t help this person';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String passwordlessRecoveryDoesAskedYou(Object username) {
|
String passwordlessRecoveryDoesAskedYou(Object username) {
|
||||||
return 'Does $username has asked you?';
|
return 'Has $username asked you?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryVerifySourceDesc =>
|
String get passwordlessRecoveryVerifySourceDesc =>
|
||||||
'Please ensure that you actualy received the link/qr code from your friend! If you are unsure, please verify again and then click again on the link or come back.';
|
'Please make sure this request really came from this person. If in doubt, ask them via another channel!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryNo => 'No';
|
String get passwordlessRecoveryNo => 'No';
|
||||||
|
|
@ -2370,9 +2380,19 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String recoverPasswordlessSharesReceived(Object received, Object threshold) {
|
String recoverPasswordlessSharesReceived(Object received, Object threshold) {
|
||||||
return '$received of $threshold friends have shared';
|
return '$received out of $threshold friends have helped';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recoverPasswordlessRecoverNowBtn => 'Recover now';
|
String get recoverPasswordlessRecoverNowBtn => 'Recover now';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get missingRecoveryContactsCardTitle => 'Recovery Contacts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get missingRecoveryContactsCardDesc =>
|
||||||
|
'Select Recovery Contacts to help you recover your account without requiring a password.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get missingRecoveryContactsCardAction => 'Select Contacts';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit b7d703c82a5ac0b6776847cb4eb6d637620f6585
|
Subproject commit 2a54bd8e7f2021c5159c92b2e1dff8c8ed4effdc
|
||||||
|
|
@ -32,8 +32,7 @@ Future<bool> handleIntentUrl(BuildContext context, Uri uri) async {
|
||||||
// Check if this is the QR code link which was
|
// Check if this is the QR code link which was
|
||||||
// therefore scanned with the system camera
|
// therefore scanned with the system camera
|
||||||
|
|
||||||
if (kDebugMode &&
|
if (uri.toString().startsWith(PasswordlessRecoveryService.linkPrefix)) {
|
||||||
uri.toString().startsWith(PasswordlessRecoveryService.linkPrefix)) {
|
|
||||||
await PasswordlessRecoveryService.handleRecoveryLink(uri.toString());
|
await PasswordlessRecoveryService.handleRecoveryLink(uri.toString());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,10 @@ import 'package:cryptography_plus/cryptography_plus.dart'
|
||||||
show Hmac, Mac, SecretBox, SecretKey, Xchacha20;
|
show Hmac, Mac, SecretBox, SecretKey, Xchacha20;
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:fixnum/fixnum.dart';
|
import 'package:fixnum/fixnum.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:twonly/core/bridge/wrapper.dart';
|
import 'package:twonly/core/bridge/wrapper.dart';
|
||||||
import 'package:twonly/core/bridge/wrapper/key_manager.dart';
|
import 'package:twonly/core/bridge/wrapper/key_manager.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
import 'package:twonly/src/constants/keyvalue.keys.dart';
|
import 'package:twonly/src/constants/keyvalue.keys.dart';
|
||||||
import 'package:twonly/src/constants/routes.keys.dart';
|
|
||||||
import 'package:twonly/src/database/daos/contacts.dao.dart'
|
import 'package:twonly/src/database/daos/contacts.dao.dart'
|
||||||
show getContactDisplayName;
|
show getContactDisplayName;
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
|
|
@ -51,10 +49,6 @@ class PasswordlessRecoveryService {
|
||||||
if (parts.length < 2) {
|
if (parts.length < 2) {
|
||||||
if (fragment.isNotEmpty) {
|
if (fragment.isNotEmpty) {
|
||||||
onEmailTokenReceived.add(fragment);
|
onEmailTokenReceived.add(fragment);
|
||||||
final context = rootNavigatorKey.currentContext;
|
|
||||||
if (context != null && context.mounted) {
|
|
||||||
unawaited(context.push(Routes.recoverPasswordless, extra: fragment));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,49 +10,57 @@ import 'package:twonly/src/visual/views/contact/contact.view.dart';
|
||||||
|
|
||||||
class ContactChip extends StatelessWidget {
|
class ContactChip extends StatelessWidget {
|
||||||
const ContactChip({
|
const ContactChip({
|
||||||
required this.contact,
|
this.contact,
|
||||||
|
this.username,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
}) : assert(
|
||||||
|
contact != null || username != null,
|
||||||
|
'Either contact or username must be provided',
|
||||||
|
);
|
||||||
|
|
||||||
final Contact contact;
|
final Contact? contact;
|
||||||
|
final String? username;
|
||||||
final void Function(int)? onTap;
|
final void Function(int)? onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ReactiveTapFeedback(
|
return ReactiveTapFeedback(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
if (contact == null) return;
|
||||||
if (onTap != null) {
|
if (onTap != null) {
|
||||||
onTap!(contact.userId);
|
onTap!(contact!.userId);
|
||||||
} else {
|
} else {
|
||||||
context.navPush(
|
context.navPush(
|
||||||
ContactView(
|
ContactView(
|
||||||
contact.userId,
|
contact!.userId,
|
||||||
key: ValueKey(contact.userId),
|
key: ValueKey(contact!.userId),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Chip(
|
child: Chip(
|
||||||
avatar: AvatarIcon(
|
avatar: AvatarIcon(
|
||||||
contactId: contact.userId,
|
contactId: contact?.userId,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
),
|
),
|
||||||
label: Row(
|
label: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
getContactDisplayName(contact),
|
contact != null ? getContactDisplayName(contact!) : username!,
|
||||||
style: const TextStyle(fontSize: 14),
|
style: const TextStyle(fontSize: 14),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
if (contact != null) ...[
|
||||||
VerificationBadgeComp(
|
const SizedBox(width: 6),
|
||||||
contact: contact,
|
VerificationBadgeComp(
|
||||||
size: 12,
|
contact: contact,
|
||||||
clickable: false,
|
size: 12,
|
||||||
),
|
clickable: false,
|
||||||
if (onTap != null) ...[
|
),
|
||||||
|
],
|
||||||
|
if (onTap != null && contact != null) ...[
|
||||||
const SizedBox(width: 15),
|
const SizedBox(width: 15),
|
||||||
const FaIcon(
|
const FaIcon(
|
||||||
FontAwesomeIcons.xmark,
|
FontAwesomeIcons.xmark,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import 'package:twonly/src/visual/views/chats/chat_list_components/feedback_btn.
|
||||||
import 'package:twonly/src/visual/views/chats/chat_list_components/group_list_item.comp.dart';
|
import 'package:twonly/src/visual/views/chats/chat_list_components/group_list_item.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/onboarding/setup/components/finish_setup.comp.dart';
|
import 'package:twonly/src/visual/views/onboarding/setup/components/finish_setup.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/settings/backup/components/missing_backup_setup.comp.dart';
|
import 'package:twonly/src/visual/views/settings/backup/components/missing_backup_setup.comp.dart';
|
||||||
|
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/missing_recovery_contacts.comp.dart';
|
||||||
|
|
||||||
class ChatListView extends StatefulWidget {
|
class ChatListView extends StatefulWidget {
|
||||||
const ChatListView({super.key});
|
const ChatListView({super.key});
|
||||||
|
|
@ -236,6 +237,7 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
|
||||||
children: [
|
children: [
|
||||||
const FinishSetupComp(),
|
const FinishSetupComp(),
|
||||||
const MissingBackupComp(),
|
const MissingBackupComp(),
|
||||||
|
const MissingRecoveryContactsComp(),
|
||||||
if (_loading)
|
if (_loading)
|
||||||
const Expanded(
|
const Expanded(
|
||||||
child: SizedBox.shrink(),
|
child: SizedBox.shrink(),
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,15 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:app_links/app_links.dart';
|
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:flutter_sharing_intent/model/sharing_file.dart';
|
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:twonly/globals.dart';
|
import 'package:twonly/globals.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
import 'package:twonly/src/constants/routes.keys.dart';
|
import 'package:twonly/src/constants/routes.keys.dart';
|
||||||
import 'package:twonly/src/providers/routing.provider.dart';
|
import 'package:twonly/src/providers/routing.provider.dart';
|
||||||
import 'package:twonly/src/services/intent/links.intent.dart';
|
|
||||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||||
import 'package:twonly/src/services/notifications/setup.notifications.dart';
|
import 'package:twonly/src/services/notifications/setup.notifications.dart';
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
|
|
@ -46,13 +43,13 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
||||||
final MainCameraController _mainCameraController = MainCameraController();
|
final MainCameraController _mainCameraController = MainCameraController();
|
||||||
late final PageController _homeViewPageController;
|
late final PageController _homeViewPageController;
|
||||||
|
|
||||||
StreamSubscription<List<SharedFile>>? _intentStreamSub;
|
StreamSubscription<Uri>? _sharedLinkSub;
|
||||||
StreamSubscription<Uri>? _deepLinkSub;
|
|
||||||
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
|
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
|
||||||
StreamSubscription<int>? _homeViewPageIndexSub;
|
StreamSubscription<int>? _homeViewPageIndexSub;
|
||||||
StreamSubscription<NotificationResponse>? _selectNotificationSub;
|
StreamSubscription<NotificationResponse>? _selectNotificationSub;
|
||||||
|
|
||||||
static final streamHomeViewPageIndex = StreamController<int>.broadcast();
|
static final streamHomeViewPageIndex = StreamController<int>.broadcast();
|
||||||
|
static final streamSharedLink = StreamController<Uri>.broadcast();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -113,26 +110,8 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
||||||
|
|
||||||
unawaited(_initAsync());
|
unawaited(_initAsync());
|
||||||
|
|
||||||
void handleShareLink(Uri uri) {
|
_sharedLinkSub = streamSharedLink.stream.listen(
|
||||||
routerProvider.go(Routes.home);
|
_mainCameraController.setSharedLinkForPreview,
|
||||||
streamHomeViewPageIndex.add(1);
|
|
||||||
_mainCameraController.setSharedLinkForPreview(uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to all events (initial link and further)
|
|
||||||
_deepLinkSub = AppLinks().uriLinkStream.listen((uri) async {
|
|
||||||
if (!mounted) return;
|
|
||||||
Log.info('Got link via app links: ${uri.scheme}');
|
|
||||||
if (!await handleIntentUrl(context, uri)) {
|
|
||||||
if (uri.scheme.startsWith('http')) {
|
|
||||||
handleShareLink(uri);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
_intentStreamSub = initIntentStreams(
|
|
||||||
context,
|
|
||||||
handleShareLink,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
|
@ -207,8 +186,7 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
||||||
_disableCameraTimer?.cancel();
|
_disableCameraTimer?.cancel();
|
||||||
_mainCameraController.setState = null;
|
_mainCameraController.setState = null;
|
||||||
_mainCameraController.closeCamera();
|
_mainCameraController.closeCamera();
|
||||||
_intentStreamSub?.cancel();
|
_sharedLinkSub?.cancel();
|
||||||
_deepLinkSub?.cancel();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
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:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
@ -168,23 +167,22 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
|
||||||
: Text(context.lang.twonlySafeRecoverBtn),
|
: Text(context.lang.twonlySafeRecoverBtn),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (kDebugMode)
|
MyButton(
|
||||||
MyButton(
|
variant: MyButtonVariant.secondary,
|
||||||
variant: MyButtonVariant.secondary,
|
onPressed: () async {
|
||||||
onPressed: () async {
|
await KeyValueStore.update<OnboardingState>(
|
||||||
await KeyValueStore.update<OnboardingState>(
|
key: KeyValueKeys.onboardingState,
|
||||||
key: KeyValueKeys.onboardingState,
|
update: (state) =>
|
||||||
update: (state) =>
|
state.hasStartedPasswordlessRecovery = true,
|
||||||
state.hasStartedPasswordlessRecovery = true,
|
);
|
||||||
);
|
if (context.mounted) {
|
||||||
if (context.mounted) {
|
await context.push(Routes.recoverPasswordless);
|
||||||
await context.push(Routes.recoverPasswordless);
|
}
|
||||||
}
|
},
|
||||||
},
|
child: Text(
|
||||||
child: Text(
|
context.lang.passwordlessRecoveryRecoverBtn,
|
||||||
context.lang.passwordlessRecoveryRecoverBtn,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import 'package:twonly/src/utils/log.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/snackbar.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_button.element.dart';
|
||||||
import 'package:twonly/src/visual/elements/my_input.element.dart';
|
import 'package:twonly/src/visual/elements/my_input.element.dart';
|
||||||
import 'package:twonly/src/visual/views/onboarding/components/animated_bell_icon.comp.dart';
|
import 'package:twonly/src/visual/views/onboarding/components/animated_bell_icon.comp.dart';
|
||||||
|
|
@ -337,7 +338,7 @@ class _RecoverPasswordlessState extends State<RecoverPasswordless> {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
showSnackbar(
|
showSnackbar(
|
||||||
context,
|
context,
|
||||||
context.lang.passwordlessRecoveryShareSent,
|
context.lang.passwordlessRecoveryEmailSent,
|
||||||
level: SnackbarLevel.success,
|
level: SnackbarLevel.success,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -511,29 +512,26 @@ class _RecoverPasswordlessState extends State<RecoverPasswordless> {
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
context.lang.recoverPasswordlessSharesReceived(
|
context.lang.recoverPasswordlessSharesReceived(
|
||||||
shares.length,
|
shares.length,
|
||||||
threshold,
|
threshold,
|
||||||
),
|
),
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
),
|
fontWeight: FontWeight.w600,
|
||||||
const Spacer(),
|
|
||||||
if (thresholdReached)
|
|
||||||
const Icon(
|
|
||||||
Icons.check_circle_rounded,
|
|
||||||
color: Colors.green,
|
|
||||||
size: 20,
|
|
||||||
),
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 12),
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
value: shares.length / threshold,
|
value: shares.length / threshold,
|
||||||
minHeight: 8,
|
minHeight: 12,
|
||||||
backgroundColor: isDark
|
backgroundColor: isDark
|
||||||
? Colors.white.withValues(alpha: 0.1)
|
? Colors.white.withValues(alpha: 0.1)
|
||||||
: Colors.black.withValues(alpha: 0.08),
|
: Colors.black.withValues(alpha: 0.08),
|
||||||
|
|
@ -542,16 +540,16 @@ class _RecoverPasswordlessState extends State<RecoverPasswordless> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 24),
|
||||||
|
Wrap(
|
||||||
...shares.map(
|
spacing: 8,
|
||||||
(share) => Padding(
|
runSpacing: 8,
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
alignment: WrapAlignment.center,
|
||||||
child: Text(
|
children: shares.map((share) {
|
||||||
share.trustedFriendDisplayName,
|
return ContactChip(
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
username: share.trustedFriendDisplayName,
|
||||||
),
|
);
|
||||||
),
|
}).toList(),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
|
|
@ -186,8 +185,7 @@ class _BackupViewState extends State<BackupView> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
if (userService.currentUser.passwordLessRecovery == null &&
|
if (userService.currentUser.passwordLessRecovery == null) ...[
|
||||||
kDebugMode) ...[
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Center(
|
Center(
|
||||||
child: MyButton(
|
child: MyButton(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
import 'dart:async';
|
||||||
|
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/elements/my_button.element.dart';
|
||||||
|
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart';
|
||||||
|
|
||||||
|
class MissingRecoveryContactsComp extends StatefulWidget {
|
||||||
|
const MissingRecoveryContactsComp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MissingRecoveryContactsComp> createState() =>
|
||||||
|
_MissingRecoveryContactsCompState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MissingRecoveryContactsCompState
|
||||||
|
extends State<MissingRecoveryContactsComp> {
|
||||||
|
bool _hasEnoughFriends = false;
|
||||||
|
bool _loading = true;
|
||||||
|
StreamSubscription<List<Contact>>? _contactsSub;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_checkFriends();
|
||||||
|
_contactsSub = twonlyDB.contactsDao.watchAllAcceptedContacts().listen((_) {
|
||||||
|
_checkFriends();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_contactsSub?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkFriends() async {
|
||||||
|
final user = userService.currentUser;
|
||||||
|
if (user.currentSetupPage != null || user.passwordLessRecovery != null) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final kvs = await twonlyDB.select(twonlyDB.keyVerifications).get();
|
||||||
|
final urs = await (twonlyDB.select(
|
||||||
|
twonlyDB.userDiscoveryUserRelations,
|
||||||
|
)..where((u) => u.publicKeyVerifiedTimestamp.isNotNull())).get();
|
||||||
|
|
||||||
|
final verified = {
|
||||||
|
...kvs.map((row) => row.contactId),
|
||||||
|
...urs.map((row) => row.announcedUserId),
|
||||||
|
};
|
||||||
|
|
||||||
|
final contacts = await twonlyDB.contactsDao.getAllContacts();
|
||||||
|
var count = 0;
|
||||||
|
for (final c in contacts) {
|
||||||
|
if (verified.contains(c.userId) &&
|
||||||
|
c.accepted &&
|
||||||
|
!c.blocked &&
|
||||||
|
!c.accountDeleted &&
|
||||||
|
!c.deletedByUser) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_hasEnoughFriends = count >= 4;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onTap() async {
|
||||||
|
await context.navPush(const PasswordLessRecoverySetup());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return StreamBuilder<void>(
|
||||||
|
stream: userService.onUserUpdated,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final user = userService.currentUser;
|
||||||
|
|
||||||
|
if (user.currentSetupPage != null ||
|
||||||
|
user.passwordLessRecovery != null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_loading || !_hasEnoughFriends) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
context.color.secondaryContainer.withValues(alpha: 0.2),
|
||||||
|
context.color.secondaryContainer.withValues(alpha: 0.1),
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
border: Border.all(
|
||||||
|
color: context.color.secondary.withValues(alpha: 0.15),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: context.color.shadow.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 68,
|
||||||
|
height: 68,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.color.secondary.withValues(alpha: 0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: FaIcon(
|
||||||
|
FontAwesomeIcons.users,
|
||||||
|
size: 28,
|
||||||
|
color: context.color.secondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 20),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.lang.missingRecoveryContactsCardTitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
fontSize: 16,
|
||||||
|
color: context.color.onSurface,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
context.lang.missingRecoveryContactsCardDesc,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: context.color.onSurfaceVariant,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
MyButton(
|
||||||
|
onPressed: onTap,
|
||||||
|
variant: MyButtonVariant.primaryDense,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.arrow_forward_rounded,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
context
|
||||||
|
.lang
|
||||||
|
.missingRecoveryContactsCardAction,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,16 +17,16 @@ class _FactorOption {
|
||||||
|
|
||||||
const _options = [
|
const _options = [
|
||||||
_FactorOption(
|
_FactorOption(
|
||||||
type: SecondFactorType.none,
|
type: SecondFactorType.email,
|
||||||
icon: Icons.block_rounded,
|
icon: Icons.email_rounded,
|
||||||
),
|
),
|
||||||
_FactorOption(
|
_FactorOption(
|
||||||
type: SecondFactorType.pin,
|
type: SecondFactorType.pin,
|
||||||
icon: Icons.dialpad_rounded,
|
icon: Icons.dialpad_rounded,
|
||||||
),
|
),
|
||||||
_FactorOption(
|
_FactorOption(
|
||||||
type: SecondFactorType.email,
|
type: SecondFactorType.none,
|
||||||
icon: Icons.email_rounded,
|
icon: Icons.block_rounded,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,22 @@ class _ConfirmRecoveryDialogState extends State<ConfirmRecoveryDialog> {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
AvatarIcon(contactId: widget.contact.userId),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
getContactDisplayName(widget.contact),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
context.lang.passwordlessRecoveryVerifySourceDesc,
|
context.lang.passwordlessRecoveryVerifySourceDesc,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
|
|
||||||
|
|
@ -93,172 +93,187 @@ class PasswordLessRecoverySettings extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final config = userService.currentUser.passwordLessRecovery;
|
return StreamBuilder<void>(
|
||||||
|
stream: userService.onUserUpdated,
|
||||||
|
builder: (context, _) {
|
||||||
|
final config = userService.currentUser.passwordLessRecovery;
|
||||||
|
|
||||||
if (config == null) {
|
if (config == null) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(context.lang.passwordlessRecovery)),
|
appBar: AppBar(title: Text(context.lang.passwordlessRecovery)),
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Text(context.lang.passwordlessRecoveryNotConfigured),
|
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(
|
var secondFactorLabel =
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
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,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.manage_accounts_rounded, size: 18),
|
Row(
|
||||||
const SizedBox(width: 8),
|
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(
|
Text(
|
||||||
context.lang.passwordlessRecoveryModify,
|
context.lang.passwordlessRecoveryModifyDesc,
|
||||||
style: const TextStyle(fontSize: 14),
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: context.color.onSurfaceVariant,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
),
|
||||||
Text(
|
const SizedBox(height: 24),
|
||||||
context.lang.passwordlessRecoveryModifyDesc,
|
_buildInfoCard(
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
context,
|
||||||
color: context.color.onSurfaceVariant,
|
title: context.lang.passwordlessRecoverySecondFactor,
|
||||||
fontSize: 12,
|
value: secondFactorLabel,
|
||||||
),
|
icon: secondFactorIcon,
|
||||||
textAlign: TextAlign.center,
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,12 +69,14 @@ class _PasswordLessRecoverySetupState extends State<PasswordLessRecoverySetup> {
|
||||||
if (config.email != null) {
|
if (config.email != null) {
|
||||||
_secondFactor = SecondFactorType.email;
|
_secondFactor = SecondFactorType.email;
|
||||||
_emailController.text = config.email!;
|
_emailController.text = config.email!;
|
||||||
|
_threshold = 2;
|
||||||
} else if (config.pinSeed != null) {
|
} else if (config.pinSeed != null) {
|
||||||
_secondFactor = SecondFactorType.pin;
|
_secondFactor = SecondFactorType.pin;
|
||||||
|
_threshold = 2;
|
||||||
} else {
|
} else {
|
||||||
_secondFactor = SecondFactorType.none;
|
_secondFactor = SecondFactorType.none;
|
||||||
|
_threshold = 4;
|
||||||
}
|
}
|
||||||
_threshold = max(2, (selectedContacts.length / 2).ceil());
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
contacts.sortBy((c) => c.mediaSendCounter);
|
contacts.sortBy((c) => c.mediaSendCounter);
|
||||||
|
|
@ -137,8 +139,9 @@ class _PasswordLessRecoverySetupState extends State<PasswordLessRecoverySetup> {
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => SelectContactsView(
|
builder: (_) => SelectContactsView(
|
||||||
text: SelectedContactView(
|
text: SelectedContactView(
|
||||||
title: 'Trusted Friends',
|
title: context.lang.passwordlessRecoveryTrustedFriends,
|
||||||
submitButton: (selected, _) => 'Done ($selected)',
|
submitButton: (selected, _) =>
|
||||||
|
context.lang.passwordlessRecoveryDoneBtn(selected),
|
||||||
submitIcon: FontAwesomeIcons.check,
|
submitIcon: FontAwesomeIcons.check,
|
||||||
),
|
),
|
||||||
alreadySelected: _selectedContacts.map((c) => c.userId).toList(),
|
alreadySelected: _selectedContacts.map((c) => c.userId).toList(),
|
||||||
|
|
@ -230,7 +233,7 @@ class _PasswordLessRecoverySetupState extends State<PasswordLessRecoverySetup> {
|
||||||
onSelectFriends: _selectTrustedFriends,
|
onSelectFriends: _selectTrustedFriends,
|
||||||
onRemoveContact: (userId) => setState(() {
|
onRemoveContact: (userId) => setState(() {
|
||||||
_selectedContacts.removeWhere((c) => c.userId == userId);
|
_selectedContacts.removeWhere((c) => c.userId == userId);
|
||||||
_threshold = _validThreshold;
|
_threshold = _minThreshold;
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
|
|
@ -239,7 +242,7 @@ class _PasswordLessRecoverySetupState extends State<PasswordLessRecoverySetup> {
|
||||||
selected: _secondFactor,
|
selected: _secondFactor,
|
||||||
onChanged: (type) => setState(() {
|
onChanged: (type) => setState(() {
|
||||||
_secondFactor = type;
|
_secondFactor = type;
|
||||||
_threshold = _validThreshold;
|
_threshold = _minThreshold;
|
||||||
}),
|
}),
|
||||||
pinController: _pinController,
|
pinController: _pinController,
|
||||||
emailController: _emailController,
|
emailController: _emailController,
|
||||||
|
|
@ -250,7 +253,7 @@ class _PasswordLessRecoverySetupState extends State<PasswordLessRecoverySetup> {
|
||||||
ThresholdPicker(
|
ThresholdPicker(
|
||||||
contactCount: _selectedContacts.length,
|
contactCount: _selectedContacts.length,
|
||||||
minThreshold: _minThreshold,
|
minThreshold: _minThreshold,
|
||||||
currentThreshold: _validThreshold,
|
currentThreshold: _threshold,
|
||||||
onChanged: (value) => setState(() => _threshold = value),
|
onChanged: (value) => setState(() => _threshold = value),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
|
||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 0.3.7+153
|
version: 0.4.0+154
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0
|
sdk: ^3.11.0
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue