import 'dart:io'; import 'package:auto_route/annotations.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/auth.dart'; import 'package:island/models/user.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/services/responsive.dart'; import 'package:island/widgets/account/account_session_sheet.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 'settings.g.dart'; @riverpod Future> authFactors(Ref ref) async { final client = ref.read(apiClientProvider); final res = await client.get('/accounts/me/factors'); return res.data.map((e) => SnAuthFactor.fromJson(e)).toList(); } @riverpod Future> contactMethods(Ref ref) async { final client = ref.read(apiClientProvider); final resp = await client.get('/accounts/me/contacts'); return resp.data .map((e) => SnContactMethod.fromJson(e)) .toList(); } @riverpod Future> accountConnections(Ref ref) async { final client = ref.read(apiClientProvider); final resp = await client.get('/accounts/me/connections'); return resp.data .map((e) => SnAccountConnection.fromJson(e)) .toList(); } @RoutePage() class AccountSettingsScreen extends HookConsumerWidget { const AccountSettingsScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final isDesktop = !kIsWeb && (Platform.isWindows || Platform.isMacOS || Platform.isLinux); final isWide = isWideScreen(context); Future 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('/accounts/me'); if (context.mounted) { showSnackBar(context, 'accountDeletionSent'.tr()); } } catch (err) { showErrorAlert(err); } finally { if (context.mounted) hideLoadingModal(context); } } Future 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( '/accounts/recovery/password', data: {'account': userInfo.value!.name, 'captcha_token': captchaTk}, ); if (context.mounted) { showSnackBar(context, '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.isNotEmpty ? Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ for (final meta in connection.meta.entries) Text( '${meta.key.split('_').map((word) => word[0].toUpperCase() + word.substring(1)).join(' ')}: ${meta.value}', style: const TextStyle( fontSize: 12, ), ), ], ) : Text(connection.providedIdentifier), leading: CircleAvatar( child: Icon(getProviderIcon(connection.provider)), ).padding(top: 4), trailing: const Icon(Symbols.chevron_right), isThreeLine: true, 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() { if (isWide) { // Two-column layout for wide screens return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SettingsSection( title: 'accountSecurityTitle', children: securitySettings, ), ], ), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SettingsSection( title: 'accountDangerZoneTitle', children: dangerZoneSettings, ), ], ), ), ], ).padding(horizontal: 16); } else { // Single column layout for narrow screens 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(), ), ], ), ); }, ), ] : 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 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), ], ); } }