import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:surface/providers/sn_network.dart'; import 'package:surface/types/auth.dart'; import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/loading_indicator.dart'; import 'package:surface/widgets/navigation/app_scaffold.dart'; final Map kFactorTypes = { 0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password), 1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email), 2: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer), 3: ('authFactorInAppNotify', 'authFactorInAppNotifyDescription', Symbols.notifications_active), }; class FactorSettingsScreen extends StatefulWidget { const FactorSettingsScreen({super.key}); @override State createState() => _FactorSettingsScreenState(); } class _FactorSettingsScreenState extends State { bool _isBusy = false; List? _factors; Future _fetchFactors() async { try { setState(() => _isBusy = true); final sn = context.read(); final resp = await sn.client.get('/cgi/id/users/me/factors'); _factors = List.from( resp.data?.map((e) => SnAuthFactor.fromJson(e as Map)).toList() ?? [], ); } catch (err) { if (!mounted) return; context.showErrorDialog(err); } finally { setState(() => _isBusy = false); } } @override void initState() { super.initState(); _fetchFactors(); } @override Widget build(BuildContext context) { return AppScaffold( appBar: AppBar( leading: PageBackButton(), title: Text('screenFactorSettings').tr(), ), body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ LoadingIndicator( isActive: _isBusy, ), ListTile( title: Text('authFactorAdd').tr(), subtitle: Text('authFactorAddSubtitle').tr(), contentPadding: const EdgeInsets.symmetric(horizontal: 24), leading: const Icon(Symbols.add), trailing: const Icon(Symbols.chevron_right), onTap: () { showDialog( context: context, builder: (context) => _FactorNewDialog( currentlyHave: _factors!, ), ).then((val) { if (val == true) _fetchFactors(); }); }, ), const Divider(height: 1), Expanded( child: MediaQuery.removePadding( context: context, removeTop: true, child: RefreshIndicator( onRefresh: _fetchFactors, child: ListView.builder( itemCount: _factors?.length ?? 0, itemBuilder: (context, idx) { final ele = _factors![idx]; return ListTile( title: Text(kFactorTypes[ele.type]!.$1).tr(), subtitle: Text(kFactorTypes[ele.type]!.$2).tr(), contentPadding: const EdgeInsets.only(left: 24, right: 12), leading: Icon(kFactorTypes[ele.type]!.$3), trailing: IconButton( icon: const Icon(Symbols.close), onPressed: ele.type > 0 ? () { context .showConfirmDialog( 'authFactorDelete'.tr(), 'authFactorDeleteDescription'.tr(args: [kFactorTypes[ele.type]!.$1.tr()]), ) .then((val) async { if (!val) return; try { if (!context.mounted) return; final sn = context.read(); await sn.client.delete('/cgi/id/users/me/factors/${ele.id}'); _fetchFactors(); } catch (err) { if (!context.mounted) return; context.showErrorDialog(err); } }); } : null, ), ); }, ), ), ), ), ], ), ); } } class _FactorNewDialog extends StatefulWidget { final List currentlyHave; const _FactorNewDialog({required this.currentlyHave}); @override State<_FactorNewDialog> createState() => _FactorNewDialogState(); } class _FactorNewDialogState extends State<_FactorNewDialog> { int? _factorType; bool _isBusy = false; Future _submit() async { try { setState(() => _isBusy = true); final sn = context.read(); final resp = await sn.client.post('/cgi/id/users/me/factors', data: { 'type': _factorType, }); final factor = SnAuthFactor.fromJson(resp.data); if (!mounted) return; if (factor.type == 2) { await showModalBottomSheet( context: context, builder: (context) => _FactorTotpFactorDialog(factor: factor), ); } if (!mounted) return; Navigator.of(context).pop(true); } catch (err) { if (!mounted) return; context.showErrorDialog(err); } finally { setState(() => _isBusy = false); } } @override Widget build(BuildContext context) { return AlertDialog( title: Text('authFactorAdd').tr(), content: Column( mainAxisSize: MainAxisSize.min, children: [ DropdownButtonHideUnderline( child: DropdownButton2( hint: Text( 'Select Item', style: TextStyle( fontSize: 14, ), overflow: TextOverflow.ellipsis, ), value: _factorType, items: kFactorTypes.entries.map( (ele) { final contains = widget.currentlyHave.map((ele) => ele.type).contains(ele.key); return DropdownMenuItem( enabled: !contains, value: ele.key, child: Text( ele.value.$1.tr(), style: const TextStyle( fontSize: 14, ), ).opacity(contains ? 0.75 : 1), ); }, ).toList(), onChanged: (val) => setState(() { _factorType = val; }), buttonStyleData: ButtonStyleData( height: 50, padding: const EdgeInsets.only(left: 14, right: 14), decoration: BoxDecoration( borderRadius: BorderRadius.circular(14), border: Border.all( color: Theme.of(context).dividerColor, ), ), ), ), ), ], ), actions: [ TextButton( onPressed: _isBusy ? null : () => Navigator.of(context).pop(), child: Text('dialogCancel').tr(), ), TextButton( onPressed: _isBusy ? null : () => _submit(), child: Text('dialogConfirm').tr(), ), ], ); } } class _FactorTotpFactorDialog extends StatelessWidget { final SnAuthFactor factor; const _FactorTotpFactorDialog({required this.factor}); @override Widget build(BuildContext context) { return Center( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ Center( child: Text( 'totpPostSetup', textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleLarge, ).tr().width(280), ), const Gap(4), Center( child: Text( 'totpPostSetupDescription', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ).tr().width(280), ), const Gap(16), QrImageView( padding: EdgeInsets.zero, data: factor.config!['url'], errorCorrectionLevel: QrErrorCorrectLevel.H, version: QrVersions.auto, size: 160, gapless: true, eyeStyle: QrEyeStyle( eyeShape: QrEyeShape.circle, color: Theme.of(context).colorScheme.onSurface, ), dataModuleStyle: QrDataModuleStyle( dataModuleShape: QrDataModuleShape.square, color: Theme.of(context).colorScheme.onSurface, ), ), const Gap(16), Center( child: Text( 'totpNeverShare', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium, ).tr().bold().width(280), ), ], ), ); } }