mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-06-25 02:54:07 +00:00
start with passwordless recovery
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
This commit is contained in:
parent
2687545e33
commit
98fa90a46b
20 changed files with 956 additions and 64 deletions
|
|
@ -1928,6 +1928,12 @@ abstract class AppLocalizations {
|
||||||
/// **'Already in Group'**
|
/// **'Already in Group'**
|
||||||
String get alreadyInGroup;
|
String get alreadyInGroup;
|
||||||
|
|
||||||
|
/// No description provided for @contactNotVerified.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Not verified'**
|
||||||
|
String get contactNotVerified;
|
||||||
|
|
||||||
/// No description provided for @removeContactFromGroupTitle.
|
/// No description provided for @removeContactFromGroupTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -1026,6 +1026,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get alreadyInGroup => 'Bereits Mitglied';
|
String get alreadyInGroup => 'Bereits Mitglied';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contactNotVerified => 'Nicht verifiziert';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String removeContactFromGroupTitle(Object username) {
|
String removeContactFromGroupTitle(Object username) {
|
||||||
return '$username aus dieser Gruppe entfernen?';
|
return '$username aus dieser Gruppe entfernen?';
|
||||||
|
|
|
||||||
|
|
@ -1020,6 +1020,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get alreadyInGroup => 'Already in Group';
|
String get alreadyInGroup => 'Already in Group';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contactNotVerified => 'Not verified';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String removeContactFromGroupTitle(Object username) {
|
String removeContactFromGroupTitle(Object username) {
|
||||||
return 'Remove $username from this group?';
|
return 'Remove $username from this group?';
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit 7686a412d9911bafe7585007b4eb867270e4fde4
|
Subproject commit 80b21d7e566a12d105f573cb7224b6cdfe37048b
|
||||||
|
|
@ -156,6 +156,8 @@ class UserData {
|
||||||
@JsonKey(defaultValue: false)
|
@JsonKey(defaultValue: false)
|
||||||
bool isBackupEnabled = false;
|
bool isBackupEnabled = false;
|
||||||
|
|
||||||
|
PasswordLessRecovery? passwordLessRecovery;
|
||||||
|
|
||||||
// Used for push notifcation via FCM.
|
// Used for push notifcation via FCM.
|
||||||
String? fcmToken;
|
String? fcmToken;
|
||||||
|
|
||||||
|
|
@ -203,3 +205,23 @@ class TwonlySafeBackup {
|
||||||
List<int> encryptionKey;
|
List<int> encryptionKey;
|
||||||
Map<String, dynamic> toJson() => _$TwonlySafeBackupToJson(this);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,11 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
|
||||||
json['twonlySafeBackup'] as Map<String, dynamic>,
|
json['twonlySafeBackup'] as Map<String, dynamic>,
|
||||||
)
|
)
|
||||||
..isBackupEnabled = json['isBackupEnabled'] as bool? ?? false
|
..isBackupEnabled = json['isBackupEnabled'] as bool? ?? false
|
||||||
|
..passwordLessRecovery = json['passwordLessRecovery'] == null
|
||||||
|
? null
|
||||||
|
: PasswordLessRecovery.fromJson(
|
||||||
|
json['passwordLessRecovery'] as Map<String, dynamic>,
|
||||||
|
)
|
||||||
..fcmToken = json['fcmToken'] as String?
|
..fcmToken = json['fcmToken'] as String?
|
||||||
..askedForUserStudyPermission =
|
..askedForUserStudyPermission =
|
||||||
json['askedForUserStudyPermission'] as bool? ?? false
|
json['askedForUserStudyPermission'] as bool? ?? false
|
||||||
|
|
@ -171,6 +176,7 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
|
||||||
'canUseLoginTokenForAuth': instance.canUseLoginTokenForAuth,
|
'canUseLoginTokenForAuth': instance.canUseLoginTokenForAuth,
|
||||||
'twonlySafeBackup': instance.twonlySafeBackup,
|
'twonlySafeBackup': instance.twonlySafeBackup,
|
||||||
'isBackupEnabled': instance.isBackupEnabled,
|
'isBackupEnabled': instance.isBackupEnabled,
|
||||||
|
'passwordLessRecovery': instance.passwordLessRecovery,
|
||||||
'fcmToken': instance.fcmToken,
|
'fcmToken': instance.fcmToken,
|
||||||
'askedForUserStudyPermission': instance.askedForUserStudyPermission,
|
'askedForUserStudyPermission': instance.askedForUserStudyPermission,
|
||||||
'userStudyParticipantsToken': instance.userStudyParticipantsToken,
|
'userStudyParticipantsToken': instance.userStudyParticipantsToken,
|
||||||
|
|
@ -234,3 +240,21 @@ const _$LastBackupUploadStateEnumMap = {
|
||||||
LastBackupUploadState.failed: 'failed',
|
LastBackupUploadState.failed: 'failed',
|
||||||
LastBackupUploadState.success: 'success',
|
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,
|
||||||
|
};
|
||||||
|
|
|
||||||
23
lib/src/services/passwordless_recovery.service.dart
Normal file
23
lib/src/services/passwordless_recovery.service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,11 +9,13 @@ class ContextMenu extends StatefulWidget {
|
||||||
const ContextMenu({
|
const ContextMenu({
|
||||||
required this.child,
|
required this.child,
|
||||||
required this.items,
|
required this.items,
|
||||||
|
this.minWidth,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<ContextMenuItem> items;
|
final List<ContextMenuItem> items;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
final double? minWidth;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ContextMenu> createState() => _ContextMenuState();
|
State<ContextMenu> createState() => _ContextMenuState();
|
||||||
|
|
@ -116,17 +118,26 @@ class _ContextMenuState extends State<ContextMenu>
|
||||||
),
|
),
|
||||||
items: <PopupMenuEntry<int>>[
|
items: <PopupMenuEntry<int>>[
|
||||||
...widget.items.map(
|
...widget.items.map(
|
||||||
(item) => PopupMenuItem(
|
(item) {
|
||||||
padding: const EdgeInsets.only(right: 4),
|
Widget child = ListTile(
|
||||||
child: ListTile(
|
|
||||||
title: Text(item.title),
|
title: Text(item.title),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
if (mounted) Navigator.pop(context);
|
if (mounted) Navigator.pop(context);
|
||||||
await item.onTap();
|
await item.onTap();
|
||||||
},
|
},
|
||||||
leading: _getIcon(item.icon),
|
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(
|
position: RelativeRect.fromRect(
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ class UserContextMenu extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ContextMenu(
|
return ContextMenu(
|
||||||
|
minWidth: 150,
|
||||||
items: [
|
items: [
|
||||||
ContextMenuItem(
|
ContextMenuItem(
|
||||||
title: context.lang.contextMenuUserProfile,
|
title: context.lang.contextMenuUserProfile,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ enum MyButtonVariant {
|
||||||
primaryMiddle,
|
primaryMiddle,
|
||||||
primaryDense,
|
primaryDense,
|
||||||
secondaryDense,
|
secondaryDense,
|
||||||
|
error,
|
||||||
}
|
}
|
||||||
|
|
||||||
class MyButton extends StatefulWidget {
|
class MyButton extends StatefulWidget {
|
||||||
|
|
@ -211,6 +212,25 @@ class _MyButtonState extends State<MyButton>
|
||||||
fontWeight: FontWeight.bold,
|
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
|
final childButton = widget.variant == MyButtonVariant.text
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
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';
|
||||||
|
|
@ -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/services/backup.service.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/elements/my_button.element.dart';
|
import 'package:twonly/src/visual/elements/my_button.element.dart';
|
||||||
|
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart';
|
||||||
|
|
||||||
class BackupView extends StatefulWidget {
|
class BackupView extends StatefulWidget {
|
||||||
const BackupView({super.key});
|
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(
|
if (userService.currentUser.passwordLessRecovery == null &&
|
||||||
child: MyButton(
|
kDebugMode) ...[
|
||||||
variant: MyButtonVariant.secondaryDense,
|
const SizedBox(height: 20),
|
||||||
onPressed: () =>
|
Center(
|
||||||
context.push(Routes.settingsBackupSetup, extra: true),
|
child: MyButton(
|
||||||
child: Text(
|
variant: MyButtonVariant.primaryMiddle,
|
||||||
!userService.currentUser.isBackupEnabled
|
onPressed: () =>
|
||||||
? context.lang.backupEnableBackup
|
context.navPush(const PasswordLessRecoverySetup()),
|
||||||
: context.lang.backupChangePassword,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
||||||
|
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
||||||
import 'package:twonly/src/visual/context_menu/user.context_menu.dart';
|
import 'package:twonly/src/visual/context_menu/user.context_menu.dart';
|
||||||
import 'package:twonly/src/visual/decorations/input_text.decoration.dart';
|
import 'package:twonly/src/visual/decorations/input_text.decoration.dart';
|
||||||
|
|
||||||
|
|
@ -19,10 +20,12 @@ class SelectedContactView {
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.submitButton,
|
required this.submitButton,
|
||||||
required this.submitIcon,
|
required this.submitIcon,
|
||||||
|
this.alreadySelectedSubtitle,
|
||||||
});
|
});
|
||||||
final String title;
|
final String title;
|
||||||
final String Function(int selected, int? limit) submitButton;
|
final String Function(int selected, int? limit) submitButton;
|
||||||
final FaIconData submitIcon;
|
final FaIconData submitIcon;
|
||||||
|
final String? alreadySelectedSubtitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
class SelectContactsView extends StatefulWidget {
|
class SelectContactsView extends StatefulWidget {
|
||||||
|
|
@ -30,11 +33,18 @@ class SelectContactsView extends StatefulWidget {
|
||||||
required this.text,
|
required this.text,
|
||||||
this.alreadySelected,
|
this.alreadySelected,
|
||||||
this.limit,
|
this.limit,
|
||||||
|
this.isAlreadySelectedLocked = true,
|
||||||
|
this.onlyVerified = false,
|
||||||
|
this.sortByMediaCount = false,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
final SelectedContactView text;
|
final SelectedContactView text;
|
||||||
final List<int>? alreadySelected;
|
final List<int>? alreadySelected;
|
||||||
final int? limit;
|
final int? limit;
|
||||||
|
final bool isAlreadySelectedLocked;
|
||||||
|
final bool onlyVerified;
|
||||||
|
final bool sortByMediaCount;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SelectContactsView> createState() => _SelectAdditionalUsers();
|
State<SelectContactsView> createState() => _SelectAdditionalUsers();
|
||||||
}
|
}
|
||||||
|
|
@ -47,12 +57,17 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
|
||||||
|
|
||||||
final HashSet<int> selectedUsers = HashSet();
|
final HashSet<int> selectedUsers = HashSet();
|
||||||
late HashSet<int> _alreadySelected;
|
late HashSet<int> _alreadySelected;
|
||||||
|
final HashSet<int> verifiedUserIds = HashSet();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
_alreadySelected = HashSet.from(widget.alreadySelected ?? []);
|
_alreadySelected = HashSet.from(widget.alreadySelected ?? []);
|
||||||
|
if (!widget.isAlreadySelectedLocked) {
|
||||||
|
selectedUsers.addAll(_alreadySelected);
|
||||||
|
_alreadySelected.clear();
|
||||||
|
}
|
||||||
|
|
||||||
final stream = twonlyDB.contactsDao.watchAllAcceptedContacts();
|
final stream = twonlyDB.contactsDao.watchAllAcceptedContacts();
|
||||||
|
|
||||||
|
|
@ -65,6 +80,23 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
|
||||||
});
|
});
|
||||||
await filterUsers();
|
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
|
@override
|
||||||
|
|
@ -74,21 +106,37 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> filterUsers() async {
|
Future<void> filterUsers() async {
|
||||||
if (searchUserName.value.text.isEmpty) {
|
var filtered = allContacts;
|
||||||
setState(() {
|
if (searchUserName.value.text.isNotEmpty) {
|
||||||
contacts = allContacts;
|
filtered = filtered
|
||||||
});
|
.where(
|
||||||
return;
|
(user) => getContactDisplayName(
|
||||||
|
user,
|
||||||
|
).toLowerCase().contains(searchUserName.value.text.toLowerCase()),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
final usersFiltered = allContacts
|
|
||||||
.where(
|
if (widget.sortByMediaCount) {
|
||||||
(user) => getContactDisplayName(
|
filtered.sort((a, b) {
|
||||||
user,
|
final aVerified = verifiedUserIds.contains(a.userId);
|
||||||
).toLowerCase().contains(searchUserName.value.text.toLowerCase()),
|
final bVerified = verifiedUserIds.contains(b.userId);
|
||||||
)
|
if (aVerified && !bVerified) return -1;
|
||||||
.toList();
|
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(() {
|
setState(() {
|
||||||
contacts = usersFiltered;
|
contacts = filtered;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,7 +167,7 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
|
||||||
: () => Navigator.pop(context, selectedUsers.toList()),
|
: () => Navigator.pop(context, selectedUsers.toList()),
|
||||||
label: Text(
|
label: Text(
|
||||||
widget.text.submitButton(
|
widget.text.submitButton(
|
||||||
selectedUsers.length + (widget.alreadySelected?.length ?? 0),
|
selectedUsers.length + _alreadySelected.length,
|
||||||
widget.limit,
|
widget.limit,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -169,10 +217,15 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
|
||||||
return Wrap(
|
return Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: selected.map((w) {
|
children: selected.map((w) {
|
||||||
|
final contact = allContacts
|
||||||
|
.where((t) => t.userId == w)
|
||||||
|
.firstOrNull;
|
||||||
|
if (contact == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
return _Chip(
|
return _Chip(
|
||||||
contact: allContacts.firstWhere(
|
key: ValueKey(contact.userId),
|
||||||
(t) => t.userId == w,
|
contact: contact,
|
||||||
),
|
|
||||||
onTap: toggleSelectedUser,
|
onTap: toggleSelectedUser,
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
|
|
@ -188,13 +241,27 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
|
||||||
i -= 2;
|
i -= 2;
|
||||||
}
|
}
|
||||||
final user = contacts[i];
|
final user = contacts[i];
|
||||||
|
final isVerified = verifiedUserIds.contains(user.userId);
|
||||||
|
final isSelectionDisabled = widget.onlyVerified && !isVerified;
|
||||||
return UserContextMenu(
|
return UserContextMenu(
|
||||||
key: ValueKey(user.userId),
|
key: ValueKey(user.userId),
|
||||||
contact: user,
|
contact: user,
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
|
enabled: !isSelectionDisabled,
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(getContactDisplayName(user)),
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
getContactDisplayName(user),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
VerificationBadgeComp(
|
||||||
|
contact: user,
|
||||||
|
size: 14,
|
||||||
|
clickable: false,
|
||||||
|
),
|
||||||
FlameCounterWidget(
|
FlameCounterWidget(
|
||||||
contactId: user.userId,
|
contactId: user.userId,
|
||||||
prefix: true,
|
prefix: true,
|
||||||
|
|
@ -202,8 +269,17 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
subtitle: (_alreadySelected.contains(user.userId))
|
subtitle: (_alreadySelected.contains(user.userId))
|
||||||
? Text(context.lang.alreadyInGroup)
|
? (widget.text.alreadySelectedSubtitle != null
|
||||||
: 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(
|
leading: AvatarIcon(
|
||||||
contactId: user.userId,
|
contactId: user.userId,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|
@ -222,13 +298,17 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
onChanged: (value) {
|
onChanged: isSelectionDisabled
|
||||||
toggleSelectedUser(user.userId);
|
? null
|
||||||
},
|
: (value) {
|
||||||
|
toggleSelectedUser(user.userId);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: isSelectionDisabled
|
||||||
toggleSelectedUser(user.userId);
|
? null
|
||||||
},
|
: () {
|
||||||
|
toggleSelectedUser(user.userId);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -247,6 +327,7 @@ class _Chip extends StatelessWidget {
|
||||||
const _Chip({
|
const _Chip({
|
||||||
required this.contact,
|
required this.contact,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
super.key,
|
||||||
});
|
});
|
||||||
final Contact contact;
|
final Contact contact;
|
||||||
final void Function(int) onTap;
|
final void Function(int) onTap;
|
||||||
|
|
@ -268,6 +349,12 @@ class _Chip extends StatelessWidget {
|
||||||
style: const TextStyle(fontSize: 14),
|
style: const TextStyle(fontSize: 14),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
VerificationBadgeComp(
|
||||||
|
contact: contact,
|
||||||
|
size: 12,
|
||||||
|
clickable: false,
|
||||||
|
),
|
||||||
const SizedBox(width: 15),
|
const SizedBox(width: 15),
|
||||||
const FaIcon(
|
const FaIcon(
|
||||||
FontAwesomeIcons.xmark,
|
FontAwesomeIcons.xmark,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use std::io::Result;
|
use std::io::Result;
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
prost_build::compile_protos(&["src/user_discovery/types.proto"], &["src/"])?;
|
prost_build::compile_protos(&["src/user_discovery/types.proto"], &["src/"])?;
|
||||||
|
prost_build::compile_protos(&["src/passwordless_recovery/types.proto"], &["src/"])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>>,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,8 @@ message TrustedFriendShare {
|
||||||
// The minimum threshold required to decrypte the shares.
|
// The minimum threshold required to decrypte the shares.
|
||||||
int32 threshold = 3;
|
int32 threshold = 3;
|
||||||
|
|
||||||
// The actual share which will become: SecretSharedDate
|
// The actual share which will become: SharedSecretData
|
||||||
bytes share = 4;
|
bytes shared_secret_data = 4;
|
||||||
|
|
||||||
message User {
|
message User {
|
||||||
int64 user_id = 1;
|
int64 user_id = 1;
|
||||||
|
|
@ -47,7 +47,7 @@ message TrustedFriendShare {
|
||||||
}
|
}
|
||||||
|
|
||||||
// After received all shares this is decrypted by the user restoring its own
|
// After received all shares this is decrypted by the user restoring its own
|
||||||
message SecretSharedDate {
|
message SharedSecretData {
|
||||||
|
|
||||||
// No second factor was selected
|
// No second factor was selected
|
||||||
optional RecoveryData recovery_data = 1;
|
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.
|
// 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 {
|
message RecoveryData {
|
||||||
int64 user_id = 1;
|
int64 user_id = 1;
|
||||||
bytes private_key = 2;
|
bytes key_manager = 3;
|
||||||
bytes backup_master_key = 3;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue