536 lines
		
	
	
		
			21 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			536 lines
		
	
	
		
			21 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'dart:io';
 | |
| 
 | |
| import 'package:easy_localization/easy_localization.dart';
 | |
| import 'package:flutter/foundation.dart';
 | |
| import 'package:flutter/material.dart';
 | |
| import 'package:flutter/services.dart';
 | |
| import 'package:gap/gap.dart';
 | |
| import 'package:hooks_riverpod/hooks_riverpod.dart';
 | |
| import 'package:island/models/auth.dart';
 | |
| import 'package:island/models/account.dart';
 | |
| import 'package:island/pods/network.dart';
 | |
| import 'package:island/pods/userinfo.dart';
 | |
| import 'package:island/screens/account/me/settings_auth_factors.dart';
 | |
| import 'package:island/screens/account/me/settings_connections.dart';
 | |
| import 'package:island/screens/account/me/settings_contacts.dart';
 | |
| import 'package:island/screens/auth/captcha.dart';
 | |
| import 'package:island/screens/auth/login.dart';
 | |
| import 'package:island/widgets/account/account_devices.dart';
 | |
| import 'package:island/widgets/alert.dart';
 | |
| import 'package:island/widgets/app_scaffold.dart';
 | |
| import 'package:island/widgets/response.dart';
 | |
| import 'package:material_symbols_icons/symbols.dart';
 | |
| import 'package:riverpod_annotation/riverpod_annotation.dart';
 | |
| import 'package:styled_widget/styled_widget.dart';
 | |
| 
 | |
| part 'account_settings.g.dart';
 | |
| 
 | |
| @riverpod
 | |
| Future<List<SnAuthFactor>> authFactors(Ref ref) async {
 | |
|   final client = ref.read(apiClientProvider);
 | |
|   final res = await client.get('/pass/accounts/me/factors');
 | |
|   return res.data.map<SnAuthFactor>((e) => SnAuthFactor.fromJson(e)).toList();
 | |
| }
 | |
| 
 | |
| @riverpod
 | |
| Future<List<SnContactMethod>> contactMethods(Ref ref) async {
 | |
|   final client = ref.read(apiClientProvider);
 | |
|   final resp = await client.get('/pass/accounts/me/contacts');
 | |
|   return resp.data
 | |
|       .map<SnContactMethod>((e) => SnContactMethod.fromJson(e))
 | |
|       .toList();
 | |
| }
 | |
| 
 | |
| @riverpod
 | |
| Future<List<SnAccountConnection>> accountConnections(Ref ref) async {
 | |
|   final client = ref.read(apiClientProvider);
 | |
|   final resp = await client.get('/pass/accounts/me/connections');
 | |
|   return resp.data
 | |
|       .map<SnAccountConnection>((e) => SnAccountConnection.fromJson(e))
 | |
|       .toList();
 | |
| }
 | |
| 
 | |
| class AccountSettingsScreen extends HookConsumerWidget {
 | |
|   const AccountSettingsScreen({super.key});
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context, WidgetRef ref) {
 | |
|     final isDesktop =
 | |
|         !kIsWeb && (Platform.isWindows || Platform.isMacOS || Platform.isLinux);
 | |
| 
 | |
|     Future<void> requestAccountDeletion() async {
 | |
|       final confirm = await showConfirmAlert(
 | |
|         'accountDeletionHint'.tr(),
 | |
|         'accountDeletion'.tr(),
 | |
|       );
 | |
|       if (!confirm || !context.mounted) return;
 | |
|       try {
 | |
|         showLoadingModal(context);
 | |
|         final client = ref.read(apiClientProvider);
 | |
|         await client.delete('/pass/accounts/me');
 | |
|         if (context.mounted) {
 | |
|           showSnackBar('accountDeletionSent'.tr());
 | |
|         }
 | |
|       } catch (err) {
 | |
|         showErrorAlert(err);
 | |
|       } finally {
 | |
|         if (context.mounted) hideLoadingModal(context);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     Future<void> requestResetPassword() async {
 | |
|       final confirm = await showConfirmAlert(
 | |
|         'accountPasswordChangeDescription'.tr(),
 | |
|         'accountPasswordChange'.tr(),
 | |
|       );
 | |
|       if (!confirm || !context.mounted) return;
 | |
|       final captchaTk = await Navigator.of(
 | |
|         context,
 | |
|       ).push(MaterialPageRoute(builder: (context) => CaptchaScreen()));
 | |
|       if (captchaTk == null) return;
 | |
|       try {
 | |
|         if (context.mounted) showLoadingModal(context);
 | |
|         final userInfo = ref.read(userInfoProvider);
 | |
|         final client = ref.read(apiClientProvider);
 | |
|         await client.post(
 | |
|           '/pass/accounts/recovery/password',
 | |
|           data: {'account': userInfo.value!.name, 'captcha_token': captchaTk},
 | |
|         );
 | |
|         if (context.mounted) {
 | |
|           showSnackBar('accountPasswordChangeSent'.tr());
 | |
|         }
 | |
|       } catch (err) {
 | |
|         showErrorAlert(err);
 | |
|       } finally {
 | |
|         if (context.mounted) hideLoadingModal(context);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     final authFactors = ref.watch(authFactorsProvider);
 | |
| 
 | |
|     // Group settings into categories for better organization
 | |
|     final securitySettings = [
 | |
|       ListTile(
 | |
|         minLeadingWidth: 48,
 | |
|         leading: const Icon(Symbols.devices),
 | |
|         title: Text('authSessions').tr(),
 | |
|         subtitle: Text('authSessionsDescription').tr().fontSize(12),
 | |
|         contentPadding: const EdgeInsets.only(left: 24, right: 17),
 | |
|         trailing: const Icon(Symbols.chevron_right),
 | |
|         onTap: () {
 | |
|           showModalBottomSheet(
 | |
|             context: context,
 | |
|             isScrollControlled: true,
 | |
|             builder: (context) => const AccountSessionSheet(),
 | |
|           );
 | |
|         },
 | |
|       ),
 | |
|       ExpansionTile(
 | |
|         leading: const Icon(
 | |
|           Symbols.link,
 | |
|         ).alignment(Alignment.centerLeft).width(48),
 | |
|         title: Text('accountConnections').tr(),
 | |
|         subtitle: Text('accountConnectionsDescription').tr().fontSize(12),
 | |
|         tilePadding: const EdgeInsets.only(left: 24, right: 17),
 | |
|         children: [
 | |
|           ref
 | |
|               .watch(accountConnectionsProvider)
 | |
|               .when(
 | |
|                 data:
 | |
|                     (connections) => Column(
 | |
|                       children: [
 | |
|                         for (final connection in connections)
 | |
|                           ListTile(
 | |
|                             minLeadingWidth: 48,
 | |
|                             contentPadding: const EdgeInsets.only(
 | |
|                               left: 16,
 | |
|                               right: 17,
 | |
|                               top: 2,
 | |
|                               bottom: 4,
 | |
|                             ),
 | |
|                             title:
 | |
|                                 Text(
 | |
|                                   getLocalizedProviderName(connection.provider),
 | |
|                                 ).tr(),
 | |
|                             subtitle:
 | |
|                                 connection.meta['email'] != null
 | |
|                                     ? Text(connection.meta['email'])
 | |
|                                     : Text(connection.providedIdentifier),
 | |
|                             leading: CircleAvatar(
 | |
|                               child: getProviderIcon(
 | |
|                                 connection.provider,
 | |
|                                 size: 16,
 | |
|                                 color:
 | |
|                                     Theme.of(
 | |
|                                       context,
 | |
|                                     ).colorScheme.onPrimaryContainer,
 | |
|                               ),
 | |
|                             ).padding(top: 4),
 | |
|                             trailing: const Icon(Symbols.chevron_right),
 | |
|                             onTap: () {
 | |
|                               showModalBottomSheet(
 | |
|                                 context: context,
 | |
|                                 builder:
 | |
|                                     (context) => AccountConnectionSheet(
 | |
|                                       connection: connection,
 | |
|                                     ),
 | |
|                               ).then((value) {
 | |
|                                 if (value == true) {
 | |
|                                   ref.invalidate(accountConnectionsProvider);
 | |
|                                 }
 | |
|                               });
 | |
|                             },
 | |
|                           ),
 | |
|                         if (connections.isNotEmpty) const Divider(height: 1),
 | |
|                         ListTile(
 | |
|                           minLeadingWidth: 48,
 | |
|                           contentPadding: const EdgeInsets.only(
 | |
|                             left: 24,
 | |
|                             right: 17,
 | |
|                           ),
 | |
|                           title: Text('accountConnectionAdd').tr(),
 | |
|                           leading: const Icon(Symbols.add),
 | |
|                           trailing: const Icon(Symbols.chevron_right),
 | |
|                           onTap: () {
 | |
|                             showModalBottomSheet(
 | |
|                               context: context,
 | |
|                               builder:
 | |
|                                   (context) =>
 | |
|                                       const AccountConnectionNewSheet(),
 | |
|                             ).then((value) {
 | |
|                               if (value == true) {
 | |
|                                 ref.invalidate(accountConnectionsProvider);
 | |
|                               }
 | |
|                             });
 | |
|                           },
 | |
|                         ),
 | |
|                       ],
 | |
|                     ),
 | |
|                 error:
 | |
|                     (err, _) => ResponseErrorWidget(
 | |
|                       error: err,
 | |
|                       onRetry: () => ref.invalidate(accountConnectionsProvider),
 | |
|                     ),
 | |
|                 loading: () => const ResponseLoadingWidget(),
 | |
|               ),
 | |
|         ],
 | |
|       ),
 | |
|       ExpansionTile(
 | |
|         leading: const Icon(
 | |
|           Symbols.security,
 | |
|         ).alignment(Alignment.centerLeft).width(48),
 | |
|         title: Text('accountAuthFactor').tr(),
 | |
|         subtitle: Text('accountAuthFactorDescription').tr().fontSize(12),
 | |
|         tilePadding: const EdgeInsets.only(left: 24, right: 17),
 | |
|         children: [
 | |
|           authFactors.when(
 | |
|             data:
 | |
|                 (factors) => Column(
 | |
|                   children: [
 | |
|                     for (final factor in factors)
 | |
|                       ListTile(
 | |
|                         minLeadingWidth: 48,
 | |
|                         contentPadding: const EdgeInsets.only(
 | |
|                           left: 16,
 | |
|                           right: 17,
 | |
|                           top: 2,
 | |
|                           bottom: 4,
 | |
|                         ),
 | |
|                         title:
 | |
|                             Text(
 | |
|                               kFactorTypes[factor.type]!.$1,
 | |
|                               style:
 | |
|                                   factor.enabledAt == null
 | |
|                                       ? TextStyle(
 | |
|                                         decoration: TextDecoration.lineThrough,
 | |
|                                       )
 | |
|                                       : null,
 | |
|                             ).tr(),
 | |
|                         subtitle:
 | |
|                             Text(
 | |
|                               kFactorTypes[factor.type]!.$2,
 | |
|                               style:
 | |
|                                   factor.enabledAt == null
 | |
|                                       ? TextStyle(
 | |
|                                         decoration: TextDecoration.lineThrough,
 | |
|                                       )
 | |
|                                       : null,
 | |
|                             ).tr(),
 | |
|                         leading: CircleAvatar(
 | |
|                           backgroundColor:
 | |
|                               factor.enabledAt == null
 | |
|                                   ? Theme.of(
 | |
|                                     context,
 | |
|                                   ).colorScheme.secondaryContainer
 | |
|                                   : Theme.of(
 | |
|                                     context,
 | |
|                                   ).colorScheme.primaryContainer,
 | |
|                           child: Icon(kFactorTypes[factor.type]!.$3),
 | |
|                         ).padding(top: 4),
 | |
|                         trailing: const Icon(Symbols.chevron_right),
 | |
|                         isThreeLine: true,
 | |
|                         onTap: () {
 | |
|                           if (factor.type == 0) {
 | |
|                             requestResetPassword();
 | |
|                             return;
 | |
|                           }
 | |
|                           showModalBottomSheet(
 | |
|                             context: context,
 | |
|                             builder:
 | |
|                                 (context) => AuthFactorSheet(factor: factor),
 | |
|                           ).then((value) {
 | |
|                             if (value == true) {
 | |
|                               ref.invalidate(authFactorsProvider);
 | |
|                             }
 | |
|                           });
 | |
|                         },
 | |
|                       ),
 | |
|                     if (factors.isNotEmpty) Divider(height: 1),
 | |
|                     ListTile(
 | |
|                       minLeadingWidth: 48,
 | |
|                       contentPadding: const EdgeInsets.only(
 | |
|                         left: 24,
 | |
|                         right: 17,
 | |
|                       ),
 | |
|                       title: Text('authFactorNew').tr(),
 | |
|                       leading: const Icon(Symbols.add),
 | |
|                       trailing: const Icon(Symbols.chevron_right),
 | |
|                       onTap: () {
 | |
|                         showModalBottomSheet(
 | |
|                           context: context,
 | |
|                           builder: (context) => const AuthFactorNewSheet(),
 | |
|                         ).then((value) {
 | |
|                           if (value == true) {
 | |
|                             ref.invalidate(authFactorsProvider);
 | |
|                           }
 | |
|                         });
 | |
|                       },
 | |
|                     ),
 | |
|                   ],
 | |
|                 ),
 | |
|             error:
 | |
|                 (err, _) => ResponseErrorWidget(
 | |
|                   error: err,
 | |
|                   onRetry: () => ref.invalidate(authFactorsProvider),
 | |
|                 ),
 | |
|             loading: () => ResponseLoadingWidget(),
 | |
|           ),
 | |
|         ],
 | |
|       ),
 | |
|       ExpansionTile(
 | |
|         leading: const Icon(
 | |
|           Symbols.contact_mail,
 | |
|         ).alignment(Alignment.centerLeft).width(48),
 | |
|         title: Text('accountContactMethod').tr(),
 | |
|         subtitle: Text('accountContactMethodDescription').tr().fontSize(12),
 | |
|         tilePadding: const EdgeInsets.only(left: 24, right: 17),
 | |
|         children: [
 | |
|           ref
 | |
|               .watch(contactMethodsProvider)
 | |
|               .when(
 | |
|                 data:
 | |
|                     (contacts) => Column(
 | |
|                       children: [
 | |
|                         for (final contact in contacts)
 | |
|                           ListTile(
 | |
|                             minLeadingWidth: 48,
 | |
|                             contentPadding: const EdgeInsets.only(
 | |
|                               left: 16,
 | |
|                               right: 17,
 | |
|                               top: 2,
 | |
|                               bottom: 4,
 | |
|                             ),
 | |
|                             title: Text(
 | |
|                               contact.content,
 | |
|                               style:
 | |
|                                   contact.verifiedAt == null
 | |
|                                       ? TextStyle(
 | |
|                                         decoration: TextDecoration.lineThrough,
 | |
|                                       )
 | |
|                                       : null,
 | |
|                             ),
 | |
|                             subtitle: Text(
 | |
|                               contact.type == 0
 | |
|                                   ? 'contactMethodTypeEmail'.tr()
 | |
|                                   : 'contactMethodTypePhone'.tr(),
 | |
|                               style:
 | |
|                                   contact.verifiedAt == null
 | |
|                                       ? TextStyle(
 | |
|                                         decoration: TextDecoration.lineThrough,
 | |
|                                       )
 | |
|                                       : null,
 | |
|                             ),
 | |
|                             leading: CircleAvatar(
 | |
|                               backgroundColor:
 | |
|                                   contact.verifiedAt == null
 | |
|                                       ? Theme.of(
 | |
|                                         context,
 | |
|                                       ).colorScheme.secondaryContainer
 | |
|                                       : Theme.of(
 | |
|                                         context,
 | |
|                                       ).colorScheme.primaryContainer,
 | |
|                               child: Icon(
 | |
|                                 contact.type == 0
 | |
|                                     ? Symbols.mail
 | |
|                                     : Symbols.phone,
 | |
|                               ),
 | |
|                             ).padding(top: 4),
 | |
|                             trailing: const Icon(Symbols.chevron_right),
 | |
|                             isThreeLine: false,
 | |
|                             onTap: () {
 | |
|                               showModalBottomSheet(
 | |
|                                 context: context,
 | |
|                                 builder:
 | |
|                                     (context) =>
 | |
|                                         ContactMethodSheet(contact: contact),
 | |
|                               ).then((value) {
 | |
|                                 if (value == true) {
 | |
|                                   ref.invalidate(contactMethodsProvider);
 | |
|                                 }
 | |
|                               });
 | |
|                             },
 | |
|                           ),
 | |
|                         if (contacts.isNotEmpty) const Divider(height: 1),
 | |
|                         ListTile(
 | |
|                           minLeadingWidth: 48,
 | |
|                           contentPadding: const EdgeInsets.only(
 | |
|                             left: 24,
 | |
|                             right: 17,
 | |
|                           ),
 | |
|                           title: Text('contactMethodNew').tr(),
 | |
|                           leading: const Icon(Symbols.add),
 | |
|                           trailing: const Icon(Symbols.chevron_right),
 | |
|                           onTap: () {
 | |
|                             showModalBottomSheet(
 | |
|                               context: context,
 | |
|                               builder:
 | |
|                                   (context) => const ContactMethodNewSheet(),
 | |
|                             ).then((value) {
 | |
|                               if (value == true) {
 | |
|                                 ref.invalidate(contactMethodsProvider);
 | |
|                               }
 | |
|                             });
 | |
|                           },
 | |
|                         ),
 | |
|                       ],
 | |
|                     ),
 | |
|                 error:
 | |
|                     (err, _) => ResponseErrorWidget(
 | |
|                       error: err,
 | |
|                       onRetry: () => ref.invalidate(contactMethodsProvider),
 | |
|                     ),
 | |
|                 loading: () => const ResponseLoadingWidget(),
 | |
|               ),
 | |
|         ],
 | |
|       ),
 | |
|     ];
 | |
| 
 | |
|     final dangerZoneSettings = [
 | |
|       ListTile(
 | |
|         minLeadingWidth: 48,
 | |
|         title: Text('accountDeletion').tr(),
 | |
|         subtitle: Text('accountDeletionDescription').tr().fontSize(12),
 | |
|         contentPadding: const EdgeInsets.only(left: 24, right: 17),
 | |
|         leading: const Icon(Symbols.delete_forever, color: Colors.red),
 | |
|         trailing: const Icon(Symbols.chevron_right),
 | |
|         onTap: requestAccountDeletion,
 | |
|       ),
 | |
|     ];
 | |
| 
 | |
|     // Create a responsive layout based on screen width
 | |
|     Widget buildSettingsList() {
 | |
|       return Column(
 | |
|         crossAxisAlignment: CrossAxisAlignment.start,
 | |
|         children: [
 | |
|           _SettingsSection(
 | |
|             title: 'accountSecurityTitle',
 | |
|             children: securitySettings,
 | |
|           ),
 | |
|           _SettingsSection(
 | |
|             title: 'accountDangerZoneTitle',
 | |
|             children: dangerZoneSettings,
 | |
|           ),
 | |
|         ],
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     return AppScaffold(
 | |
|       appBar: AppBar(
 | |
|         title: Text('accountSettings').tr(),
 | |
|         actions:
 | |
|             isDesktop
 | |
|                 ? [
 | |
|                   IconButton(
 | |
|                     icon: const Icon(Symbols.help_outline),
 | |
|                     onPressed: () {
 | |
|                       // Show help dialog
 | |
|                       showDialog(
 | |
|                         context: context,
 | |
|                         builder:
 | |
|                             (context) => AlertDialog(
 | |
|                               title: Text('accountSettingsHelp').tr(),
 | |
|                               content: Text('accountSettingsHelpContent').tr(),
 | |
|                               actions: [
 | |
|                                 TextButton(
 | |
|                                   onPressed: () => Navigator.of(context).pop(),
 | |
|                                   child: Text('Close').tr(),
 | |
|                                 ),
 | |
|                               ],
 | |
|                             ),
 | |
|                       );
 | |
|                     },
 | |
|                   ),
 | |
|                   const Gap(8),
 | |
|                 ]
 | |
|                 : null,
 | |
|       ),
 | |
|       body: Focus(
 | |
|         autofocus: true,
 | |
|         onKeyEvent: (node, event) {
 | |
|           // Add keyboard shortcuts for desktop
 | |
|           if (isDesktop &&
 | |
|               event is KeyDownEvent &&
 | |
|               event.logicalKey == LogicalKeyboardKey.escape) {
 | |
|             Navigator.of(context).pop();
 | |
|             return KeyEventResult.handled;
 | |
|           }
 | |
|           return KeyEventResult.ignored;
 | |
|         },
 | |
|         child: SingleChildScrollView(
 | |
|           padding: const EdgeInsets.symmetric(vertical: 16),
 | |
|           child: buildSettingsList(),
 | |
|         ),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| // Helper widget for displaying settings sections with titles
 | |
| class _SettingsSection extends StatelessWidget {
 | |
|   final String title;
 | |
|   final List<Widget> children;
 | |
| 
 | |
|   const _SettingsSection({required this.title, required this.children});
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     return Column(
 | |
|       crossAxisAlignment: CrossAxisAlignment.start,
 | |
|       children: [
 | |
|         Padding(
 | |
|           padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
 | |
|           child: Text(
 | |
|             title.tr(),
 | |
|             style: Theme.of(context).textTheme.titleMedium?.copyWith(
 | |
|               color: Theme.of(context).colorScheme.primary,
 | |
|               fontWeight: FontWeight.bold,
 | |
|             ),
 | |
|           ),
 | |
|         ),
 | |
|         ...children,
 | |
|         const SizedBox(height: 16),
 | |
|       ],
 | |
|     );
 | |
|   }
 | |
| }
 |