start with passwordless recovery
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-06-16 20:46:15 +02:00
parent 2687545e33
commit 98fa90a46b
20 changed files with 956 additions and 64 deletions

View file

@ -1928,6 +1928,12 @@ abstract class AppLocalizations {
/// **'Already in Group'**
String get alreadyInGroup;
/// No description provided for @contactNotVerified.
///
/// In en, this message translates to:
/// **'Not verified'**
String get contactNotVerified;
/// No description provided for @removeContactFromGroupTitle.
///
/// In en, this message translates to:

View file

@ -1026,6 +1026,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get alreadyInGroup => 'Bereits Mitglied';
@override
String get contactNotVerified => 'Nicht verifiziert';
@override
String removeContactFromGroupTitle(Object username) {
return '$username aus dieser Gruppe entfernen?';

View file

@ -1020,6 +1020,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get alreadyInGroup => 'Already in Group';
@override
String get contactNotVerified => 'Not verified';
@override
String removeContactFromGroupTitle(Object username) {
return 'Remove $username from this group?';

@ -1 +1 @@
Subproject commit 7686a412d9911bafe7585007b4eb867270e4fde4
Subproject commit 80b21d7e566a12d105f573cb7224b6cdfe37048b

View file

@ -156,6 +156,8 @@ class UserData {
@JsonKey(defaultValue: false)
bool isBackupEnabled = false;
PasswordLessRecovery? passwordLessRecovery;
// Used for push notifcation via FCM.
String? fcmToken;
@ -203,3 +205,23 @@ class TwonlySafeBackup {
List<int> encryptionKey;
Map<String, dynamic> toJson() => _$TwonlySafeBackupToJson(this);
}
@JsonSerializable()
class PasswordLessRecovery {
PasswordLessRecovery({
this.email,
this.pinSeed,
this.pinUnlockToken,
this.threshold,
});
factory PasswordLessRecovery.fromJson(Map<String, dynamic> json) =>
_$PasswordLessRecoveryFromJson(json);
String? email;
String? pinSeed;
String? pinUnlockToken;
int? threshold;
Map<String, dynamic> toJson() => _$PasswordLessRecoveryToJson(this);
}

View file

@ -103,6 +103,11 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
json['twonlySafeBackup'] as Map<String, dynamic>,
)
..isBackupEnabled = json['isBackupEnabled'] as bool? ?? false
..passwordLessRecovery = json['passwordLessRecovery'] == null
? null
: PasswordLessRecovery.fromJson(
json['passwordLessRecovery'] as Map<String, dynamic>,
)
..fcmToken = json['fcmToken'] as String?
..askedForUserStudyPermission =
json['askedForUserStudyPermission'] as bool? ?? false
@ -171,6 +176,7 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
'canUseLoginTokenForAuth': instance.canUseLoginTokenForAuth,
'twonlySafeBackup': instance.twonlySafeBackup,
'isBackupEnabled': instance.isBackupEnabled,
'passwordLessRecovery': instance.passwordLessRecovery,
'fcmToken': instance.fcmToken,
'askedForUserStudyPermission': instance.askedForUserStudyPermission,
'userStudyParticipantsToken': instance.userStudyParticipantsToken,
@ -234,3 +240,21 @@ const _$LastBackupUploadStateEnumMap = {
LastBackupUploadState.failed: 'failed',
LastBackupUploadState.success: 'success',
};
PasswordLessRecovery _$PasswordLessRecoveryFromJson(
Map<String, dynamic> json,
) => PasswordLessRecovery(
email: json['email'] as String?,
pinSeed: json['pin_seed'] as String?,
pinUnlockToken: json['pin_unlock_token'] as String?,
threshold: (json['threshold'] as num?)?.toInt(),
);
Map<String, dynamic> _$PasswordLessRecoveryToJson(
PasswordLessRecovery instance,
) => <String, dynamic>{
'email': instance.email,
'pin_seed': instance.pinSeed,
'pin_unlock_token': instance.pinUnlockToken,
'threshold': instance.threshold,
};

View file

@ -0,0 +1,23 @@
import 'dart:async';
import 'package:twonly/src/utils/log.dart';
class PasswordlessRecoveryService {
static Future<bool> enablePasswordlessRecovery({
required List<int> trustedFriendIds,
required String secondFactorType,
required String secondFactorValue,
required int threshold,
}) async {
Log.info('Enabling passwordless recovery with:');
Log.info(' - Trusted Friends: $trustedFriendIds');
Log.info(' - Second Factor Type: $secondFactorType');
Log.info(' - Second Factor Value: $secondFactorValue');
Log.info(' - Threshold: $threshold');
// Simulate network delay
await Future.delayed(const Duration(milliseconds: 1000));
return true;
}
}

View file

@ -9,11 +9,13 @@ class ContextMenu extends StatefulWidget {
const ContextMenu({
required this.child,
required this.items,
this.minWidth,
super.key,
});
final List<ContextMenuItem> items;
final Widget child;
final double? minWidth;
@override
State<ContextMenu> createState() => _ContextMenuState();
@ -116,17 +118,26 @@ class _ContextMenuState extends State<ContextMenu>
),
items: <PopupMenuEntry<int>>[
...widget.items.map(
(item) => PopupMenuItem(
padding: const EdgeInsets.only(right: 4),
child: ListTile(
(item) {
Widget child = ListTile(
title: Text(item.title),
onTap: () async {
if (mounted) Navigator.pop(context);
await item.onTap();
},
leading: _getIcon(item.icon),
),
),
);
if (widget.minWidth != null) {
child = ConstrainedBox(
constraints: BoxConstraints(minWidth: widget.minWidth!),
child: child,
);
}
return PopupMenuItem(
padding: const EdgeInsets.only(right: 4),
child: child,
);
},
),
],
position: RelativeRect.fromRect(

View file

@ -18,6 +18,7 @@ class UserContextMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ContextMenu(
minWidth: 150,
items: [
ContextMenuItem(
title: context.lang.contextMenuUserProfile,

View file

@ -10,6 +10,7 @@ enum MyButtonVariant {
primaryMiddle,
primaryDense,
secondaryDense,
error,
}
class MyButton extends StatefulWidget {
@ -211,6 +212,25 @@ class _MyButtonState extends State<MyButton>
fontWeight: FontWeight.bold,
),
);
case MyButtonVariant.error:
buttonStyle = FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.errorContainer,
foregroundColor: Theme.of(context).colorScheme.onErrorContainer,
disabledBackgroundColor: disabledBgColor,
disabledForegroundColor: disabledFgColor,
minimumSize: const Size(0, 40),
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
textStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
);
}
final childButton = widget.variant == MyButtonVariant.text

View file

@ -1,5 +1,6 @@
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';
@ -8,6 +9,7 @@ import 'package:twonly/src/model/json/backup.model.dart';
import 'package:twonly/src/services/backup.service.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 BackupView extends StatefulWidget {
const BackupView({super.key});
@ -176,36 +178,59 @@ class _BackupViewState extends State<BackupView> {
),
]),
),
const SizedBox(height: 10),
MyButton(
variant: MyButtonVariant.primaryMiddle,
onPressed: _isLoading
? null
: () async {
setState(() {
_isLoading = true;
});
await BackupService.makeBackup(force: true);
setState(() {
_isLoading = false;
});
},
child: Text(context.lang.backupTwonlySaveNow),
),
],
),
const SizedBox(height: 32),
Center(
child: MyButton(
variant: MyButtonVariant.secondaryDense,
onPressed: () =>
context.push(Routes.settingsBackupSetup, extra: true),
child: Text(
!userService.currentUser.isBackupEnabled
? context.lang.backupEnableBackup
: context.lang.backupChangePassword,
if (userService.currentUser.passwordLessRecovery == null &&
kDebugMode) ...[
const SizedBox(height: 20),
Center(
child: MyButton(
variant: MyButtonVariant.primaryMiddle,
onPressed: () =>
context.navPush(const PasswordLessRecoverySetup()),
child: const Text('Setup Passwordless Recovery'),
),
),
],
const SizedBox(height: 20),
Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (userService.currentUser.isBackupEnabled) ...[
MyButton(
variant: MyButtonVariant.secondaryDense,
onPressed: _isLoading
? null
: () async {
setState(() {
_isLoading = true;
});
await BackupService.makeBackup(force: true);
setState(() {
_isLoading = false;
});
},
child: Text(context.lang.backupTwonlySaveNow),
),
const SizedBox(width: 12),
],
MyButton(
variant: MyButtonVariant.secondaryDense,
onPressed: () => context.push(
Routes.settingsBackupSetup,
extra: true,
),
child: Text(
!userService.currentUser.isBackupEnabled
? context.lang.backupEnableBackup
: context.lang.backupChangePassword,
),
),
],
),
),
],
),

View file

@ -0,0 +1,166 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_input.element.dart';
import 'package:twonly/src/visual/themes/light.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart';
class _FactorOption {
const _FactorOption({
required this.type,
required this.icon,
required this.label,
});
final SecondFactorType type;
final IconData icon;
final String label;
}
const _options = [
_FactorOption(
type: SecondFactorType.none,
icon: Icons.block_rounded,
label: 'None',
),
_FactorOption(
type: SecondFactorType.pin,
icon: Icons.dialpad_rounded,
label: 'PIN',
),
_FactorOption(
type: SecondFactorType.email,
icon: Icons.email_rounded,
label: 'Email',
),
];
class SecondFactorPicker extends StatelessWidget {
const SecondFactorPicker({
required this.selected,
required this.onChanged,
required this.pinController,
required this.emailController,
required this.onInputChanged,
super.key,
});
final SecondFactorType selected;
final ValueChanged<SecondFactorType> onChanged;
final TextEditingController pinController;
final TextEditingController emailController;
final VoidCallback onInputChanged;
@override
Widget build(BuildContext context) {
return Column(
children: [
const Text(
'Second factor method',
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
_buildSegmentedControl(context),
const SizedBox(height: 16),
_buildInputField(),
],
);
}
Widget _buildSegmentedControl(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: context.color.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: context.color.outlineVariant.withValues(alpha: 0.3),
),
),
child: Row(
children: List.generate(_options.length * 2 - 1, (index) {
if (index.isOdd) {
return Container(
width: 1,
height: 40,
color: context.color.outlineVariant.withValues(alpha: 0.3),
);
}
final optionIndex = index ~/ 2;
final option = _options[optionIndex];
final isSelected = selected == option.type;
BorderRadius? borderRadius;
if (optionIndex == 0) {
borderRadius = const BorderRadius.horizontal(
left: Radius.circular(15),
);
} else if (optionIndex == _options.length - 1) {
borderRadius = const BorderRadius.horizontal(
right: Radius.circular(15),
);
}
return Expanded(
child: GestureDetector(
onTap: () => onChanged(option.type),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: isSelected ? primaryColor : Colors.transparent,
borderRadius: borderRadius,
),
child: Column(
children: [
Icon(
option.icon,
color: isSelected
? Colors.black87
: context.color.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
option.label,
style: TextStyle(
fontWeight: FontWeight.bold,
color: isSelected
? Colors.black87
: context.color.onSurfaceVariant,
),
),
],
),
),
),
);
}),
),
);
}
Widget _buildInputField() {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: switch (selected) {
SecondFactorType.none => const SizedBox.shrink(
key: ValueKey('none_input'),
),
SecondFactorType.pin => MyInput(
key: const ValueKey('pin_input'),
controller: pinController,
hintText: 'Enter PIN',
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (_) => onInputChanged(),
),
SecondFactorType.email => MyInput(
key: const ValueKey('email_input'),
controller: emailController,
hintText: 'Enter recovery email address',
keyboardType: TextInputType.emailAddress,
onChanged: (_) => onInputChanged(),
),
},
);
}
}

