360 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			360 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
import 'dart:convert';
 | 
						|
import 'dart:math' as math;
 | 
						|
 | 
						|
import 'package:easy_localization/easy_localization.dart';
 | 
						|
import 'package:flutter/material.dart';
 | 
						|
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
						|
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
 | 
						|
import 'package:gap/gap.dart';
 | 
						|
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
						|
import 'package:island/models/auth.dart';
 | 
						|
import 'package:island/pods/network.dart';
 | 
						|
import 'package:island/screens/auth/login.dart';
 | 
						|
import 'package:island/widgets/alert.dart';
 | 
						|
import 'package:island/widgets/content/sheet.dart';
 | 
						|
import 'package:material_symbols_icons/symbols.dart';
 | 
						|
import 'package:qr_flutter/qr_flutter.dart';
 | 
						|
import 'package:styled_widget/styled_widget.dart';
 | 
						|
 | 
						|
class AuthFactorSheet extends HookConsumerWidget {
 | 
						|
  final SnAuthFactor factor;
 | 
						|
  const AuthFactorSheet({super.key, required this.factor});
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context, WidgetRef ref) {
 | 
						|
    Future<void> deleteFactor() async {
 | 
						|
      final confirm = await showConfirmAlert(
 | 
						|
        'authFactorDeleteHint'.tr(),
 | 
						|
        'authFactorDelete'.tr(),
 | 
						|
      );
 | 
						|
      if (!confirm || !context.mounted) return;
 | 
						|
      try {
 | 
						|
        showLoadingModal(context);
 | 
						|
        final client = ref.read(apiClientProvider);
 | 
						|
        await client.delete('/pass/accounts/me/factors/${factor.id}');
 | 
						|
        if (context.mounted) Navigator.pop(context, true);
 | 
						|
      } catch (err) {
 | 
						|
        showErrorAlert(err);
 | 
						|
      } finally {
 | 
						|
        if (context.mounted) hideLoadingModal(context);
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    Future<void> disableFactor() async {
 | 
						|
      final confirm = await showConfirmAlert(
 | 
						|
        'authFactorDisableHint'.tr(),
 | 
						|
        'authFactorDisable'.tr(),
 | 
						|
      );
 | 
						|
      if (!confirm || !context.mounted) return;
 | 
						|
      try {
 | 
						|
        showLoadingModal(context);
 | 
						|
        final client = ref.read(apiClientProvider);
 | 
						|
        await client.post('/pass/accounts/me/factors/${factor.id}/disable');
 | 
						|
        if (context.mounted) Navigator.pop(context, true);
 | 
						|
      } catch (err) {
 | 
						|
        showErrorAlert(err);
 | 
						|
      } finally {
 | 
						|
        if (context.mounted) hideLoadingModal(context);
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    Future<void> enableFactor() async {
 | 
						|
      String? password;
 | 
						|
      if ([3].contains(factor.type)) {
 | 
						|
        final confirmed = await showDialog<bool>(
 | 
						|
          context: context,
 | 
						|
          builder:
 | 
						|
              (context) => AlertDialog(
 | 
						|
                title: Text('authFactorEnable').tr(),
 | 
						|
                content: Column(
 | 
						|
                  mainAxisSize: MainAxisSize.min,
 | 
						|
                  children: [
 | 
						|
                    Text('authFactorEnableHint').tr(),
 | 
						|
                    const SizedBox(height: 16),
 | 
						|
                    OtpTextField(
 | 
						|
                      showCursor: false,
 | 
						|
                      numberOfFields: 6,
 | 
						|
                      obscureText: false,
 | 
						|
                      showFieldAsBox: true,
 | 
						|
                      focusedBorderColor: Theme.of(context).colorScheme.primary,
 | 
						|
                      onSubmit: (String verificationCode) {
 | 
						|
                        password = verificationCode;
 | 
						|
                      },
 | 
						|
                      textStyle: Theme.of(context).textTheme.titleLarge!,
 | 
						|
                    ),
 | 
						|
                  ],
 | 
						|
                ),
 | 
						|
                actions: [
 | 
						|
                  TextButton(
 | 
						|
                    onPressed: () => Navigator.of(context).pop(false),
 | 
						|
                    child: Text('cancel').tr(),
 | 
						|
                  ),
 | 
						|
                  TextButton(
 | 
						|
                    onPressed: () => Navigator.of(context).pop(true),
 | 
						|
                    child: Text('confirm').tr(),
 | 
						|
                  ),
 | 
						|
                ],
 | 
						|
              ),
 | 
						|
        );
 | 
						|
        if (confirmed == false ||
 | 
						|
            (password?.isEmpty ?? true) ||
 | 
						|
            !context.mounted) {
 | 
						|
          return;
 | 
						|
        }
 | 
						|
      }
 | 
						|
      try {
 | 
						|
        showLoadingModal(context);
 | 
						|
        final client = ref.read(apiClientProvider);
 | 
						|
        await client.post(
 | 
						|
          '/pass/accounts/me/factors/${factor.id}/enable',
 | 
						|
          data: jsonEncode(password),
 | 
						|
        );
 | 
						|
        if (context.mounted) Navigator.pop(context, true);
 | 
						|
      } catch (err) {
 | 
						|
        showErrorAlert(err);
 | 
						|
      } finally {
 | 
						|
        if (context.mounted) hideLoadingModal(context);
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    return SheetScaffold(
 | 
						|
      titleText: 'authFactor'.tr(),
 | 
						|
      child: Column(
 | 
						|
        crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
						|
        children: [
 | 
						|
          Column(
 | 
						|
            crossAxisAlignment: CrossAxisAlignment.start,
 | 
						|
            mainAxisAlignment: MainAxisAlignment.center,
 | 
						|
            children: [
 | 
						|
              Icon(kFactorTypes[factor.type]!.$3, size: 32),
 | 
						|
              const Gap(8),
 | 
						|
              Text(kFactorTypes[factor.type]!.$1).tr(),
 | 
						|
              const Gap(4),
 | 
						|
              Text(
 | 
						|
                kFactorTypes[factor.type]!.$2,
 | 
						|
                style: Theme.of(context).textTheme.bodySmall,
 | 
						|
              ).tr(),
 | 
						|
              const Gap(10),
 | 
						|
              Row(
 | 
						|
                children: [
 | 
						|
                  if (factor.enabledAt == null)
 | 
						|
                    Badge(
 | 
						|
                      label: Text('authFactorDisabled').tr(),
 | 
						|
                      textColor: Theme.of(context).colorScheme.onSecondary,
 | 
						|
                      backgroundColor: Theme.of(context).colorScheme.secondary,
 | 
						|
                    )
 | 
						|
                  else
 | 
						|
                    Badge(
 | 
						|
                      label: Text('authFactorEnabled').tr(),
 | 
						|
                      textColor: Theme.of(context).colorScheme.onPrimary,
 | 
						|
                      backgroundColor: Theme.of(context).colorScheme.primary,
 | 
						|
                    ),
 | 
						|
                ],
 | 
						|
              ),
 | 
						|
            ],
 | 
						|
          ).padding(all: 20),
 | 
						|
          const Divider(height: 1),
 | 
						|
          if (factor.enabledAt != null)
 | 
						|
            ListTile(
 | 
						|
              leading: const Icon(Symbols.disabled_by_default),
 | 
						|
              title: Text('authFactorDisable').tr(),
 | 
						|
              onTap: disableFactor,
 | 
						|
              contentPadding: EdgeInsets.symmetric(horizontal: 20),
 | 
						|
            )
 | 
						|
          else
 | 
						|
            ListTile(
 | 
						|
              leading: const Icon(Symbols.check_circle),
 | 
						|
              title: Text('authFactorEnable').tr(),
 | 
						|
              onTap: enableFactor,
 | 
						|
              contentPadding: EdgeInsets.symmetric(horizontal: 20),
 | 
						|
            ),
 | 
						|
          ListTile(
 | 
						|
            leading: const Icon(Symbols.delete),
 | 
						|
            title: Text('authFactorDelete').tr(),
 | 
						|
            onTap: deleteFactor,
 | 
						|
            contentPadding: EdgeInsets.symmetric(horizontal: 20),
 | 
						|
          ),
 | 
						|
        ],
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class AuthFactorNewSheet extends HookConsumerWidget {
 | 
						|
  const AuthFactorNewSheet({super.key});
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context, WidgetRef ref) {
 | 
						|
    final factorType = useState<int>(0);
 | 
						|
    final secretController = useTextEditingController();
 | 
						|
 | 
						|
    Future<void> addFactor() async {
 | 
						|
      try {
 | 
						|
        showLoadingModal(context);
 | 
						|
        final apiClient = ref.read(apiClientProvider);
 | 
						|
        final resp = await apiClient.post(
 | 
						|
          '/pass/accounts/me/factors',
 | 
						|
          data: {'type': factorType.value, 'secret': secretController.text},
 | 
						|
        );
 | 
						|
        final factor = SnAuthFactor.fromJson(resp.data);
 | 
						|
        if (!context.mounted) return;
 | 
						|
        hideLoadingModal(context);
 | 
						|
        if (factor.type == 3) {
 | 
						|
          showModalBottomSheet(
 | 
						|
            context: context,
 | 
						|
            builder: (context) => AuthFactorNewAdditonalSheet(factor: factor),
 | 
						|
          ).then((_) {
 | 
						|
            if (context.mounted) {
 | 
						|
              showSnackBar('contactMethodVerificationNeeded'.tr());
 | 
						|
            }
 | 
						|
            if (context.mounted) Navigator.pop(context, true);
 | 
						|
          });
 | 
						|
        } else {
 | 
						|
          Navigator.pop(context, true);
 | 
						|
        }
 | 
						|
      } catch (err) {
 | 
						|
        showErrorAlert(err);
 | 
						|
        if (context.mounted) hideLoadingModal(context);
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    final width = math.min(400, MediaQuery.of(context).size.width);
 | 
						|
 | 
						|
    return SheetScaffold(
 | 
						|
      titleText: 'authFactorNew'.tr(),
 | 
						|
      child: Column(
 | 
						|
        spacing: 16,
 | 
						|
        crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
						|
        children: [
 | 
						|
          DropdownButtonFormField<int>(
 | 
						|
            value: factorType.value,
 | 
						|
            decoration: InputDecoration(
 | 
						|
              labelText: 'authFactor'.tr(),
 | 
						|
              border: const OutlineInputBorder(),
 | 
						|
            ),
 | 
						|
            items:
 | 
						|
                kFactorTypes.entries.map((entry) {
 | 
						|
                  return DropdownMenuItem<int>(
 | 
						|
                    value: entry.key,
 | 
						|
                    child: Row(
 | 
						|
                      children: [
 | 
						|
                        Icon(entry.value.$3),
 | 
						|
                        const Gap(8),
 | 
						|
                        Text(entry.value.$1).tr(),
 | 
						|
                      ],
 | 
						|
                    ),
 | 
						|
                  );
 | 
						|
                }).toList(),
 | 
						|
            onChanged: (value) {
 | 
						|
              if (value != null) {
 | 
						|
                factorType.value = value;
 | 
						|
              }
 | 
						|
            },
 | 
						|
          ),
 | 
						|
          if ([0].contains(factorType.value))
 | 
						|
            TextField(
 | 
						|
              controller: secretController,
 | 
						|
              decoration: InputDecoration(
 | 
						|
                prefixIcon: const Icon(Symbols.password_2),
 | 
						|
                labelText: 'authFactorSecret'.tr(),
 | 
						|
                hintText: 'authFactorSecretHint'.tr(),
 | 
						|
                border: const OutlineInputBorder(),
 | 
						|
              ),
 | 
						|
              onTapOutside:
 | 
						|
                  (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
						|
            )
 | 
						|
          else if ([4].contains(factorType.value))
 | 
						|
            OtpTextField(
 | 
						|
              showCursor: false,
 | 
						|
              numberOfFields: 6,
 | 
						|
              obscureText: false,
 | 
						|
              showFieldAsBox: true,
 | 
						|
              focusedBorderColor: Theme.of(context).colorScheme.primary,
 | 
						|
              fieldWidth: (width / 6) - 10,
 | 
						|
              keyboardType: TextInputType.number,
 | 
						|
              onSubmit: (String verificationCode) {
 | 
						|
                secretController.text = verificationCode;
 | 
						|
              },
 | 
						|
              textStyle: Theme.of(context).textTheme.titleLarge!,
 | 
						|
            ),
 | 
						|
          Padding(
 | 
						|
            padding: const EdgeInsets.symmetric(horizontal: 16.0),
 | 
						|
            child: Text(kFactorTypes[factorType.value]!.$2).tr(),
 | 
						|
          ),
 | 
						|
          Row(
 | 
						|
            mainAxisAlignment: MainAxisAlignment.end,
 | 
						|
            children: [
 | 
						|
              TextButton.icon(
 | 
						|
                onPressed: addFactor,
 | 
						|
                icon: Icon(Symbols.add),
 | 
						|
                label: Text('create').tr(),
 | 
						|
              ),
 | 
						|
            ],
 | 
						|
          ),
 | 
						|
        ],
 | 
						|
      ).padding(horizontal: 20, vertical: 24),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class AuthFactorNewAdditonalSheet extends StatelessWidget {
 | 
						|
  final SnAuthFactor factor;
 | 
						|
  const AuthFactorNewAdditonalSheet({super.key, required this.factor});
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context) {
 | 
						|
    final uri = factor.createdResponse?['uri'];
 | 
						|
 | 
						|
    return SheetScaffold(
 | 
						|
      titleText: 'authFactorAdditional'.tr(),
 | 
						|
      child: Column(
 | 
						|
        crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
						|
        children: [
 | 
						|
          if (uri != null) ...[
 | 
						|
            const SizedBox(height: 16),
 | 
						|
            Center(
 | 
						|
              child: ClipRRect(
 | 
						|
                borderRadius: BorderRadius.circular(16),
 | 
						|
                child: QrImageView(
 | 
						|
                  data: uri,
 | 
						|
                  version: QrVersions.auto,
 | 
						|
                  size: 200,
 | 
						|
                  backgroundColor: Theme.of(context).colorScheme.surface,
 | 
						|
                  foregroundColor: Theme.of(context).colorScheme.onSurface,
 | 
						|
                ),
 | 
						|
              ),
 | 
						|
            ),
 | 
						|
            const Gap(16),
 | 
						|
            Padding(
 | 
						|
              padding: const EdgeInsets.symmetric(horizontal: 16),
 | 
						|
              child: Text(
 | 
						|
                'authFactorQrCodeScan'.tr(),
 | 
						|
                textAlign: TextAlign.center,
 | 
						|
                style: Theme.of(context).textTheme.bodySmall,
 | 
						|
              ),
 | 
						|
            ),
 | 
						|
          ] else ...[
 | 
						|
            const SizedBox(height: 16),
 | 
						|
            Center(
 | 
						|
              child: Text(
 | 
						|
                'authFactorNoQrCode'.tr(),
 | 
						|
                textAlign: TextAlign.center,
 | 
						|
                style: Theme.of(context).textTheme.bodyMedium,
 | 
						|
              ),
 | 
						|
            ),
 | 
						|
          ],
 | 
						|
          const Gap(16),
 | 
						|
          Padding(
 | 
						|
            padding: const EdgeInsets.symmetric(horizontal: 16),
 | 
						|
            child: TextButton.icon(
 | 
						|
              onPressed: () => Navigator.of(context).pop(),
 | 
						|
              icon: const Icon(Symbols.check),
 | 
						|
              label: Text('next'.tr()),
 | 
						|
            ),
 | 
						|
          ),
 | 
						|
        ],
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 |