[mob] Move trusted contact code from old repo
This commit is contained in:
3
mobile/devtools_options.yaml
Normal file
3
mobile/devtools_options.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
description: This file stores settings for Dart & Flutter DevTools.
|
||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||
extensions:
|
||||
521
mobile/lib/emergency/emergency_page.dart
Normal file
521
mobile/lib/emergency/emergency_page.dart
Normal file
@@ -0,0 +1,521 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
import 'package:flutter/material.dart';
|
||||
import "package:logging/logging.dart";
|
||||
import 'package:photos/core/configuration.dart';
|
||||
import "package:photos/emergency/emergency_service.dart";
|
||||
import "package:photos/emergency/model.dart";
|
||||
import "package:photos/emergency/other_contact_page.dart";
|
||||
import "package:photos/emergency/select_contact_page.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import "package:photos/ui/common/loading_widget.dart";
|
||||
import "package:photos/ui/components/action_sheet_widget.dart";
|
||||
import "package:photos/ui/components/buttons/button_widget.dart";
|
||||
import 'package:photos/ui/components/captioned_text_widget.dart';
|
||||
import 'package:photos/ui/components/divider_widget.dart';
|
||||
import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart';
|
||||
import 'package:photos/ui/components/menu_section_title.dart';
|
||||
import "package:photos/ui/components/models/button_type.dart";
|
||||
import "package:photos/ui/components/notification_widget.dart";
|
||||
import 'package:photos/ui/components/title_bar_title_widget.dart';
|
||||
import 'package:photos/ui/components/title_bar_widget.dart';
|
||||
import "package:photos/ui/sharing/user_avator_widget.dart";
|
||||
import "package:photos/utils/navigation_util.dart";
|
||||
import "package:photos/utils/toast_util.dart";
|
||||
|
||||
class EmergencyPage extends StatefulWidget {
|
||||
const EmergencyPage({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EmergencyPage> createState() => _EmergencyPageState();
|
||||
}
|
||||
|
||||
class _EmergencyPageState extends State<EmergencyPage> {
|
||||
late int currentUserID;
|
||||
EmergencyInfo? info;
|
||||
final Logger _logger = Logger('EmergencyPage');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
currentUserID = Configuration.instance.getUserID()!;
|
||||
// set info to null after 5 second
|
||||
Future.delayed(
|
||||
const Duration(seconds: 0),
|
||||
() async {
|
||||
await _fetchData();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _fetchData() async {
|
||||
try {
|
||||
final result = await EmergencyContactService.instance.getInfo();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
info = result;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
showShortToast(
|
||||
context,
|
||||
S.of(context).somethingWentWrong,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
final currentUserID = Configuration.instance.getUserID()!;
|
||||
final List<EmergencyContact> othersTrustedContacts =
|
||||
info?.othersEmergencyContact ?? [];
|
||||
final List<EmergencyContact> trustedContacts = info?.contacts ?? [];
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
primary: false,
|
||||
slivers: <Widget>[
|
||||
TitleBarWidget(
|
||||
flexibleSpaceTitle: TitleBarTitleWidget(
|
||||
title: S.of(context).trustedContacts,
|
||||
),
|
||||
),
|
||||
if (info == null)
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: EnteLoadingWidget(),
|
||||
),
|
||||
),
|
||||
if (info != null)
|
||||
if (info!.recoverSessions.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 20,
|
||||
left: 16,
|
||||
right: 16,
|
||||
),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index == 0) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: NotificationWidget(
|
||||
startIcon: Icons.warning_amber_rounded,
|
||||
text: "Your trusted contact is trying to "
|
||||
"access your account",
|
||||
actionIcon: null,
|
||||
onTap: () {},
|
||||
),
|
||||
);
|
||||
}
|
||||
final RecoverySessions recoverSession =
|
||||
info!.recoverSessions[index - 1];
|
||||
return MenuItemWidget(
|
||||
captionedTextWidget: CaptionedTextWidget(
|
||||
title: recoverSession.emergencyContact.email,
|
||||
makeTextBold: recoverSession.status.isNotEmpty,
|
||||
textColor: colorScheme.warning500,
|
||||
),
|
||||
leadingIconWidget: UserAvatarWidget(
|
||||
recoverSession.emergencyContact,
|
||||
currentUserID: currentUserID,
|
||||
),
|
||||
leadingIconSize: 24,
|
||||
menuItemColor: colorScheme.fillFaint,
|
||||
singleBorderRadius: 8,
|
||||
trailingIcon: Icons.chevron_right,
|
||||
onTap: () async {
|
||||
await showRejectRecoveryDialog(recoverSession);
|
||||
},
|
||||
);
|
||||
},
|
||||
childCount: 1 + info!.recoverSessions.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (info != null)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.only(top: 12, left: 16, right: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index == 0 && trustedContacts.isNotEmpty) {
|
||||
return MenuSectionTitle(
|
||||
title: S.of(context).myTrustedContact,
|
||||
iconData: Icons.emergency_outlined,
|
||||
);
|
||||
} else if (index > 0 && index <= trustedContacts.length) {
|
||||
final listIndex = index - 1;
|
||||
final contact = trustedContacts[listIndex];
|
||||
final isLastItem = index == trustedContacts.length;
|
||||
return Column(
|
||||
children: [
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: CaptionedTextWidget(
|
||||
title: contact.emergencyContact.email,
|
||||
subTitle: contact.isPendingInvite() ? "⚠" : null,
|
||||
makeTextBold: contact.isPendingInvite(),
|
||||
),
|
||||
leadingIconSize: 24.0,
|
||||
surfaceExecutionStates: false,
|
||||
alwaysShowSuccessState: false,
|
||||
leadingIconWidget: UserAvatarWidget(
|
||||
contact.emergencyContact,
|
||||
type: AvatarType.mini,
|
||||
currentUserID: currentUserID,
|
||||
),
|
||||
menuItemColor:
|
||||
getEnteColorScheme(context).fillFaint,
|
||||
trailingIcon: Icons.chevron_right,
|
||||
trailingIconIsMuted: true,
|
||||
onTap: () async {
|
||||
await showRevokeOrRemoveDialog(context, contact);
|
||||
},
|
||||
isTopBorderRadiusRemoved: listIndex > 0,
|
||||
isBottomBorderRadiusRemoved: !isLastItem,
|
||||
singleBorderRadius: 8,
|
||||
),
|
||||
isLastItem
|
||||
? const SizedBox.shrink()
|
||||
: DividerWidget(
|
||||
dividerType: DividerType.menu,
|
||||
bgColor:
|
||||
getEnteColorScheme(context).fillFaint,
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (index == (1 + trustedContacts.length)) {
|
||||
if (trustedContacts.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24.0,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.emergency_outlined,
|
||||
color: colorScheme.strokeMuted,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
"Your trusted contacts can help in recovering "
|
||||
"your account.",
|
||||
textAlign: TextAlign.center,
|
||||
style: getEnteTextTheme(context).bodyMuted,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.primary,
|
||||
labelText: S.of(context).addTrustedContact,
|
||||
onTap: () async {
|
||||
await routeToPage(
|
||||
context,
|
||||
AddContactPage(info!),
|
||||
);
|
||||
_fetchData();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return MenuItemWidget(
|
||||
captionedTextWidget: CaptionedTextWidget(
|
||||
title: trustedContacts.isNotEmpty
|
||||
? S.of(context).addMore
|
||||
: S.of(context).addTrustedContact,
|
||||
makeTextBold: true,
|
||||
),
|
||||
leadingIcon: Icons.add_outlined,
|
||||
surfaceExecutionStates: false,
|
||||
menuItemColor: getEnteColorScheme(context).fillFaint,
|
||||
onTap: () async {
|
||||
await routeToPage(
|
||||
context,
|
||||
AddContactPage(info!),
|
||||
);
|
||||
_fetchData();
|
||||
},
|
||||
isTopBorderRadiusRemoved: trustedContacts.isNotEmpty,
|
||||
singleBorderRadius: 8,
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
childCount: 1 + trustedContacts.length + 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (info != null && info!.othersEmergencyContact.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.only(top: 24, left: 16, right: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index == 0 && (othersTrustedContacts.isNotEmpty)) {
|
||||
return const MenuSectionTitle(
|
||||
title: "You're Their Trusted Contact",
|
||||
iconData: Icons.workspace_premium_outlined,
|
||||
);
|
||||
} else if (index > 0 &&
|
||||
index <= othersTrustedContacts.length) {
|
||||
final listIndex = index - 1;
|
||||
final currentUser = othersTrustedContacts[listIndex];
|
||||
final isLastItem = index == othersTrustedContacts.length;
|
||||
return Column(
|
||||
children: [
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: CaptionedTextWidget(
|
||||
title: currentUser.user.email,
|
||||
makeTextBold: currentUser.isPendingInvite(),
|
||||
subTitle:
|
||||
currentUser.isPendingInvite() ? "⚠" : null,
|
||||
),
|
||||
leadingIconSize: 24.0,
|
||||
leadingIconWidget: UserAvatarWidget(
|
||||
currentUser.user,
|
||||
type: AvatarType.mini,
|
||||
currentUserID: currentUserID,
|
||||
),
|
||||
menuItemColor:
|
||||
getEnteColorScheme(context).fillFaint,
|
||||
trailingIcon: Icons.chevron_right,
|
||||
trailingIconIsMuted: true,
|
||||
onTap: () async {
|
||||
if (currentUser.isPendingInvite()) {
|
||||
await showAcceptOrDeclineDialog(
|
||||
context,
|
||||
currentUser,
|
||||
);
|
||||
} else {
|
||||
await routeToPage(
|
||||
context,
|
||||
OtherContactPage(
|
||||
contact: currentUser,
|
||||
emergencyInfo: info!,
|
||||
),
|
||||
);
|
||||
if (mounted) {
|
||||
_fetchData();
|
||||
}
|
||||
}
|
||||
},
|
||||
isTopBorderRadiusRemoved: listIndex > 0,
|
||||
isBottomBorderRadiusRemoved: !isLastItem,
|
||||
singleBorderRadius: 8,
|
||||
surfaceExecutionStates: false,
|
||||
),
|
||||
isLastItem
|
||||
? const SizedBox.shrink()
|
||||
: DividerWidget(
|
||||
dividerType: DividerType.menu,
|
||||
bgColor:
|
||||
getEnteColorScheme(context).fillFaint,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
childCount: 1 + othersTrustedContacts.length + 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showRevokeOrRemoveDialog(
|
||||
BuildContext context,
|
||||
EmergencyContact contact,
|
||||
) async {
|
||||
if (contact.isPendingInvite()) {
|
||||
await showActionSheet(
|
||||
context: context,
|
||||
body:
|
||||
"You have invited ${contact.emergencyContact.email} to be a trusted contact",
|
||||
bodyHighlight: "They are yet to accept your invite",
|
||||
buttons: [
|
||||
ButtonWidget(
|
||||
labelText: S.of(context).removeInvite,
|
||||
buttonType: ButtonType.critical,
|
||||
buttonSize: ButtonSize.large,
|
||||
buttonAction: ButtonAction.first,
|
||||
shouldStickToDarkTheme: true,
|
||||
shouldSurfaceExecutionStates: true,
|
||||
shouldShowSuccessConfirmation: false,
|
||||
onTap: () async {
|
||||
await EmergencyContactService.instance
|
||||
.updateContact(contact, ContactState.UserRevokedContact);
|
||||
info?.contacts.remove(contact);
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
_fetchData();
|
||||
}
|
||||
},
|
||||
isInAlert: true,
|
||||
),
|
||||
ButtonWidget(
|
||||
labelText: S.of(context).cancel,
|
||||
buttonType: ButtonType.tertiary,
|
||||
buttonSize: ButtonSize.large,
|
||||
buttonAction: ButtonAction.second,
|
||||
shouldStickToDarkTheme: true,
|
||||
isInAlert: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
await showActionSheet(
|
||||
context: context,
|
||||
body:
|
||||
"You have added ${contact.emergencyContact.email} as a trusted contact",
|
||||
bodyHighlight: "They have accepted your invite",
|
||||
buttons: [
|
||||
ButtonWidget(
|
||||
labelText: S.of(context).remove,
|
||||
buttonType: ButtonType.critical,
|
||||
buttonSize: ButtonSize.large,
|
||||
buttonAction: ButtonAction.second,
|
||||
shouldStickToDarkTheme: true,
|
||||
shouldSurfaceExecutionStates: true,
|
||||
shouldShowSuccessConfirmation: false,
|
||||
onTap: () async {
|
||||
await EmergencyContactService.instance
|
||||
.updateContact(contact, ContactState.UserRevokedContact);
|
||||
info?.contacts.remove(contact);
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
_fetchData();
|
||||
}
|
||||
},
|
||||
isInAlert: true,
|
||||
),
|
||||
ButtonWidget(
|
||||
labelText: S.of(context).cancel,
|
||||
buttonType: ButtonType.tertiary,
|
||||
buttonSize: ButtonSize.large,
|
||||
buttonAction: ButtonAction.third,
|
||||
shouldStickToDarkTheme: true,
|
||||
isInAlert: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showAcceptOrDeclineDialog(
|
||||
BuildContext context,
|
||||
EmergencyContact contact,
|
||||
) async {
|
||||
await showActionSheet(
|
||||
context: context,
|
||||
buttons: [
|
||||
ButtonWidget(
|
||||
labelText: S.of(context).acceptTrustInvite,
|
||||
buttonType: ButtonType.primary,
|
||||
buttonSize: ButtonSize.large,
|
||||
shouldStickToDarkTheme: true,
|
||||
buttonAction: ButtonAction.first,
|
||||
onTap: () async {
|
||||
await EmergencyContactService.instance
|
||||
.updateContact(contact, ContactState.ContactAccepted);
|
||||
final updatedContact =
|
||||
contact.copyWith(state: ContactState.ContactAccepted);
|
||||
info?.othersEmergencyContact.remove(contact);
|
||||
info?.othersEmergencyContact.add(updatedContact);
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
isInAlert: true,
|
||||
),
|
||||
ButtonWidget(
|
||||
labelText: S.of(context).declineTrustInvite,
|
||||
buttonType: ButtonType.critical,
|
||||
buttonSize: ButtonSize.large,
|
||||
buttonAction: ButtonAction.second,
|
||||
shouldStickToDarkTheme: true,
|
||||
onTap: () async {
|
||||
await EmergencyContactService.instance
|
||||
.updateContact(contact, ContactState.ContactDenied);
|
||||
info?.othersEmergencyContact.remove(contact);
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
isInAlert: true,
|
||||
),
|
||||
ButtonWidget(
|
||||
labelText: S.of(context).cancel,
|
||||
buttonType: ButtonType.tertiary,
|
||||
buttonSize: ButtonSize.large,
|
||||
buttonAction: ButtonAction.third,
|
||||
shouldStickToDarkTheme: true,
|
||||
isInAlert: true,
|
||||
),
|
||||
],
|
||||
body:
|
||||
"You have been invited to be a trusted contact by ${contact.user.email}",
|
||||
actionSheetType: ActionSheetType.defaultActionSheet,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Future<void> showRejectRecoveryDialog(RecoverySessions session) async {
|
||||
final String emergencyContactEmail = session.emergencyContact.email;
|
||||
await showActionSheet(
|
||||
context: context,
|
||||
buttons: [
|
||||
ButtonWidget(
|
||||
labelText: "Reject Recovery",
|
||||
buttonSize: ButtonSize.large,
|
||||
shouldStickToDarkTheme: true,
|
||||
buttonType: ButtonType.critical,
|
||||
buttonAction: ButtonAction.first,
|
||||
onTap: () async {
|
||||
await EmergencyContactService.instance.rejectRecovery(session);
|
||||
info?.recoverSessions
|
||||
.removeWhere((element) => element.id == session.id);
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
_fetchData();
|
||||
},
|
||||
isInAlert: true,
|
||||
),
|
||||
if (kDebugMode)
|
||||
ButtonWidget(
|
||||
labelText: "Approve Recovery",
|
||||
buttonType: ButtonType.primary,
|
||||
buttonSize: ButtonSize.large,
|
||||
buttonAction: ButtonAction.second,
|
||||
shouldStickToDarkTheme: true,
|
||||
onTap: () async {
|
||||
showToast(context, "Coming soon for internal users");
|
||||
},
|
||||
isInAlert: true,
|
||||
),
|
||||
ButtonWidget(
|
||||
labelText: S.of(context).cancel,
|
||||
buttonType: ButtonType.tertiary,
|
||||
buttonSize: ButtonSize.large,
|
||||
buttonAction: ButtonAction.third,
|
||||
shouldStickToDarkTheme: true,
|
||||
isInAlert: true,
|
||||
),
|
||||
],
|
||||
body: "$emergencyContactEmail is trying to recover your account.",
|
||||
actionSheetType: ActionSheetType.defaultActionSheet,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
258
mobile/lib/emergency/emergency_service.dart
Normal file
258
mobile/lib/emergency/emergency_service.dart
Normal file
@@ -0,0 +1,258 @@
|
||||
import "dart:convert";
|
||||
import "dart:math";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:dio/dio.dart";
|
||||
import "package:flutter/cupertino.dart";
|
||||
import "package:logging/logging.dart";
|
||||
import "package:photos/core/configuration.dart";
|
||||
import "package:photos/core/network/network.dart";
|
||||
import "package:photos/emergency/model.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/models/api/user/srp.dart";
|
||||
import "package:photos/models/key_attributes.dart";
|
||||
import "package:photos/models/set_keys_request.dart";
|
||||
import "package:photos/services/user_service.dart";
|
||||
import "package:photos/ui/common/user_dialogs.dart";
|
||||
import "package:photos/utils/crypto_util.dart";
|
||||
import "package:photos/utils/dialog_util.dart";
|
||||
import "package:photos/utils/email_util.dart";
|
||||
import "package:pointycastle/pointycastle.dart";
|
||||
import "package:pointycastle/random/fortuna_random.dart";
|
||||
import "package:pointycastle/srp/srp6_client.dart";
|
||||
import "package:pointycastle/srp/srp6_standard_groups.dart";
|
||||
import "package:pointycastle/srp/srp6_util.dart";
|
||||
import "package:pointycastle/srp/srp6_verifier_generator.dart";
|
||||
import "package:uuid/uuid.dart";
|
||||
|
||||
class EmergencyContactService {
|
||||
late Dio _enteDio;
|
||||
late UserService _userService;
|
||||
late Configuration _config;
|
||||
late final Logger _logger = Logger("EmergencyContactService");
|
||||
|
||||
EmergencyContactService._privateConstructor() {
|
||||
_enteDio = NetworkClient.instance.enteDio;
|
||||
_userService = UserService.instance;
|
||||
_config = Configuration.instance;
|
||||
}
|
||||
|
||||
static final EmergencyContactService instance =
|
||||
EmergencyContactService._privateConstructor();
|
||||
|
||||
Future<bool> addContact(BuildContext context, String email) async {
|
||||
if (!isValidEmail(email)) {
|
||||
await showErrorDialog(
|
||||
context,
|
||||
S.of(context).invalidEmailAddress,
|
||||
S.of(context).enterValidEmail,
|
||||
);
|
||||
return false;
|
||||
} else if (email.trim() == Configuration.instance.getEmail()) {
|
||||
await showErrorDialog(
|
||||
context,
|
||||
S.of(context).oops,
|
||||
S.of(context).youCannotShareWithYourself,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
final String? publicKey = await _userService.getPublicKey(email);
|
||||
if (publicKey == null) {
|
||||
await showInviteDialog(context, email);
|
||||
return false;
|
||||
}
|
||||
final Uint8List recoveryKey = Configuration.instance.getRecoveryKey();
|
||||
final encryptedKey = CryptoUtil.sealSync(
|
||||
recoveryKey,
|
||||
CryptoUtil.base642bin(publicKey),
|
||||
);
|
||||
await _enteDio.post(
|
||||
"/emergency-contacts/add",
|
||||
data: {
|
||||
"email": email.trim(),
|
||||
"encryptedKey": CryptoUtil.bin2base64(encryptedKey),
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<EmergencyInfo> getInfo() async {
|
||||
try {
|
||||
final response = await _enteDio.get("/emergency-contacts/info");
|
||||
return EmergencyInfo.fromJson(response.data);
|
||||
} catch (e, s) {
|
||||
Logger("EmergencyContact").severe('failed to get info', e, s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateContact(
|
||||
EmergencyContact contact,
|
||||
ContactState state,
|
||||
) async {
|
||||
try {
|
||||
await _enteDio.post(
|
||||
"/emergency-contacts/update",
|
||||
data: {
|
||||
"userID": contact.user.id,
|
||||
"emergencyContactID": contact.emergencyContact.id,
|
||||
"state": state.stringValue,
|
||||
},
|
||||
);
|
||||
} catch (e, s) {
|
||||
Logger("EmergencyContact").severe('failed to update contact', e, s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startRecovery(EmergencyContact contact) async {
|
||||
try {
|
||||
await _enteDio.post(
|
||||
"/emergency-contacts/start-recovery",
|
||||
data: {
|
||||
"userID": contact.user.id,
|
||||
"emergencyContactID": contact.emergencyContact.id,
|
||||
},
|
||||
);
|
||||
} catch (e, s) {
|
||||
Logger("EmergencyContact").severe('failed to start recovery', e, s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopRecovery(RecoverySessions session) async {
|
||||
try {
|
||||
await _enteDio.post(
|
||||
"/emergency-contacts/stop-recovery",
|
||||
data: {
|
||||
"userID": session.user.id,
|
||||
"emergencyContactID": session.emergencyContact.id,
|
||||
"id": session.id,
|
||||
},
|
||||
);
|
||||
} catch (e, s) {
|
||||
Logger("EmergencyContact").severe('failed to stop recovery', e, s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> rejectRecovery(RecoverySessions session) async {
|
||||
try {
|
||||
await _enteDio.post(
|
||||
"/emergency-contacts/reject-recovery",
|
||||
data: {
|
||||
"userID": session.user.id,
|
||||
"emergencyContactID": session.emergencyContact.id,
|
||||
"id": session.id,
|
||||
},
|
||||
);
|
||||
} catch (e, s) {
|
||||
Logger("EmergencyContact").severe('failed to stop recovery', e, s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<(String, KeyAttributes)> getRecoveryInfo(
|
||||
RecoverySessions sessions,
|
||||
) async {
|
||||
try {
|
||||
final resp = await _enteDio.get(
|
||||
"/emergency-contacts/recovery-info/${sessions.id}",
|
||||
);
|
||||
final String encryptedKey = resp.data["encryptedKey"]!;
|
||||
final decryptedKey = CryptoUtil.openSealSync(
|
||||
CryptoUtil.base642bin(encryptedKey),
|
||||
CryptoUtil.base642bin(_config.getKeyAttributes()!.publicKey),
|
||||
_config.getSecretKey()!,
|
||||
);
|
||||
final String hexRecoveryKey = CryptoUtil.bin2hex(decryptedKey);
|
||||
final KeyAttributes keyAttributes =
|
||||
KeyAttributes.fromMap(resp.data['userKeyAttr']);
|
||||
return (hexRecoveryKey, keyAttributes);
|
||||
} catch (e, s) {
|
||||
Logger("EmergencyContact").severe('failed to stop recovery', e, s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> changePasswordForOther(
|
||||
Uint8List loginKey,
|
||||
SetKeysRequest setKeysRequest,
|
||||
RecoverySessions recoverySessions,
|
||||
) async {
|
||||
try {
|
||||
final SRP6GroupParameters kDefaultSrpGroup =
|
||||
SRP6StandardGroups.rfc5054_4096;
|
||||
final String username = const Uuid().v4().toString();
|
||||
final SecureRandom random = _getSecureRandom();
|
||||
final Uint8List identity = Uint8List.fromList(utf8.encode(username));
|
||||
final Uint8List password = loginKey;
|
||||
final Uint8List salt = random.nextBytes(16);
|
||||
final gen = SRP6VerifierGenerator(
|
||||
group: kDefaultSrpGroup,
|
||||
digest: Digest('SHA-256'),
|
||||
);
|
||||
final v = gen.generateVerifier(salt, identity, password);
|
||||
|
||||
final client = SRP6Client(
|
||||
group: kDefaultSrpGroup,
|
||||
digest: Digest('SHA-256'),
|
||||
random: random,
|
||||
);
|
||||
|
||||
final A = client.generateClientCredentials(salt, identity, password);
|
||||
final request = SetupSRPRequest(
|
||||
srpUserID: username,
|
||||
srpSalt: base64Encode(salt),
|
||||
srpVerifier: base64Encode(SRP6Util.encodeBigInt(v)),
|
||||
srpA: base64Encode(SRP6Util.encodeBigInt(A!)),
|
||||
isUpdate: false,
|
||||
);
|
||||
final response = await _enteDio.post(
|
||||
"/emergency-contacts/init-change-password",
|
||||
data: {
|
||||
"recoveryID": recoverySessions.id,
|
||||
"setupSRPRequest": request.toMap(),
|
||||
},
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final SetupSRPResponse setupSRPResponse =
|
||||
SetupSRPResponse.fromJson(response.data);
|
||||
final serverB =
|
||||
SRP6Util.decodeBigInt(base64Decode(setupSRPResponse.srpB));
|
||||
// ignore: need to calculate secret to get M1, unused_local_variable
|
||||
final clientS = client.calculateSecret(serverB);
|
||||
final clientM = client.calculateClientEvidenceMessage();
|
||||
// ignore: unused_local_variable
|
||||
late Response srpCompleteResponse;
|
||||
srpCompleteResponse = await _enteDio.post(
|
||||
"/emergency-contacts/change-password",
|
||||
data: {
|
||||
"recoveryID": recoverySessions.id,
|
||||
'updateSrpAndKeysRequest': {
|
||||
'setupID': setupSRPResponse.setupID,
|
||||
'srpM1': base64Encode(SRP6Util.encodeBigInt(clientM!)),
|
||||
'updatedKeyAttr': setKeysRequest.toMap(),
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
throw Exception("register-srp action failed");
|
||||
}
|
||||
} catch (e, s) {
|
||||
_logger.severe("failed to change password for other", e, s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
SecureRandom _getSecureRandom() {
|
||||
final List<int> seeds = [];
|
||||
final random = Random.secure();
|
||||
for (int i = 0; i < 32; i++) {
|
||||
seeds.add(random.nextInt(255));
|
||||
}
|
||||
final secureRandom = FortunaRandom();
|
||||
secureRandom.seed(KeyParameter(Uint8List.fromList(seeds)));
|
||||
return secureRandom;
|
||||
}
|
||||
}
|
||||
240
mobile/lib/emergency/other_contact_page.dart
Normal file
240
mobile/lib/emergency/other_contact_page.dart
Normal file
@@ -0,0 +1,240 @@
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:logging/logging.dart";
|
||||
import "package:photos/emergency/emergency_service.dart";
|
||||
import "package:photos/emergency/model.dart";
|
||||
import "package:photos/emergency/recover_others_account.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/models/key_attributes.dart";
|
||||
import "package:photos/theme/colors.dart";
|
||||
import "package:photos/theme/ente_theme.dart";
|
||||
import "package:photos/ui/components/buttons/button_widget.dart";
|
||||
import "package:photos/ui/components/captioned_text_widget.dart";
|
||||
import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart";
|
||||
import "package:photos/ui/components/menu_section_title.dart";
|
||||
import "package:photos/ui/components/models/button_type.dart";
|
||||
import "package:photos/ui/components/title_bar_title_widget.dart";
|
||||
import "package:photos/utils/date_time_util.dart";
|
||||
import "package:photos/utils/dialog_util.dart";
|
||||
import "package:photos/utils/navigation_util.dart";
|
||||
|
||||
// OtherContactPage is used to start recovery process for other user's account
|
||||
// Based on the state of the contact & recovery session, it will show
|
||||
// different UI
|
||||
class OtherContactPage extends StatefulWidget {
|
||||
final EmergencyContact contact;
|
||||
final EmergencyInfo emergencyInfo;
|
||||
|
||||
const OtherContactPage({
|
||||
required this.contact,
|
||||
required this.emergencyInfo,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<OtherContactPage> createState() => _OtherContactPageState();
|
||||
}
|
||||
|
||||
class _OtherContactPageState extends State<OtherContactPage> {
|
||||
late String recoverDelayTime;
|
||||
late String accountEmail = widget.contact.user.email;
|
||||
RecoverySessions? recoverySession;
|
||||
String? waitTill;
|
||||
final Logger _logger = Logger("_OtherContactPageState");
|
||||
late EmergencyInfo emergencyInfo = widget.emergencyInfo;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
recoverDelayTime = "${(widget.contact.recoveryNoticeInDays ~/ 24)} days";
|
||||
recoverySession = widget.emergencyInfo.othersRecoverySession
|
||||
.firstWhereOrNull((session) => session.user.email == accountEmail);
|
||||
_fetchData();
|
||||
}
|
||||
|
||||
Future<void> _fetchData() async {
|
||||
try {
|
||||
final result = await EmergencyContactService.instance.getInfo();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
recoverySession = result.othersRecoverySession.firstWhereOrNull(
|
||||
(session) => session.user.email == accountEmail,
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (ignored) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_logger.info('session ${widget.emergencyInfo}');
|
||||
if (recoverySession != null) {
|
||||
final dateTime = DateTime.now().add(
|
||||
Duration(
|
||||
microseconds: recoverySession!.waitTill,
|
||||
),
|
||||
);
|
||||
waitTill = getFormattedTime(context, dateTime);
|
||||
}
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
final textTheme = getEnteTextTheme(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
const TitleBarTitleWidget(
|
||||
title: "Recover account",
|
||||
),
|
||||
Text(
|
||||
accountEmail,
|
||||
textAlign: TextAlign.left,
|
||||
style:
|
||||
textTheme.small.copyWith(color: colorScheme.textMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
recoverySession == null
|
||||
? Text(
|
||||
"You can recover $accountEmail account $recoverDelayTime"
|
||||
" after starting recovery process.",
|
||||
style: textTheme.body,
|
||||
)
|
||||
: Text(
|
||||
"You can recover $accountEmail's"
|
||||
" account after $waitTill ",
|
||||
style: textTheme.bodyBold,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (recoverySession == null)
|
||||
ButtonWidget(
|
||||
// icon: Icons.start_outlined,
|
||||
buttonType: ButtonType.trailingIconPrimary,
|
||||
icon: Icons.start_outlined,
|
||||
labelText: S.of(context).startAccountRecoveryTitle,
|
||||
onTap: widget.contact.isPendingInvite()
|
||||
? null
|
||||
: () async {
|
||||
final actionResult = await showChoiceActionSheet(
|
||||
context,
|
||||
title: S.of(context).startAccountRecoveryTitle,
|
||||
firstButtonLabel: S.of(context).yes,
|
||||
body: "Are you sure you want to initiate recovery?",
|
||||
isCritical: true,
|
||||
);
|
||||
if (actionResult?.action != null) {
|
||||
if (actionResult!.action == ButtonAction.first) {
|
||||
try {
|
||||
await EmergencyContactService.instance
|
||||
.startRecovery(widget.contact);
|
||||
if (mounted) {
|
||||
_fetchData().ignore();
|
||||
await showErrorDialog(
|
||||
context,
|
||||
"Done",
|
||||
"Please visit page after $recoverDelayTime to"
|
||||
" recover $accountEmail's account.",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
showGenericErrorDialog(context: context, error: e)
|
||||
.ignore();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// isTopBorderRadiusRemoved: true,
|
||||
),
|
||||
if (recoverySession != null && recoverySession!.status == "READY")
|
||||
ButtonWidget(
|
||||
// icon: Icons.start_outlined,
|
||||
buttonType: ButtonType.primary,
|
||||
labelText: "Recover account",
|
||||
onTap: () async {
|
||||
final (String key, KeyAttributes attributes) =
|
||||
await EmergencyContactService.instance
|
||||
.getRecoveryInfo(recoverySession!);
|
||||
routeToPage(
|
||||
context,
|
||||
RecoverOthersAccount(key, attributes, recoverySession!),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (recoverySession != null && recoverySession!.status == "WAITING")
|
||||
ButtonWidget(
|
||||
// icon: Icons.start_outlined,
|
||||
buttonType: ButtonType.neutral,
|
||||
labelText: S.of(context).cancelAccountRecovery,
|
||||
shouldSurfaceExecutionStates: false,
|
||||
onTap: () async {
|
||||
final actionResult = await showChoiceActionSheet(
|
||||
context,
|
||||
title: S.of(context).cancelAccountRecovery,
|
||||
firstButtonLabel: S.of(context).yes,
|
||||
body: S.of(context).cancelAccountRecoveryBody,
|
||||
isCritical: true,
|
||||
firstButtonOnTap: () async {
|
||||
EmergencyContactService.instance
|
||||
.stopRecovery(recoverySession!);
|
||||
},
|
||||
);
|
||||
if (actionResult?.action == ButtonAction.first) {
|
||||
_fetchData();
|
||||
}
|
||||
},
|
||||
),
|
||||
SizedBox(height: recoverySession == null ? 48 : 24),
|
||||
MenuSectionTitle(
|
||||
title: S.of(context).removeYourselfAsTrustedContact,
|
||||
),
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: CaptionedTextWidget(
|
||||
title: S.of(context).remove,
|
||||
textColor: warning500,
|
||||
makeTextBold: true,
|
||||
),
|
||||
leadingIcon: Icons.not_interested_outlined,
|
||||
leadingIconColor: warning500,
|
||||
menuItemColor: getEnteColorScheme(context).fillFaint,
|
||||
surfaceExecutionStates: false,
|
||||
onTap: () async {
|
||||
final actionResult = await showChoiceActionSheet(
|
||||
context,
|
||||
title: "Remove",
|
||||
firstButtonLabel: S.of(context).yes,
|
||||
body: "Are you sure your want to stop being a trusted "
|
||||
"contact for $accountEmail?",
|
||||
isCritical: true,
|
||||
firstButtonOnTap: () async {
|
||||
try {
|
||||
await EmergencyContactService.instance.updateContact(
|
||||
widget.contact,
|
||||
ContactState.ContactLeft,
|
||||
);
|
||||
Navigator.of(context).pop(true);
|
||||
} catch (e) {
|
||||
showGenericErrorDialog(context: context, error: e)
|
||||
.ignore();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
364
mobile/lib/emergency/recover_others_account.dart
Normal file
364
mobile/lib/emergency/recover_others_account.dart
Normal file
@@ -0,0 +1,364 @@
|
||||
import "dart:convert";
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:password_strength/password_strength.dart';
|
||||
import "package:photos/emergency/emergency_service.dart";
|
||||
import "package:photos/emergency/model.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/models/key_attributes.dart";
|
||||
import "package:photos/models/set_keys_request.dart";
|
||||
import 'package:photos/ui/common/dynamic_fab.dart';
|
||||
import "package:photos/utils/crypto_util.dart";
|
||||
import 'package:photos/utils/dialog_util.dart';
|
||||
import 'package:photos/utils/toast_util.dart';
|
||||
|
||||
class RecoverOthersAccount extends StatefulWidget {
|
||||
final String recoveryKey;
|
||||
final KeyAttributes attributes;
|
||||
final RecoverySessions sessions;
|
||||
|
||||
const RecoverOthersAccount(
|
||||
this.recoveryKey,
|
||||
this.attributes,
|
||||
this.sessions, {
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<RecoverOthersAccount> createState() => _RecoverOthersAccountState();
|
||||
}
|
||||
|
||||
class _RecoverOthersAccountState extends State<RecoverOthersAccount> {
|
||||
static const kMildPasswordStrengthThreshold = 0.4;
|
||||
static const kStrongPasswordStrengthThreshold = 0.7;
|
||||
|
||||
final _logger = Logger((_RecoverOthersAccountState).toString());
|
||||
final _passwordController1 = TextEditingController(),
|
||||
_passwordController2 = TextEditingController();
|
||||
final Color _validFieldValueColor = const Color.fromRGBO(45, 194, 98, 0.2);
|
||||
String _passwordInInputBox = '';
|
||||
String _passwordInInputConfirmationBox = '';
|
||||
double _passwordStrength = 0.0;
|
||||
bool _password1Visible = false;
|
||||
bool _password2Visible = false;
|
||||
final _password1FocusNode = FocusNode();
|
||||
final _password2FocusNode = FocusNode();
|
||||
bool _password1InFocus = false;
|
||||
bool _password2InFocus = false;
|
||||
|
||||
bool _passwordsMatch = false;
|
||||
bool _isPasswordValid = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_password1FocusNode.addListener(() {
|
||||
setState(() {
|
||||
_password1InFocus = _password1FocusNode.hasFocus;
|
||||
});
|
||||
});
|
||||
_password2FocusNode.addListener(() {
|
||||
setState(() {
|
||||
_password2InFocus = _password2FocusNode.hasFocus;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isKeypadOpen = MediaQuery.of(context).viewInsets.bottom > 100;
|
||||
|
||||
FloatingActionButtonLocation? fabLocation() {
|
||||
if (isKeypadOpen) {
|
||||
return null;
|
||||
} else {
|
||||
return FloatingActionButtonLocation.centerFloat;
|
||||
}
|
||||
}
|
||||
|
||||
String title = S.of(context).setPasswordTitle;
|
||||
title = S.of(context).resetPasswordTitle;
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: isKeypadOpen,
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
body: _getBody(title),
|
||||
floatingActionButton: DynamicFAB(
|
||||
isKeypadOpen: isKeypadOpen,
|
||||
isFormValid: _passwordsMatch && _isPasswordValid,
|
||||
buttonText: title,
|
||||
onPressedFunction: () {
|
||||
_updatePassword();
|
||||
FocusScope.of(context).unfocus();
|
||||
},
|
||||
),
|
||||
floatingActionButtonLocation: fabLocation(),
|
||||
floatingActionButtonAnimator: NoScalingAnimation(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getBody(String buttonTextAndHeading) {
|
||||
final email = widget.sessions!.user.email;
|
||||
var passwordStrengthText = S.of(context).weakStrength;
|
||||
var passwordStrengthColor = Colors.redAccent;
|
||||
if (_passwordStrength > kStrongPasswordStrengthThreshold) {
|
||||
passwordStrengthText = S.of(context).strongStrength;
|
||||
passwordStrengthColor = Colors.greenAccent;
|
||||
} else if (_passwordStrength > kMildPasswordStrengthThreshold) {
|
||||
passwordStrengthText = S.of(context).moderateStrength;
|
||||
passwordStrengthColor = Colors.orangeAccent;
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AutofillGroup(
|
||||
child: ListView(
|
||||
children: [
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 30, horizontal: 20),
|
||||
child: Text(
|
||||
buttonTextAndHeading,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Text(
|
||||
"Enter new password for $email account. You will be able "
|
||||
"to use this password to login into $email account.",
|
||||
textAlign: TextAlign.start,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium!
|
||||
.copyWith(fontSize: 14),
|
||||
),
|
||||
),
|
||||
const Padding(padding: EdgeInsets.all(12)),
|
||||
Visibility(
|
||||
// hidden textForm for suggesting auto-fill service for saving
|
||||
// password
|
||||
visible: false,
|
||||
child: TextFormField(
|
||||
autofillHints: const [
|
||||
AutofillHints.email,
|
||||
],
|
||||
autocorrect: false,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
initialValue: email,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
|
||||
child: TextFormField(
|
||||
autofillHints: const [AutofillHints.newPassword],
|
||||
decoration: InputDecoration(
|
||||
fillColor:
|
||||
_isPasswordValid ? _validFieldValueColor : null,
|
||||
filled: true,
|
||||
hintText: S.of(context).password,
|
||||
contentPadding: const EdgeInsets.all(20),
|
||||
border: UnderlineInputBorder(
|
||||
borderSide: BorderSide.none,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
suffixIcon: _password1InFocus
|
||||
? IconButton(
|
||||
icon: Icon(
|
||||
_password1Visible
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_password1Visible = !_password1Visible;
|
||||
});
|
||||
},
|
||||
)
|
||||
: _isPasswordValid
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: Theme.of(context)
|
||||
.inputDecorationTheme
|
||||
.focusedBorder!
|
||||
.borderSide
|
||||
.color,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
obscureText: !_password1Visible,
|
||||
controller: _passwordController1,
|
||||
autofocus: false,
|
||||
autocorrect: false,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
onChanged: (password) {
|
||||
setState(() {
|
||||
_passwordInInputBox = password;
|
||||
_passwordStrength = estimatePasswordStrength(password);
|
||||
_isPasswordValid =
|
||||
_passwordStrength >= kMildPasswordStrengthThreshold;
|
||||
_passwordsMatch = _passwordInInputBox ==
|
||||
_passwordInInputConfirmationBox;
|
||||
});
|
||||
},
|
||||
textInputAction: TextInputAction.next,
|
||||
focusNode: _password1FocusNode,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
|
||||
child: TextFormField(
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
controller: _passwordController2,
|
||||
obscureText: !_password2Visible,
|
||||
autofillHints: const [AutofillHints.newPassword],
|
||||
onEditingComplete: () => TextInput.finishAutofillContext(),
|
||||
decoration: InputDecoration(
|
||||
fillColor: _passwordsMatch ? _validFieldValueColor : null,
|
||||
filled: true,
|
||||
hintText: S.of(context).confirmPassword,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 20,
|
||||
),
|
||||
suffixIcon: _password2InFocus
|
||||
? IconButton(
|
||||
icon: Icon(
|
||||
_password2Visible
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_password2Visible = !_password2Visible;
|
||||
});
|
||||
},
|
||||
)
|
||||
: _passwordsMatch
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: Theme.of(context)
|
||||
.inputDecorationTheme
|
||||
.focusedBorder!
|
||||
.borderSide
|
||||
.color,
|
||||
)
|
||||
: null,
|
||||
border: UnderlineInputBorder(
|
||||
borderSide: BorderSide.none,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
focusNode: _password2FocusNode,
|
||||
onChanged: (cnfPassword) {
|
||||
setState(() {
|
||||
_passwordInInputConfirmationBox = cnfPassword;
|
||||
if (_passwordInInputBox != '') {
|
||||
_passwordsMatch = _passwordInInputBox ==
|
||||
_passwordInInputConfirmationBox;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Opacity(
|
||||
opacity:
|
||||
(_passwordInInputBox != '') && _password1InFocus ? 1 : 0,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: Text(
|
||||
S.of(context).passwordStrength(passwordStrengthText),
|
||||
style: TextStyle(
|
||||
color: passwordStrengthColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Padding(padding: EdgeInsets.all(20)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _updatePassword() async {
|
||||
final dialog =
|
||||
createProgressDialog(context, S.of(context).generatingEncryptionKeys);
|
||||
await dialog.show();
|
||||
try {
|
||||
final String password = _passwordController1.text;
|
||||
final KeyAttributes attributes = widget.attributes;
|
||||
Uint8List? masterKey;
|
||||
try {
|
||||
// Decrypt the master key that was earlier encrypted with the recovery key
|
||||
masterKey = await CryptoUtil.decrypt(
|
||||
CryptoUtil.base642bin(attributes.masterKeyEncryptedWithRecoveryKey!),
|
||||
CryptoUtil.hex2bin(widget.recoveryKey),
|
||||
CryptoUtil.base642bin(attributes.masterKeyDecryptionNonce!),
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.severe(e, "Failed to get master key using recoveryKey");
|
||||
rethrow;
|
||||
}
|
||||
|
||||
// Derive a key from the password that will be used to encrypt and
|
||||
// decrypt the master key
|
||||
final kekSalt = CryptoUtil.getSaltToDeriveKey();
|
||||
final derivedKeyResult = await CryptoUtil.deriveSensitiveKey(
|
||||
utf8.encode(password) as Uint8List,
|
||||
kekSalt,
|
||||
);
|
||||
final loginKey = await CryptoUtil.deriveLoginKey(derivedKeyResult.key);
|
||||
// Encrypt the key with this derived key
|
||||
final encryptedKeyData =
|
||||
CryptoUtil.encryptSync(masterKey, derivedKeyResult.key);
|
||||
|
||||
final updatedAttributes = attributes.copyWith(
|
||||
kekSalt: CryptoUtil.bin2base64(kekSalt),
|
||||
encryptedKey: CryptoUtil.bin2base64(encryptedKeyData.encryptedData!),
|
||||
keyDecryptionNonce: CryptoUtil.bin2base64(encryptedKeyData.nonce!),
|
||||
memLimit: derivedKeyResult.memLimit,
|
||||
opsLimit: derivedKeyResult.opsLimit,
|
||||
);
|
||||
final setKeyRequest = SetKeysRequest(
|
||||
kekSalt: updatedAttributes.kekSalt,
|
||||
encryptedKey: updatedAttributes.encryptedKey,
|
||||
keyDecryptionNonce: updatedAttributes.keyDecryptionNonce,
|
||||
memLimit: updatedAttributes.memLimit!,
|
||||
opsLimit: updatedAttributes.opsLimit!,
|
||||
);
|
||||
await EmergencyContactService.instance.changePasswordForOther(
|
||||
loginKey,
|
||||
setKeyRequest,
|
||||
widget.sessions,
|
||||
);
|
||||
await dialog.hide();
|
||||
showShortToast(context, S.of(context).passwordChangedSuccessfully);
|
||||
Navigator.of(context).pop();
|
||||
} catch (e, s) {
|
||||
_logger.severe(e, s);
|
||||
await dialog.hide();
|
||||
showGenericErrorDialog(context: context, error: e).ignore();
|
||||
}
|
||||
}
|
||||
}
|
||||
352
mobile/lib/emergency/select_contact_page.dart
Normal file
352
mobile/lib/emergency/select_contact_page.dart
Normal file
@@ -0,0 +1,352 @@
|
||||
import 'package:email_validator/email_validator.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import "package:logging/logging.dart";
|
||||
import 'package:photos/core/configuration.dart';
|
||||
import "package:photos/emergency/emergency_service.dart";
|
||||
import "package:photos/emergency/model.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/models/api/collection/user.dart";
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import "package:photos/services/user_service.dart";
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/actions/collection/collection_sharing_actions.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/ui/components/captioned_text_widget.dart';
|
||||
import 'package:photos/ui/components/divider_widget.dart';
|
||||
import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart';
|
||||
import 'package:photos/ui/components/menu_section_description_widget.dart';
|
||||
import 'package:photos/ui/components/menu_section_title.dart';
|
||||
import 'package:photos/ui/components/models/button_type.dart';
|
||||
import 'package:photos/ui/sharing/user_avator_widget.dart';
|
||||
import "package:photos/ui/sharing/verify_identity_dialog.dart";
|
||||
import "package:photos/utils/dialog_util.dart";
|
||||
|
||||
class AddContactPage extends StatefulWidget {
|
||||
final EmergencyInfo emergencyInfo;
|
||||
|
||||
const AddContactPage(this.emergencyInfo, {super.key});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _AddContactPage();
|
||||
}
|
||||
|
||||
class _AddContactPage extends State<AddContactPage> {
|
||||
String selectedEmail = '';
|
||||
String _email = '';
|
||||
bool isEmailListEmpty = false;
|
||||
bool _emailIsValid = false;
|
||||
bool isKeypadOpen = false;
|
||||
late CollectionActions collectionActions;
|
||||
late final Logger _logger = Logger('AddContactPage');
|
||||
|
||||
// Focus nodes are necessary
|
||||
final textFieldFocusNode = FocusNode();
|
||||
final _textController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
collectionActions = CollectionActions(CollectionsService.instance);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
isKeypadOpen = MediaQuery.of(context).viewInsets.bottom > 100;
|
||||
final enteTextTheme = getEnteTextTheme(context);
|
||||
final enteColorScheme = getEnteColorScheme(context);
|
||||
final List<User> suggestedUsers = _getSuggestedUser();
|
||||
isEmailListEmpty = suggestedUsers.isEmpty;
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: isKeypadOpen,
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
S.of(context).addTrustedContact,
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Text(
|
||||
S.of(context).addANewEmail,
|
||||
style: enteTextTheme.small
|
||||
.copyWith(color: enteColorScheme.textMuted),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: _getEmailField(),
|
||||
),
|
||||
if (isEmailListEmpty)
|
||||
const Expanded(child: SizedBox.shrink())
|
||||
else
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
!isEmailListEmpty
|
||||
? MenuSectionTitle(
|
||||
title: S.of(context).orPickAnExistingOne,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= suggestedUsers.length) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: MenuSectionDescriptionWidget(
|
||||
content: S.of(context).whyAddTrustContact,
|
||||
),
|
||||
);
|
||||
}
|
||||
final currentUser = suggestedUsers[index];
|
||||
return Column(
|
||||
children: [
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: CaptionedTextWidget(
|
||||
title: currentUser.email,
|
||||
),
|
||||
leadingIconSize: 24.0,
|
||||
leadingIconWidget: UserAvatarWidget(
|
||||
currentUser,
|
||||
type: AvatarType.mini,
|
||||
),
|
||||
menuItemColor:
|
||||
getEnteColorScheme(context).fillFaint,
|
||||
pressedColor:
|
||||
getEnteColorScheme(context).fillFaint,
|
||||
trailingIcon:
|
||||
(selectedEmail == currentUser.email)
|
||||
? Icons.check
|
||||
: null,
|
||||
onTap: () async {
|
||||
textFieldFocusNode.unfocus();
|
||||
if (selectedEmail == currentUser.email) {
|
||||
selectedEmail = '';
|
||||
} else {
|
||||
selectedEmail = currentUser.email;
|
||||
}
|
||||
setState(() => {});
|
||||
},
|
||||
isTopBorderRadiusRemoved: index > 0,
|
||||
isBottomBorderRadiusRemoved:
|
||||
index < (suggestedUsers.length - 1),
|
||||
),
|
||||
(index == (suggestedUsers.length - 1))
|
||||
? const SizedBox.shrink()
|
||||
: DividerWidget(
|
||||
dividerType: DividerType.menu,
|
||||
bgColor:
|
||||
getEnteColorScheme(context).fillFaint,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
itemCount: suggestedUsers.length + 1,
|
||||
// physics: const ClampingScrollPhysics(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
left: 16,
|
||||
right: 16,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.primary,
|
||||
buttonSize: ButtonSize.large,
|
||||
labelText: "Add",
|
||||
isDisabled: (selectedEmail == '' && !_emailIsValid),
|
||||
onTap: (selectedEmail == '' && !_emailIsValid)
|
||||
? null
|
||||
: () async {
|
||||
final emailToAdd =
|
||||
selectedEmail == '' ? _email : selectedEmail;
|
||||
try {
|
||||
final result = await EmergencyContactService
|
||||
.instance
|
||||
.addContact(context, emailToAdd);
|
||||
if (result && mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.severe('Failed to add contact', e);
|
||||
await showErrorDialog(
|
||||
context,
|
||||
S.of(context).error,
|
||||
S.of(context).somethingWentWrong,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
if ((selectedEmail == '' && !_emailIsValid)) {
|
||||
await showErrorDialog(
|
||||
context,
|
||||
S.of(context).invalidEmailAddress,
|
||||
S.of(context).enterValidEmail,
|
||||
);
|
||||
return;
|
||||
}
|
||||
final emailToAdd =
|
||||
selectedEmail == '' ? _email : selectedEmail;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return VerifyIdentifyDialog(
|
||||
self: false,
|
||||
email: emailToAdd,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
S.of(context).verifyIDLabel,
|
||||
textAlign: TextAlign.center,
|
||||
style: enteTextTheme.smallMuted.copyWith(
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void clearFocus() {
|
||||
_textController.clear();
|
||||
_email = _textController.text;
|
||||
_emailIsValid = false;
|
||||
textFieldFocusNode.unfocus();
|
||||
setState(() => {});
|
||||
}
|
||||
|
||||
Widget _getEmailField() {
|
||||
return TextFormField(
|
||||
controller: _textController,
|
||||
focusNode: textFieldFocusNode,
|
||||
style: getEnteTextTheme(context).body,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
decoration: InputDecoration(
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
|
||||
borderSide:
|
||||
BorderSide(color: getEnteColorScheme(context).strokeMuted),
|
||||
),
|
||||
fillColor: getEnteColorScheme(context).fillFaint,
|
||||
filled: true,
|
||||
hintText: S.of(context).enterEmail,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
border: UnderlineInputBorder(
|
||||
borderSide: BorderSide.none,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Icons.email_outlined,
|
||||
color: getEnteColorScheme(context).strokeMuted,
|
||||
),
|
||||
suffixIcon: _email == ''
|
||||
? null
|
||||
: IconButton(
|
||||
onPressed: clearFocus,
|
||||
icon: Icon(
|
||||
Icons.cancel,
|
||||
color: getEnteColorScheme(context).strokeMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
if (selectedEmail != '') {
|
||||
selectedEmail = '';
|
||||
}
|
||||
_email = value.trim();
|
||||
_emailIsValid = EmailValidator.validate(_email);
|
||||
setState(() {});
|
||||
},
|
||||
autocorrect: false,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
//initialValue: _email,
|
||||
textInputAction: TextInputAction.next,
|
||||
);
|
||||
}
|
||||
|
||||
List<User> _getSuggestedUser() {
|
||||
final List<User> suggestedUsers = [];
|
||||
final Set<String> existingEmails = {};
|
||||
final int ownerID = Configuration.instance.getUserID()!;
|
||||
existingEmails.add(Configuration.instance.getEmail()!);
|
||||
for (final c in CollectionsService.instance.getActiveCollections()) {
|
||||
if (c.owner?.id == ownerID) {
|
||||
for (final User? u in c.sharees ?? []) {
|
||||
if (u != null &&
|
||||
u.id != null &&
|
||||
u.email.isNotEmpty &&
|
||||
!existingEmails.contains(u.email)) {
|
||||
existingEmails.add(u.email);
|
||||
suggestedUsers.add(u);
|
||||
}
|
||||
}
|
||||
} else if (c.owner != null &&
|
||||
c.owner!.id != null &&
|
||||
c.owner!.email.isNotEmpty &&
|
||||
!existingEmails.contains(c.owner!.email)) {
|
||||
existingEmails.add(c.owner!.email);
|
||||
suggestedUsers.add(c.owner!);
|
||||
}
|
||||
}
|
||||
final cachedUserDetails = UserService.instance.getCachedUserDetails();
|
||||
if (cachedUserDetails != null &&
|
||||
(cachedUserDetails.familyData?.members?.isNotEmpty ?? false)) {
|
||||
for (final member in cachedUserDetails.familyData!.members!) {
|
||||
if (!existingEmails.contains(member.email)) {
|
||||
existingEmails.add(member.email);
|
||||
suggestedUsers.add(User(email: member.email));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (_textController.text.trim().isNotEmpty) {
|
||||
suggestedUsers.removeWhere(
|
||||
(element) => !element.email
|
||||
.toLowerCase()
|
||||
.contains(_textController.text.trim().toLowerCase()),
|
||||
);
|
||||
}
|
||||
suggestedUsers.sort((a, b) => a.email.compareTo(b.email));
|
||||
|
||||
return suggestedUsers;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import 'package:photos/services/user_service.dart';
|
||||
import 'package:photos/theme/colors.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/common/progress_dialog.dart';
|
||||
import "package:photos/ui/common/user_dialogs.dart";
|
||||
import 'package:photos/ui/components/action_sheet_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/ui/components/dialog_widget.dart';
|
||||
@@ -233,28 +234,7 @@ class CollectionActions {
|
||||
if (publicKey == null || publicKey == '') {
|
||||
// todo: neeraj replace this as per the design where a new screen
|
||||
// is used for error. Do this change along with handling of network errors
|
||||
await showDialogWidget(
|
||||
context: context,
|
||||
title: S.of(context).inviteToEnte,
|
||||
icon: Icons.info_outline,
|
||||
body: S.of(context).emailNoEnteAccount(email),
|
||||
isDismissible: true,
|
||||
buttons: [
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.neutral,
|
||||
icon: Icons.adaptive.share,
|
||||
labelText: S.of(context).sendInvite,
|
||||
isInAlert: true,
|
||||
onTap: () async {
|
||||
unawaited(
|
||||
shareText(
|
||||
S.of(context).shareTextRecommendUsingEnte,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
await showInviteDialog(context, email);
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
|
||||
33
mobile/lib/ui/common/user_dialogs.dart
Normal file
33
mobile/lib/ui/common/user_dialogs.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import "dart:async";
|
||||
|
||||
import "package:flutter/material.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/ui/components/buttons/button_widget.dart";
|
||||
import "package:photos/ui/components/dialog_widget.dart";
|
||||
import "package:photos/ui/components/models/button_type.dart";
|
||||
import "package:photos/utils/share_util.dart";
|
||||
|
||||
Future<void> showInviteDialog(BuildContext context, String email) async {
|
||||
await showDialogWidget(
|
||||
context: context,
|
||||
title: S.of(context).inviteToEnte,
|
||||
icon: Icons.info_outline,
|
||||
body: S.of(context).emailNoEnteAccount(email),
|
||||
isDismissible: true,
|
||||
buttons: [
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.neutral,
|
||||
icon: Icons.adaptive.share,
|
||||
labelText: S.of(context).sendInvite,
|
||||
isInAlert: true,
|
||||
onTap: () async {
|
||||
unawaited(
|
||||
shareText(
|
||||
S.of(context).shareTextRecommendUsingEnte,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ enum NotificationType {
|
||||
|
||||
class NotificationWidget extends StatelessWidget {
|
||||
final IconData startIcon;
|
||||
final IconData actionIcon;
|
||||
final IconData? actionIcon;
|
||||
final String text;
|
||||
final String? subText;
|
||||
final GestureTapCallback onTap;
|
||||
@@ -155,14 +155,15 @@ class NotificationWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
IconButtonWidget(
|
||||
icon: actionIcon,
|
||||
iconButtonType: IconButtonType.rounded,
|
||||
iconColor: strokeColorScheme.strokeBase,
|
||||
defaultColor: strokeColorScheme.fillFaint,
|
||||
pressedColor: strokeColorScheme.fillMuted,
|
||||
onTap: onTap,
|
||||
),
|
||||
if (actionIcon != null)
|
||||
IconButtonWidget(
|
||||
icon: actionIcon!,
|
||||
iconButtonType: IconButtonType.rounded,
|
||||
iconColor: strokeColorScheme.strokeBase,
|
||||
defaultColor: strokeColorScheme.fillFaint,
|
||||
pressedColor: strokeColorScheme.fillMuted,
|
||||
onTap: onTap,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import "package:flutter/foundation.dart";
|
||||
import 'package:flutter/material.dart';
|
||||
import "package:photos/emergency/emergency_page.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import 'package:photos/services/local_authentication_service.dart';
|
||||
import 'package:photos/services/user_service.dart';
|
||||
@@ -142,6 +144,33 @@ class AccountSectionWidget extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: CaptionedTextWidget(
|
||||
title: S.of(context).trustedContacts,
|
||||
),
|
||||
pressedColor: getEnteColorScheme(context).fillFaint,
|
||||
trailingIcon: Icons.chevron_right_outlined,
|
||||
trailingIconIsMuted: true,
|
||||
showOnlyLoadingState: true,
|
||||
onTap: () async {
|
||||
final hasAuthenticated = kDebugMode ||
|
||||
await LocalAuthenticationService.instance
|
||||
.requestLocalAuthentication(
|
||||
context,
|
||||
S.of(context).authToChangeYourPassword,
|
||||
);
|
||||
if (hasAuthenticated) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return const EmergencyPage();
|
||||
},
|
||||
),
|
||||
).ignore();
|
||||
}
|
||||
},
|
||||
),
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: CaptionedTextWidget(
|
||||
title: S.of(context).exportYourData,
|
||||
|
||||
Reference in New Issue
Block a user