View file

@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/themes/light.dart';
class ThresholdPicker extends StatelessWidget {
const ThresholdPicker({
required this.contactCount,
required this.minThreshold,
required this.currentThreshold,
required this.onChanged,
super.key,
});
final int contactCount;
final int minThreshold;
final int currentThreshold;
final ValueChanged<int> onChanged;
/// Clamps [value] between [minThreshold] and contactCount - 2.
static int clampThreshold({
required int value,
required int minThreshold,
required int contactCount,
}) {
final maxT = (contactCount - 2) < minThreshold
? minThreshold
: (contactCount - 2);
if (value < minThreshold) return minThreshold;
if (value > maxT) return maxT;
return value;
}
@override
Widget build(BuildContext context) {
if (contactCount == 0) return const SizedBox.shrink();
final maxT = (contactCount - 2) < minThreshold
? minThreshold
: (contactCount - 2);
final options = List.generate(
maxT - minThreshold + 1,
(i) => minThreshold + i,
);
if (options.length == 1) {
return Text(
'To recover your account you need ${options.first} of your selected trusted friends.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: context.color.onSurfaceVariant,
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Required trusted friends for recovery',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: context.color.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: context.color.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: context.color.outlineVariant.withValues(alpha: 0.3),
),
),
child: Row(
children: List.generate(options.length * 2 - 1, (index) {
if (index.isOdd) {
return Container(
width: 1,
height: 40,
color: context.color.outlineVariant.withValues(alpha: 0.3),
);
}
final optionIndex = index ~/ 2;
final value = options[optionIndex];
final isSelected = value == currentThreshold;
BorderRadius? borderRadius;
if (optionIndex == 0) {
borderRadius = const BorderRadius.horizontal(
left: Radius.circular(15),
);
} else if (optionIndex == options.length - 1) {
borderRadius = const BorderRadius.horizontal(
right: Radius.circular(15),
);
}
return Expanded(
child: GestureDetector(
onTap: () => onChanged(value),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: isSelected ? primaryColor : Colors.transparent,
borderRadius: borderRadius,
),
child: Center(
child: Text(
'$value',
style: TextStyle(
fontWeight: FontWeight.bold,
color: isSelected
? Colors.black87
: context.color.onSurfaceVariant,
),
),
),
),
),
);
}),
),
),
],
);
}
}

View file

@ -0,0 +1,148 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
class TrustedFriendsCard extends StatelessWidget {
const TrustedFriendsCard({
required this.selectedContacts,
required this.needsMoreFriends,
required this.missingCount,
required this.onSelectFriends,
required this.onRemoveContact,
super.key,
});
final List<Contact> selectedContacts;
final bool needsMoreFriends;
final int missingCount;
final VoidCallback onSelectFriends;
final void Function(int userId) onRemoveContact;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: context.color.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: context.color.outlineVariant.withValues(alpha: 0.5),
),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (selectedContacts.isEmpty)
_buildEmptyState(context)
else
Wrap(
spacing: 4,
alignment: WrapAlignment.center,
children: selectedContacts.map((contact) {
return _ContactChip(
key: ValueKey(contact.userId),
contact: contact,
onRemove: onRemoveContact,
);
}).toList(),
),
const SizedBox(height: 12),
MyButton(
variant: needsMoreFriends
? MyButtonVariant.error
: MyButtonVariant.secondaryDense,
onPressed: onSelectFriends,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.add_moderator_rounded, size: 18),
const SizedBox(width: 8),
Text(
needsMoreFriends
? 'Select friends ($missingCount more needed)'
: 'Select trusted friends',
),
],
),
),
],
),
);
}
Widget _buildEmptyState(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Column(
children: [
Icon(
Icons.people_outline_rounded,
size: 48,
color: context.color.onSurfaceVariant.withValues(alpha: 0.6),
),
const SizedBox(height: 12),
Text(
'No trusted friends selected yet',
style: TextStyle(
fontSize: 14,
color: context.color.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
],
),
);
}
}
class _ContactChip extends StatelessWidget {
const _ContactChip({
required this.contact,
required this.onRemove,
super.key,
});
final Contact contact;
final void Function(int) onRemove;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onRemove(contact.userId),
child: Chip(
avatar: AvatarIcon(
contactId: contact.userId,
fontSize: 10,
),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
getContactDisplayName(contact),
style: const TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis,
),
const SizedBox(width: 6),
VerificationBadgeComp(
contact: contact,
size: 12,
clickable: false,
),
const SizedBox(width: 15),
const FaIcon(
FontAwesomeIcons.xmark,
color: Colors.grey,
size: 12,
),
],
),
),
);
}
}

View file

