make the passwordless backup visible
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-07-15 16:20:53 +02:00
parent aeea5c9c8b
commit d45ffbf597
20 changed files with 626 additions and 286 deletions

View file

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

View file

@ -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();
}
} }

View file

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

View file

@ -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';
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -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(),

View file

@ -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();
} }

View file

@ -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),
], ],

View file

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

View file

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

View file

@ -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,
),
),
],
),
),
],
),
),
],
),
),
),
),
);
},
);
}
}

View file

@ -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,
), ),
]; ];

View file

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

View file

@ -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,
),
],
],
);
},
),
],
),
); );
} }

View file

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

View file

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