From d45ffbf597dbf98a6191311cb8ad6ea644b054f1 Mon Sep 17 00:00:00 2001 From: otsmr Date: Wed, 15 Jul 2026 16:20:53 +0200 Subject: [PATCH] make the passwordless backup visible --- CHANGELOG.md | 5 + lib/app.dart | 48 ++- .../generated/app_localizations.dart | 50 ++- .../generated/app_localizations_de.dart | 35 +- .../generated/app_localizations_en.dart | 36 +- lib/src/localization/translations | 2 +- lib/src/services/intent/links.intent.dart | 3 +- .../passwordless_recovery.service.dart | 6 - .../visual/elements/contact_chip.element.dart | 38 +- .../visual/views/chats/chat_list.view.dart | 2 + lib/src/visual/views/home.view.dart | 32 +- .../onboarding/recover_password.view.dart | 32 +- .../onboarding/recover_passwordless.view.dart | 42 ++- .../settings/backup/backup_settings.view.dart | 4 +- .../missing_recovery_contacts.comp.dart | 207 +++++++++++ .../components/second_factor_picker.comp.dart | 8 +- ...p_a_friend.passwordless_recovery.view.dart | 16 + .../settings.passwordless_recovery.view.dart | 329 +++++++++--------- .../setup.passwordless_recovery.view.dart | 15 +- pubspec.yaml | 2 +- 20 files changed, 626 insertions(+), 286 deletions(-) create mode 100644 lib/src/visual/views/settings/backup/passwordless_recovery/components/missing_recovery_contacts.comp.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 008bc369..e6772f04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.4.0 + +- New: Passwordless Backup +- Fix: Performance issues + ## 0.3.7 - Fix: Multiple UI issues diff --git a/lib/app.dart b/lib/app.dart index 8a0dc086..ba3a4a3c 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,7 +1,10 @@ import 'dart:async'; + +import 'package:app_links/app_links.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.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:twonly/globals.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/providers/routing.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/log.dart'; import 'package:twonly/src/utils/pow.dart'; @@ -143,6 +148,10 @@ class _AppMainWidgetState extends State { bool _wasLogged = true; late int _initialPage; + StreamSubscription? _deepLinkSub; + StreamSubscription>? _intentStreamSub; + StreamSubscription? _emailTokenSub; + (Future?, bool) _proofOfWork = (null, false); @override @@ -151,6 +160,33 @@ class _AppMainWidgetState extends State { _initialPage = widget.initialPage; Log.info('AppWidgetState: initState started'); 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 initAsync() async { @@ -197,10 +233,6 @@ class _AppMainWidgetState extends State { } } - // await PasswordlessRecoveryService.handleRecoveryLink( - // 'https://me.twonly.eu/r/#7fdb8f08-0927-4e44-8761-038993414e48/1p7SKEzpxE3wSW9FQw60EUI4OpSW2U4EskdXLw8xg48', - // ); - setState(() { _isLoaded = true; }); @@ -264,4 +296,12 @@ class _AppMainWidgetState extends State { ], ); } + + @override + void dispose() { + _deepLinkSub?.cancel(); + _intentStreamSub?.cancel(); + _emailTokenSub?.cancel(); + super.dispose(); + } } diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 67645aec..10999072 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -3891,7 +3891,7 @@ abstract class AppLocalizations { /// No description provided for @passwordlessRecoveryInfoHowItWorksDesc. /// /// In en, this message translates to: - /// **'Because twonly operates without central user accounts or phone numbers to maximize privacy, we rely on a decentralized recovery mechanism. Using Shamir\'s Secret Sharing, your cryptographic identity is split into independent shares and distributed among your trusted friends. To restore access, a predefined threshold of these friends must combine their shares.'** + /// **'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; /// No description provided for @passwordlessRecoveryInfoWhySecondFactor. @@ -3903,7 +3903,7 @@ abstract class AppLocalizations { /// No description provided for @passwordlessRecoveryInfoWhySecondFactorDesc. /// /// In en, this message translates to: - /// **'The second factor (Email or PIN) serves as a vital cryptographic safeguard against malicious collusion. If your trusted friends were to coordinate their shares behind your back, they still wouldn\'t be able to decrypt your identity without the second factor key.'** + /// **'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; /// No description provided for @passwordlessRecoveryInfoGotIt. @@ -3966,6 +3966,18 @@ abstract class AppLocalizations { /// **'Select trusted friends'** 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. /// /// In en, this message translates to: @@ -4032,10 +4044,16 @@ abstract class AppLocalizations { /// **'No recovery share stored for this contact.'** 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. /// /// In en, this message translates to: - /// **'Recovery share successfully sent!'** + /// **'Recovery share sent!'** String get passwordlessRecoveryShareSent; /// No description provided for @passwordlessRecoveryNetworkError. @@ -4083,19 +4101,19 @@ abstract class AppLocalizations { /// No description provided for @passwordlessRecoveryCantHelpHim. /// /// In en, this message translates to: - /// **'You can\'t help him'** + /// **'You can\'t help this person'** String get passwordlessRecoveryCantHelpHim; /// No description provided for @passwordlessRecoveryDoesAskedYou. /// /// In en, this message translates to: - /// **'Does {username} has asked you?'** + /// **'Has {username} asked you?'** String passwordlessRecoveryDoesAskedYou(Object username); /// No description provided for @passwordlessRecoveryVerifySourceDesc. /// /// 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; /// No description provided for @passwordlessRecoveryNo. @@ -4125,7 +4143,7 @@ abstract class AppLocalizations { /// No description provided for @recoverPasswordlessSharesReceived. /// /// 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); /// No description provided for @recoverPasswordlessRecoverNowBtn. @@ -4133,6 +4151,24 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Recover now'** 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 diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 7f7046a2..f0daeed9 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -2242,7 +2242,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get passwordlessRecoveryInfoHowItWorksDesc => - 'Da twonly ohne zentrale Benutzerkonten oder Telefonnummern funktioniert, um deine Privatsphäre zu maximieren, setzen wir auf einen dezentralen Wiederherstellungsmechanismus. Mit Shamir\'s Secret Sharing wird deine kryptografische Identität in unabhängige Teile aufgeteilt und an deine 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 String get passwordlessRecoveryInfoWhySecondFactor => @@ -2250,7 +2250,7 @@ class AppLocalizationsDe extends AppLocalizations { @override 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 String get passwordlessRecoveryInfoGotIt => 'Verstanden'; @@ -2290,6 +2290,14 @@ class AppLocalizationsDe extends AppLocalizations { @override String get passwordlessRecoverySelectFriends => 'Freunde auswählen'; + @override + String get passwordlessRecoveryTrustedFriends => 'Vertrauenswürdige Freunde'; + + @override + String passwordlessRecoveryDoneBtn(num count) { + return 'Fertig ($count)'; + } + @override String get passwordlessRecoveryNoFriendsSelected => 'Noch keine Freunde ausgewählt'; @@ -2332,9 +2340,13 @@ class AppLocalizationsDe extends AppLocalizations { String get passwordlessRecoveryNoShareStored => 'Kein Wiederherstellungs-Teil für diesen Kontakt gespeichert.'; + @override + String get passwordlessRecoveryEmailSent => + 'Wiederherstellungs-E-Mail gesendet!'; + @override String get passwordlessRecoveryShareSent => - 'Wiederherstellungs-Teil erfolgreich gesendet!'; + 'Wiederherstellungs-Teil gesendet!'; @override String get passwordlessRecoveryNetworkError => @@ -2361,7 +2373,8 @@ class AppLocalizationsDe extends AppLocalizations { String get passwordlessRecoveryNoContactsFound => 'Keine Kontakte gefunden'; @override - String get passwordlessRecoveryCantHelpHim => 'Du kannst ihm nicht helfen'; + String get passwordlessRecoveryCantHelpHim => + 'Du kannst dieser Person nicht helfen'; @override String passwordlessRecoveryDoesAskedYou(Object username) { @@ -2370,7 +2383,7 @@ class AppLocalizationsDe extends AppLocalizations { @override 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 String get passwordlessRecoveryNo => 'Nein'; @@ -2389,9 +2402,19 @@ class AppLocalizationsDe extends AppLocalizations { @override String recoverPasswordlessSharesReceived(Object received, Object threshold) { - return '$received von $threshold Freunden haben geteilt'; + return '$received von $threshold Freunden haben geholfen'; } @override 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'; } diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 41a6b5eb..f0f3dc5e 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -2226,14 +2226,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String get passwordlessRecoveryInfoHowItWorksDesc => - 'Because twonly operates without central user accounts or phone numbers to maximize privacy, we rely on a decentralized recovery mechanism. Using Shamir\'s Secret Sharing, your cryptographic identity is split into independent shares and distributed among your trusted friends. To restore access, a predefined threshold of these friends must combine their shares.'; + '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 String get passwordlessRecoveryInfoWhySecondFactor => 'Why a Second Factor?'; @override String get passwordlessRecoveryInfoWhySecondFactorDesc => - 'The second factor (Email or PIN) serves as a vital cryptographic safeguard against malicious collusion. If your trusted friends were to coordinate their shares behind your back, they still wouldn\'t be able to decrypt your identity without the second factor key.'; + '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 String get passwordlessRecoveryInfoGotIt => 'Got it'; @@ -2273,6 +2273,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String get passwordlessRecoverySelectFriends => 'Select trusted friends'; + @override + String get passwordlessRecoveryTrustedFriends => 'Trusted Friends'; + + @override + String passwordlessRecoveryDoneBtn(num count) { + return 'Done ($count)'; + } + @override String get passwordlessRecoveryNoFriendsSelected => 'No trusted friends selected yet'; @@ -2314,8 +2322,10 @@ class AppLocalizationsEn extends AppLocalizations { 'No recovery share stored for this contact.'; @override - String get passwordlessRecoveryShareSent => - 'Recovery share successfully sent!'; + String get passwordlessRecoveryEmailSent => 'Recovery email sent!'; + + @override + String get passwordlessRecoveryShareSent => 'Recovery share sent!'; @override String get passwordlessRecoveryNetworkError => @@ -2342,16 +2352,16 @@ class AppLocalizationsEn extends AppLocalizations { String get passwordlessRecoveryNoContactsFound => 'No contacts found'; @override - String get passwordlessRecoveryCantHelpHim => 'You can\'t help him'; + String get passwordlessRecoveryCantHelpHim => 'You can\'t help this person'; @override String passwordlessRecoveryDoesAskedYou(Object username) { - return 'Does $username has asked you?'; + return 'Has $username asked you?'; } @override 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 String get passwordlessRecoveryNo => 'No'; @@ -2370,9 +2380,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String recoverPasswordlessSharesReceived(Object received, Object threshold) { - return '$received of $threshold friends have shared'; + return '$received out of $threshold friends have helped'; } @override 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'; } diff --git a/lib/src/localization/translations b/lib/src/localization/translations index b7d703c8..2a54bd8e 160000 --- a/lib/src/localization/translations +++ b/lib/src/localization/translations @@ -1 +1 @@ -Subproject commit b7d703c82a5ac0b6776847cb4eb6d637620f6585 +Subproject commit 2a54bd8e7f2021c5159c92b2e1dff8c8ed4effdc diff --git a/lib/src/services/intent/links.intent.dart b/lib/src/services/intent/links.intent.dart index c8042ebc..054397ec 100644 --- a/lib/src/services/intent/links.intent.dart +++ b/lib/src/services/intent/links.intent.dart @@ -32,8 +32,7 @@ Future handleIntentUrl(BuildContext context, Uri uri) async { // Check if this is the QR code link which was // therefore scanned with the system camera - if (kDebugMode && - uri.toString().startsWith(PasswordlessRecoveryService.linkPrefix)) { + if (uri.toString().startsWith(PasswordlessRecoveryService.linkPrefix)) { await PasswordlessRecoveryService.handleRecoveryLink(uri.toString()); return true; } diff --git a/lib/src/services/passwordless_recovery.service.dart b/lib/src/services/passwordless_recovery.service.dart index 67e0e067..7770d119 100644 --- a/lib/src/services/passwordless_recovery.service.dart +++ b/lib/src/services/passwordless_recovery.service.dart @@ -8,12 +8,10 @@ import 'package:cryptography_plus/cryptography_plus.dart' show Hmac, Mac, SecretBox, SecretKey, Xchacha20; import 'package:drift/drift.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/key_manager.dart'; import 'package:twonly/locator.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' show getContactDisplayName; import 'package:twonly/src/database/twonly.db.dart'; @@ -51,10 +49,6 @@ class PasswordlessRecoveryService { if (parts.length < 2) { if (fragment.isNotEmpty) { onEmailTokenReceived.add(fragment); - final context = rootNavigatorKey.currentContext; - if (context != null && context.mounted) { - unawaited(context.push(Routes.recoverPasswordless, extra: fragment)); - } } return; } diff --git a/lib/src/visual/elements/contact_chip.element.dart b/lib/src/visual/elements/contact_chip.element.dart index d6506396..f74356c1 100644 --- a/lib/src/visual/elements/contact_chip.element.dart +++ b/lib/src/visual/elements/contact_chip.element.dart @@ -10,49 +10,57 @@ import 'package:twonly/src/visual/views/contact/contact.view.dart'; class ContactChip extends StatelessWidget { const ContactChip({ - required this.contact, + this.contact, + this.username, this.onTap, 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; @override Widget build(BuildContext context) { return ReactiveTapFeedback( onTap: () { + if (contact == null) return; if (onTap != null) { - onTap!(contact.userId); + onTap!(contact!.userId); } else { context.navPush( ContactView( - contact.userId, - key: ValueKey(contact.userId), + contact!.userId, + key: ValueKey(contact!.userId), ), ); } }, child: Chip( avatar: AvatarIcon( - contactId: contact.userId, + contactId: contact?.userId, fontSize: 10, ), label: Row( mainAxisSize: MainAxisSize.min, children: [ Text( - getContactDisplayName(contact), + contact != null ? getContactDisplayName(contact!) : username!, style: const TextStyle(fontSize: 14), overflow: TextOverflow.ellipsis, ), - const SizedBox(width: 6), - VerificationBadgeComp( - contact: contact, - size: 12, - clickable: false, - ), - if (onTap != null) ...[ + if (contact != null) ...[ + const SizedBox(width: 6), + VerificationBadgeComp( + contact: contact, + size: 12, + clickable: false, + ), + ], + if (onTap != null && contact != null) ...[ const SizedBox(width: 15), const FaIcon( FontAwesomeIcons.xmark, diff --git a/lib/src/visual/views/chats/chat_list.view.dart b/lib/src/visual/views/chats/chat_list.view.dart index 0605b307..58de7f31 100644 --- a/lib/src/visual/views/chats/chat_list.view.dart +++ b/lib/src/visual/views/chats/chat_list.view.dart @@ -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/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/passwordless_recovery/components/missing_recovery_contacts.comp.dart'; class ChatListView extends StatefulWidget { const ChatListView({super.key}); @@ -236,6 +237,7 @@ class _ChatListViewState extends State with AutomaticKeepAliveClie children: [ const FinishSetupComp(), const MissingBackupComp(), + const MissingRecoveryContactsComp(), if (_loading) const Expanded( child: SizedBox.shrink(), diff --git a/lib/src/visual/views/home.view.dart b/lib/src/visual/views/home.view.dart index f6cfdcba..c85eb538 100644 --- a/lib/src/visual/views/home.view.dart +++ b/lib/src/visual/views/home.view.dart @@ -1,18 +1,15 @@ import 'dart:async'; -import 'package:app_links/app_links.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.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:permission_handler/permission_handler.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.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/notifications/setup.notifications.dart'; import 'package:twonly/src/utils/log.dart'; @@ -46,13 +43,13 @@ class HomeViewState extends State with WidgetsBindingObserver { final MainCameraController _mainCameraController = MainCameraController(); late final PageController _homeViewPageController; - StreamSubscription>? _intentStreamSub; - StreamSubscription? _deepLinkSub; + StreamSubscription? _sharedLinkSub; StreamSubscription? _onMessageOpenedAppSub; StreamSubscription? _homeViewPageIndexSub; StreamSubscription? _selectNotificationSub; static final streamHomeViewPageIndex = StreamController.broadcast(); + static final streamSharedLink = StreamController.broadcast(); @override void initState() { @@ -113,26 +110,8 @@ class HomeViewState extends State with WidgetsBindingObserver { unawaited(_initAsync()); - void handleShareLink(Uri uri) { - routerProvider.go(Routes.home); - 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, + _sharedLinkSub = streamSharedLink.stream.listen( + _mainCameraController.setSharedLinkForPreview, ); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -207,8 +186,7 @@ class HomeViewState extends State with WidgetsBindingObserver { _disableCameraTimer?.cancel(); _mainCameraController.setState = null; _mainCameraController.closeCamera(); - _intentStreamSub?.cancel(); - _deepLinkSub?.cancel(); + _sharedLinkSub?.cancel(); super.dispose(); } diff --git a/lib/src/visual/views/onboarding/recover_password.view.dart b/lib/src/visual/views/onboarding/recover_password.view.dart index 425c9271..205949f5 100644 --- a/lib/src/visual/views/onboarding/recover_password.view.dart +++ b/lib/src/visual/views/onboarding/recover_password.view.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.dart'; @@ -168,23 +167,22 @@ class _BackupRecoveryViewState extends State { : Text(context.lang.twonlySafeRecoverBtn), ), const SizedBox(height: 16), - if (kDebugMode) - MyButton( - variant: MyButtonVariant.secondary, - onPressed: () async { - await KeyValueStore.update( - key: KeyValueKeys.onboardingState, - update: (state) => - state.hasStartedPasswordlessRecovery = true, - ); - if (context.mounted) { - await context.push(Routes.recoverPasswordless); - } - }, - child: Text( - context.lang.passwordlessRecoveryRecoverBtn, - ), + MyButton( + variant: MyButtonVariant.secondary, + onPressed: () async { + await KeyValueStore.update( + key: KeyValueKeys.onboardingState, + update: (state) => + state.hasStartedPasswordlessRecovery = true, + ); + if (context.mounted) { + await context.push(Routes.recoverPasswordless); + } + }, + child: Text( + context.lang.passwordlessRecoveryRecoverBtn, ), + ), const Spacer(), const SizedBox(height: 40), ], diff --git a/lib/src/visual/views/onboarding/recover_passwordless.view.dart b/lib/src/visual/views/onboarding/recover_passwordless.view.dart index 78bb8fc9..f8b88a1b 100644 --- a/lib/src/visual/views/onboarding/recover_passwordless.view.dart +++ b/lib/src/visual/views/onboarding/recover_passwordless.view.dart @@ -26,6 +26,7 @@ import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/snackbar.dart'; +import 'package:twonly/src/visual/elements/contact_chip.element.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/elements/my_input.element.dart'; import 'package:twonly/src/visual/views/onboarding/components/animated_bell_icon.comp.dart'; @@ -337,7 +338,7 @@ class _RecoverPasswordlessState extends State { if (mounted) { showSnackbar( context, - context.lang.passwordlessRecoveryShareSent, + context.lang.passwordlessRecoveryEmailSent, level: SnackbarLevel.success, ); } @@ -511,29 +512,26 @@ class _RecoverPasswordlessState extends State { const SizedBox(height: 24), Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ Text( context.lang.recoverPasswordlessSharesReceived( shares.length, threshold, ), - style: Theme.of(context).textTheme.bodyMedium, - ), - const Spacer(), - if (thresholdReached) - const Icon( - Icons.check_circle_rounded, - color: Colors.green, - size: 20, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, ), + textAlign: TextAlign.center, + ), ], ), - const SizedBox(height: 8), + const SizedBox(height: 12), ClipRRect( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(12), child: LinearProgressIndicator( value: shares.length / threshold, - minHeight: 8, + minHeight: 12, backgroundColor: isDark ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.08), @@ -542,16 +540,16 @@ class _RecoverPasswordlessState extends State { ), ), ), - const SizedBox(height: 16), - - ...shares.map( - (share) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - share.trustedFriendDisplayName, - style: Theme.of(context).textTheme.bodyMedium, - ), - ), + const SizedBox(height: 24), + Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: shares.map((share) { + return ContactChip( + username: share.trustedFriendDisplayName, + ); + }).toList(), ), const SizedBox(height: 24), diff --git a/lib/src/visual/views/settings/backup/backup_settings.view.dart b/lib/src/visual/views/settings/backup/backup_settings.view.dart index 54c0e749..2f381f37 100644 --- a/lib/src/visual/views/settings/backup/backup_settings.view.dart +++ b/lib/src/visual/views/settings/backup/backup_settings.view.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:twonly/locator.dart'; @@ -186,8 +185,7 @@ class _BackupViewState extends State { ], ), - if (userService.currentUser.passwordLessRecovery == null && - kDebugMode) ...[ + if (userService.currentUser.passwordLessRecovery == null) ...[ const SizedBox(height: 20), Center( child: MyButton( diff --git a/lib/src/visual/views/settings/backup/passwordless_recovery/components/missing_recovery_contacts.comp.dart b/lib/src/visual/views/settings/backup/passwordless_recovery/components/missing_recovery_contacts.comp.dart new file mode 100644 index 00000000..c9483ea1 --- /dev/null +++ b/lib/src/visual/views/settings/backup/passwordless_recovery/components/missing_recovery_contacts.comp.dart @@ -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 createState() => + _MissingRecoveryContactsCompState(); +} + +class _MissingRecoveryContactsCompState + extends State { + bool _hasEnoughFriends = false; + bool _loading = true; + StreamSubscription>? _contactsSub; + + @override + void initState() { + super.initState(); + _checkFriends(); + _contactsSub = twonlyDB.contactsDao.watchAllAcceptedContacts().listen((_) { + _checkFriends(); + }); + } + + @override + void dispose() { + _contactsSub?.cancel(); + super.dispose(); + } + + Future _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 onTap() async { + await context.navPush(const PasswordLessRecoverySetup()); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + 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, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/src/visual/views/settings/backup/passwordless_recovery/components/second_factor_picker.comp.dart b/lib/src/visual/views/settings/backup/passwordless_recovery/components/second_factor_picker.comp.dart index 24dcf175..39b0582c 100644 --- a/lib/src/visual/views/settings/backup/passwordless_recovery/components/second_factor_picker.comp.dart +++ b/lib/src/visual/views/settings/backup/passwordless_recovery/components/second_factor_picker.comp.dart @@ -17,16 +17,16 @@ class _FactorOption { const _options = [ _FactorOption( - type: SecondFactorType.none, - icon: Icons.block_rounded, + type: SecondFactorType.email, + icon: Icons.email_rounded, ), _FactorOption( type: SecondFactorType.pin, icon: Icons.dialpad_rounded, ), _FactorOption( - type: SecondFactorType.email, - icon: Icons.email_rounded, + type: SecondFactorType.none, + icon: Icons.block_rounded, ), ]; diff --git a/lib/src/visual/views/settings/backup/passwordless_recovery/help_a_friend.passwordless_recovery.view.dart b/lib/src/visual/views/settings/backup/passwordless_recovery/help_a_friend.passwordless_recovery.view.dart index 9f4caa80..12c0adeb 100644 --- a/lib/src/visual/views/settings/backup/passwordless_recovery/help_a_friend.passwordless_recovery.view.dart +++ b/lib/src/visual/views/settings/backup/passwordless_recovery/help_a_friend.passwordless_recovery.view.dart @@ -285,6 +285,22 @@ class _ConfirmRecoveryDialogState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, 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( context.lang.passwordlessRecoveryVerifySourceDesc, style: TextStyle( diff --git a/lib/src/visual/views/settings/backup/passwordless_recovery/settings.passwordless_recovery.view.dart b/lib/src/visual/views/settings/backup/passwordless_recovery/settings.passwordless_recovery.view.dart index f03b2bbe..9afe306a 100644 --- a/lib/src/visual/views/settings/backup/passwordless_recovery/settings.passwordless_recovery.view.dart +++ b/lib/src/visual/views/settings/backup/passwordless_recovery/settings.passwordless_recovery.view.dart @@ -93,172 +93,187 @@ class PasswordLessRecoverySettings extends StatelessWidget { @override Widget build(BuildContext context) { - final config = userService.currentUser.passwordLessRecovery; + return StreamBuilder( + stream: userService.onUserUpdated, + builder: (context, _) { + final config = userService.currentUser.passwordLessRecovery; - if (config == null) { - return Scaffold( - appBar: AppBar(title: Text(context.lang.passwordlessRecovery)), - body: Center( - child: Text(context.lang.passwordlessRecoveryNotConfigured), - ), - ); - } - - var secondFactorLabel = context.lang.passwordlessRecoverySecondFactorNone; - var secondFactorIcon = Icons.lock_outline_rounded; - Widget? actionButton; - - if (config.email != null) { - secondFactorLabel = context.lang - .passwordlessRecoverySecondFactorEmailLabel(config.email!); - secondFactorIcon = Icons.email_outlined; - } else if (config.pinSeed != null) { - secondFactorLabel = context.lang.passwordlessRecoverySecondFactorPin; - secondFactorIcon = Icons.pin_outlined; - actionButton = MyButton( - variant: MyButtonVariant.secondaryDense, - onPressed: () => _testPin(context), - child: Text(context.lang.passwordlessRecoveryTestPin), - ); - } - - return Scaffold( - appBar: AppBar( - title: Text(context.lang.passwordlessRecovery), - actions: [ - IconButton( - onPressed: () => showPasswordlessRecoveryInfoSheet(context), - icon: const Icon(Icons.info_outline_rounded), - ), - ], - ), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - MyButton( - variant: MyButtonVariant.secondary, - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const PasswordLessRecoverySetup(), - ), + if (config == null) { + return Scaffold( + appBar: AppBar(title: Text(context.lang.passwordlessRecovery)), + body: Center( + child: Text(context.lang.passwordlessRecoveryNotConfigured), ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, + ); + } + + 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: [ - const Icon(Icons.manage_accounts_rounded, size: 18), - const SizedBox(width: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.manage_accounts_rounded, size: 18), + const SizedBox(width: 8), + Text( + context.lang.passwordlessRecoveryModify, + style: const TextStyle(fontSize: 14), + ), + ], + ), + const SizedBox(height: 2), Text( - context.lang.passwordlessRecoveryModify, - style: const TextStyle(fontSize: 14), + context.lang.passwordlessRecoveryModifyDesc, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: context.color.onSurfaceVariant, + fontSize: 12, + ), + textAlign: TextAlign.center, ), ], ), - const SizedBox(height: 2), - Text( - context.lang.passwordlessRecoveryModifyDesc, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: context.color.onSurfaceVariant, - fontSize: 12, - ), - textAlign: TextAlign.center, - ), - ], - ), + ), + const SizedBox(height: 24), + _buildInfoCard( + context, + title: context.lang.passwordlessRecoverySecondFactor, + value: secondFactorLabel, + icon: secondFactorIcon, + action: actionButton, + ), + const SizedBox(height: 24), + StreamBuilder>( + 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>( - 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, - ), - ], - ], - ); - }, - ), - ], - ), + ); + }, ); } diff --git a/lib/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart b/lib/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart index 669694ee..5d5b5504 100644 --- a/lib/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart +++ b/lib/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart @@ -69,12 +69,14 @@ class _PasswordLessRecoverySetupState extends State { if (config.email != null) { _secondFactor = SecondFactorType.email; _emailController.text = config.email!; + _threshold = 2; } else if (config.pinSeed != null) { _secondFactor = SecondFactorType.pin; + _threshold = 2; } else { _secondFactor = SecondFactorType.none; + _threshold = 4; } - _threshold = max(2, (selectedContacts.length / 2).ceil()); }); } else { contacts.sortBy((c) => c.mediaSendCounter); @@ -137,8 +139,9 @@ class _PasswordLessRecoverySetupState extends State { MaterialPageRoute( builder: (_) => SelectContactsView( text: SelectedContactView( - title: 'Trusted Friends', - submitButton: (selected, _) => 'Done ($selected)', + title: context.lang.passwordlessRecoveryTrustedFriends, + submitButton: (selected, _) => + context.lang.passwordlessRecoveryDoneBtn(selected), submitIcon: FontAwesomeIcons.check, ), alreadySelected: _selectedContacts.map((c) => c.userId).toList(), @@ -230,7 +233,7 @@ class _PasswordLessRecoverySetupState extends State { onSelectFriends: _selectTrustedFriends, onRemoveContact: (userId) => setState(() { _selectedContacts.removeWhere((c) => c.userId == userId); - _threshold = _validThreshold; + _threshold = _minThreshold; }), ), const SizedBox(height: 28), @@ -239,7 +242,7 @@ class _PasswordLessRecoverySetupState extends State { selected: _secondFactor, onChanged: (type) => setState(() { _secondFactor = type; - _threshold = _validThreshold; + _threshold = _minThreshold; }), pinController: _pinController, emailController: _emailController, @@ -250,7 +253,7 @@ class _PasswordLessRecoverySetupState extends State { ThresholdPicker( contactCount: _selectedContacts.length, minThreshold: _minThreshold, - currentThreshold: _validThreshold, + currentThreshold: _threshold, onChanged: (value) => setState(() => _threshold = value), ), const SizedBox(height: 28), diff --git a/pubspec.yaml b/pubspec.yaml index d55ded6c..53d91f4b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec publish_to: 'none' -version: 0.3.7+153 +version: 0.4.0+154 environment: sdk: ^3.11.0