@ -0,0 +1,213 @@
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/services/passwordless_recovery.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/second_factor_picker.comp.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/threshold_picker.comp.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/trusted_friends_card.comp.dart';
import 'package:twonly/src/visual/views/shared/select_contacts.view.dart';
enum SecondFactorType { none, pin, email }
class PasswordLessRecoverySetup extends StatefulWidget {
const PasswordLessRecoverySetup({super.key});
@override
State<PasswordLessRecoverySetup> createState() =>
_PasswordLessRecoverySetupState();
}
class _PasswordLessRecoverySetupState extends State<PasswordLessRecoverySetup> {
List<Contact> _selectedContacts = [];
SecondFactorType _secondFactor = SecondFactorType.pin;
final _pinController = TextEditingController();
final _emailController = TextEditingController();
bool _isLoading = false;
int _threshold = 2;
@override
void dispose() {
_pinController.dispose();
_emailController.dispose();
super.dispose();
}
// --- Threshold logic ---
int get _minThreshold => _secondFactor == SecondFactorType.none ? 4 : 2;
int get _validThreshold => ThresholdPicker.clampThreshold(
value: _threshold,
minThreshold: _minThreshold,
contactCount: _selectedContacts.length,
);
int get _minSelectedFriends => _validThreshold + 2;
// --- Validation ---
bool get _canEnable {
if (_selectedContacts.length < _minSelectedFriends) return false;
return switch (_secondFactor) {
SecondFactorType.none => true,
SecondFactorType.pin => _pinController.text.length >= 4,
SecondFactorType.email =>
RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')
.hasMatch(_emailController.text),
};
}
// --- Actions ---
Future<void> _selectTrustedFriends() async {
final selectedIds = await Navigator.push<List<int>>(
context,
MaterialPageRoute(
builder: (_) => SelectContactsView(
text: SelectedContactView(
title: 'Trusted Friends',
submitButton: (selected, _) => 'Done ($selected)',
submitIcon: FontAwesomeIcons.check,
),
alreadySelected: _selectedContacts.map((c) => c.userId).toList(),
isAlreadySelectedLocked: false,
onlyVerified: true,
sortByMediaCount: true,
),
),
);
if (selectedIds == null) return;
final contacts = <Contact>[];
for (final id in selectedIds) {
final contact = await twonlyDB.contactsDao.getContactById(id);
if (contact != null) contacts.add(contact);
}
setState(() {
_selectedContacts = contacts;
_threshold = _validThreshold;
});
}
Future<void> _enable() async {
setState(() => _isLoading = true);
final secondFactorValue = switch (_secondFactor) {
SecondFactorType.none => '',
SecondFactorType.pin => _pinController.text,
SecondFactorType.email => _emailController.text,
};
final success =
await PasswordlessRecoveryService.enablePasswordlessRecovery(
trustedFriendIds: _selectedContacts.map((c) => c.userId).toList(),
secondFactorType: _secondFactor.name,
secondFactorValue: secondFactorValue,
threshold: _validThreshold,
);
if (!mounted) return;
setState(() => _isLoading = false);
if (success) {
showSnackbar(
context,
'Passwordless recovery enabled successfully!',
level: SnackbarLevel.success,
);
Navigator.pop(context);
}
}
// --- Build ---
@override
Widget build(BuildContext context) {
final needsMoreFriends = _selectedContacts.length < _minSelectedFriends;
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
appBar: AppBar(
title: const Text('Passwordless Recovery'),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
children: [
Text(
'Recover your identity without a password.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: context.color.onSurfaceVariant,
),
),
const SizedBox(height: 24),
TrustedFriendsCard(
selectedContacts: _selectedContacts,
needsMoreFriends: needsMoreFriends,
missingCount: _minSelectedFriends - _selectedContacts.length,
onSelectFriends: _selectTrustedFriends,
onRemoveContact: (userId) => setState(() {
_selectedContacts.removeWhere((c) => c.userId == userId);
_threshold = _validThreshold;
}),
),
const SizedBox(height: 28),
SecondFactorPicker(
selected: _secondFactor,
onChanged: (type) => setState(() {
_secondFactor = type;
_threshold = _validThreshold;
}),
pinController: _pinController,
emailController: _emailController,
onInputChanged: () => setState(() {}),
),
const SizedBox(height: 28),
ThresholdPicker(
contactCount: _selectedContacts.length,
minThreshold: _minThreshold,
currentThreshold: _validThreshold,
onChanged: (value) => setState(() => _threshold = value),
),
const SizedBox(height: 28),
MyButton(
onPressed: (_canEnable && !_isLoading) ? _enable : null,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_isLoading)
const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.black87,
),
),
)
else
const Icon(Icons.check_circle_outline_rounded, size: 20),
const SizedBox(width: 8),
const Text('Enable Passwordless Recovery'),
],
),
),
],
),
),
),
);
}
}

View file

@ -11,6 +11,7 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
import 'package:twonly/src/visual/context_menu/user.context_menu.dart';
import 'package:twonly/src/visual/decorations/input_text.decoration.dart';
@ -19,10 +20,12 @@ class SelectedContactView {
required this.title,
required this.submitButton,
required this.submitIcon,
this.alreadySelectedSubtitle,
});
final String title;
final String Function(int selected, int? limit) submitButton;
final FaIconData submitIcon;
final String? alreadySelectedSubtitle;
}
class SelectContactsView extends StatefulWidget {
@ -30,11 +33,18 @@ class SelectContactsView extends StatefulWidget {
required this.text,
this.alreadySelected,
this.limit,
this.isAlreadySelectedLocked = true,
this.onlyVerified = false,
this.sortByMediaCount = false,
super.key,
});
final SelectedContactView text;
final List<int>? alreadySelected;
final int? limit;
final bool isAlreadySelectedLocked;
final bool onlyVerified;
final bool sortByMediaCount;
@override
State<SelectContactsView> createState() => _SelectAdditionalUsers();
}
@ -47,12 +57,17 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
final HashSet<int> selectedUsers = HashSet();
late HashSet<int> _alreadySelected;
final HashSet<int> verifiedUserIds = HashSet();
@override
void initState() {
super.initState();
_alreadySelected = HashSet.from(widget.alreadySelected ?? []);
if (!widget.isAlreadySelectedLocked) {
selectedUsers.addAll(_alreadySelected);
_alreadySelected.clear();
}
final stream = twonlyDB.contactsDao.watchAllAcceptedContacts();
@ -65,6 +80,23 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
});
await filterUsers();
});
_loadVerifiedContacts();
}
Future<void> _loadVerifiedContacts() async {
final kvs = await twonlyDB.select(twonlyDB.keyVerifications).get();
final urs = await (twonlyDB.select(twonlyDB.userDiscoveryUserRelations)
..where((u) => u.publicKeyVerifiedTimestamp.isNotNull()))
.get();
if (!mounted) return;
setState(() {
verifiedUserIds
..addAll(kvs.map((row) => row.contactId))
..addAll(urs.map((row) => row.announcedUserId));
});
await filterUsers();
}
@override
@ -74,21 +106,37 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
}
Future<void> filterUsers() async {
if (searchUserName.value.text.isEmpty) {
setState(() {
contacts = allContacts;
});
return;
var filtered = allContacts;
if (searchUserName.value.text.isNotEmpty) {
filtered = filtered
.where(
(user) => getContactDisplayName(
user,
).toLowerCase().contains(searchUserName.value.text.toLowerCase()),
)
.toList();
}
final usersFiltered = allContacts
.where(
(user) => getContactDisplayName(
user,
).toLowerCase().contains(searchUserName.value.text.toLowerCase()),
)
.toList();
if (widget.sortByMediaCount) {
filtered.sort((a, b) {
final aVerified = verifiedUserIds.contains(a.userId);
final bVerified = verifiedUserIds.contains(b.userId);
if (aVerified && !bVerified) return -1;
if (!aVerified && bVerified) return 1;
final cmp = b.mediaSendCounter.compareTo(a.mediaSendCounter);
if (cmp != 0) return cmp;
return getContactDisplayName(a).compareTo(getContactDisplayName(b));
});
} else {
filtered.sort(
(a, b) => getContactDisplayName(a).compareTo(getContactDisplayName(b)),
);
}
setState(() {
contacts = usersFiltered;
contacts = filtered;
});
}
@ -119,7 +167,7 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
: () => Navigator.pop(context, selectedUsers.toList()),
label: Text(
widget.text.submitButton(
selectedUsers.length + (widget.alreadySelected?.length ?? 0),
selectedUsers.length + _alreadySelected.length,
widget.limit,
),
),
@ -169,10 +217,15 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
return Wrap(
spacing: 8,
children: selected.map((w) {
final contact = allContacts
.where((t) => t.userId == w)
.firstOrNull;
if (contact == null) {
return const SizedBox.shrink();
}
return _Chip(
contact: allContacts.firstWhere(
(t) => t.userId == w,
),
key: ValueKey(contact.userId),
contact: contact,
onTap: toggleSelectedUser,
);
}).toList(),
@ -188,13 +241,27 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
i -= 2;
}
final user = contacts[i];
final isVerified = verifiedUserIds.contains(user.userId);
final isSelectionDisabled = widget.onlyVerified && !isVerified;
return UserContextMenu(
key: ValueKey(user.userId),
contact: user,
child: ListTile(
enabled: !isSelectionDisabled,
title: Row(
children: [
Text(getContactDisplayName(user)),
Flexible(
child: Text(
getContactDisplayName(user),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 4),
VerificationBadgeComp(
contact: user,
size: 14,
clickable: false,
),
FlameCounterWidget(
contactId: user.userId,
prefix: true,
@ -202,8 +269,17 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
],
),
subtitle: (_alreadySelected.contains(user.userId))
? Text(context.lang.alreadyInGroup)
: null,
? (widget.text.alreadySelectedSubtitle != null
? Text(widget.text.alreadySelectedSubtitle!)
: Text(context.lang.alreadyInGroup))
: (isSelectionDisabled
? Text(
context.lang.contactNotVerified,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
)
: null),
leading: AvatarIcon(
contactId: user.userId,
fontSize: 13,
@ -222,13 +298,17 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
);
},
),
onChanged: (value) {
toggleSelectedUser(user.userId);
},
onChanged: isSelectionDisabled
? null
: (value) {
toggleSelectedUser(user.userId);
},
),
onTap: () {
toggleSelectedUser(user.userId);
},
onTap: isSelectionDisabled
? null
: () {
toggleSelectedUser(user.userId);
},
),
);
},
@ -247,6 +327,7 @@ class _Chip extends StatelessWidget {
const _Chip({
required this.contact,
required this.onTap,
super.key,
});
final Contact contact;
final void Function(int) onTap;
@ -268,6 +349,12 @@ class _Chip extends StatelessWidget {
style: const TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis,
),
const SizedBox(width: 4),
VerificationBadgeComp(
contact: contact,
size: 12,
clickable: false,
),
const SizedBox(width: 15),
const FaIcon(
FontAwesomeIcons.xmark,

View file

@ -1,5 +1,6 @@
use std::io::Result;
fn main() -> Result<()> {
prost_build::compile_protos(&["src/user_discovery/types.proto"], &["src/"])?;
prost_build::compile_protos(&["src/passwordless_recovery/types.proto"], &["src/"])?;
Ok(())
}

View file

@ -1 +1,12 @@
mod traits;
include!(concat!(env!("OUT_DIR"), "/passwordless_recovery.rs"));
struct PasswordLessRecovery {}
impl PasswordLessRecovery {
pub(crate) fn create_shared_secret_data(
total_shares: i64,
threshold: i64,
pin_unlock_token: Option<Vec<u8>>,
) {
}
}

View file

@ -36,8 +36,8 @@ message TrustedFriendShare {
// The minimum threshold required to decrypte the shares.
int32 threshold = 3;
// The actual share which will become: SecretSharedDate
bytes share = 4;
// The actual share which will become: SharedSecretData
bytes shared_secret_data = 4;
message User {
int64 user_id = 1;
@ -47,7 +47,7 @@ message TrustedFriendShare {
}
// After received all shares this is decrypted by the user restoring its own
message SecretSharedDate {
message SharedSecretData {
// No second factor was selected
optional RecoveryData recovery_data = 1;
@ -81,6 +81,5 @@ message SecretSharedDate {
// In case the backup is not available any more the user can use its user_id and his private_key to requister as a new user.
message RecoveryData {
int64 user_id = 1;
bytes private_key = 2;
bytes backup_master_key = 3;
bytes key_manager = 3;
}