import 'package:animations/animations.dart'; import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:gap/gap.dart'; import 'package:island/models/auth.dart'; import 'package:island/pods/config.dart'; import 'package:island/pods/network.dart'; import 'package:island/pods/userinfo.dart'; import 'package:island/pods/websocket.dart'; import 'package:island/services/notify.dart'; import 'package:island/services/udid.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:url_launcher/url_launcher_string.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, ), }; @RoutePage() class LoginScreen extends HookConsumerWidget { const LoginScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final period = useState(0); final currentTicket = useState(null); final factors = useState>([]); final factorPicked = useState(null); return AppScaffold( appBar: AppBar( leading: const PageBackButton(), title: Text('login').tr(), ), body: Theme( data: Theme.of(context).copyWith(canvasColor: Colors.transparent), child: SingleChildScrollView( child: PageTransitionSwitcher( transitionBuilder: ( Widget child, Animation primaryAnimation, Animation secondaryAnimation, ) { return SharedAxisTransition( animation: primaryAnimation, secondaryAnimation: secondaryAnimation, transitionType: SharedAxisTransitionType.horizontal, child: Container( constraints: BoxConstraints(maxWidth: 380), child: child, ), ); }, child: switch (period.value % 3) { 1 => _LoginPickerScreen( key: const ValueKey(1), ticket: currentTicket.value, factors: factors.value, onChallenge: (SnAuthChallenge? p0) => currentTicket.value = p0, onPickFactor: (SnAuthFactor p0) => factorPicked.value = p0, onNext: () => period.value++, ), 2 => _LoginCheckScreen( key: const ValueKey(2), challenge: currentTicket.value, factor: factorPicked.value, onChallenge: (SnAuthChallenge? p0) => currentTicket.value = p0, onNext: () => period.value++, ), _ => _LoginLookupScreen( key: const ValueKey(0), ticket: currentTicket.value, onChallenge: (SnAuthChallenge? p0) => currentTicket.value = p0, onFactor: (List? p0) => factors.value = p0 ?? [], onNext: () => period.value++, ), }, ).padding(all: 24), ).center(), ), ); } } class _LoginCheckScreen extends HookConsumerWidget { final SnAuthChallenge? challenge; final SnAuthFactor? factor; final Function(SnAuthChallenge?) onChallenge; final Function onNext; const _LoginCheckScreen({ super.key, required this.challenge, required this.factor, required this.onChallenge, required this.onNext, }); @override Widget build(BuildContext context, WidgetRef ref) { final isBusy = useState(false); final passwordController = useTextEditingController(); Future performCheckTicket() async { final pwd = passwordController.value.text; if (pwd.isEmpty) return; isBusy.value = true; try { final client = ref.watch(apiClientProvider); final resp = await client.patch( '/auth/challenge/${challenge!.id}', data: {'factor_id': factor!.id, 'password': pwd}, ); final result = SnAuthChallenge.fromJson(resp.data); onChallenge(result); if (result.stepRemain > 0) { onNext(); return; } final tokenResp = await client.post( '/auth/token', data: {'grant_type': 'authorization_code', 'code': result.id}, ); final atk = tokenResp.data['access_token']; final rtk = tokenResp.data['refresh_token']; setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk); ref.invalidate(tokenPairProvider); if (!context.mounted) return; final userNotifier = ref.read(userInfoProvider.notifier); userNotifier.fetchUser().then((_) { final apiClient = ref.read(apiClientProvider); subscribePushNotification(apiClient); final wsNotifier = ref.read(websocketStateProvider.notifier); wsNotifier.connect(); if (context.mounted) Navigator.pop(context, true); }); } catch (err) { showErrorAlert(err); return; } finally { isBusy.value = false; } } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Align( alignment: Alignment.centerLeft, child: CircleAvatar( radius: 26, child: const Icon(Symbols.asterisk, size: 28), ).padding(bottom: 8), ), Text( 'loginEnterPassword'.tr(), style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900), ).padding(left: 4, bottom: 16), TextField( autocorrect: false, enableSuggestions: false, controller: passwordController, obscureText: true, autofillHints: [ factor!.type == 0 ? AutofillHints.password : AutofillHints.oneTimeCode, ], decoration: InputDecoration( isDense: true, border: const UnderlineInputBorder(), labelText: 'password'.tr(), ), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onSubmitted: isBusy.value ? null : (_) => performCheckTicket(), ).padding(horizontal: 7), const Gap(12), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: isBusy.value ? null : () => performCheckTicket(), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text('next').tr(), const Icon(Symbols.chevron_right), ], ), ), ], ), ], ); } } class _LoginPickerScreen extends HookConsumerWidget { final SnAuthChallenge? ticket; final List? factors; final Function(SnAuthChallenge?) onChallenge; final Function(SnAuthFactor) onPickFactor; final Function onNext; const _LoginPickerScreen({ super.key, required this.ticket, required this.factors, required this.onChallenge, required this.onPickFactor, required this.onNext, }); @override Widget build(BuildContext context, WidgetRef ref) { final isBusy = useState(false); final factorPicked = useState(null); final unfocusColor = Theme.of( context, ).colorScheme.onSurface.withAlpha((255 * 0.75).round()); void performGetFactorCode() async { if (factorPicked.value == null) return; isBusy.value = true; final client = ref.watch(apiClientProvider); try { // Request one-time-password code await client.post( '/auth/challenge/${ticket!.id}/factors/${factorPicked.value}', ); onPickFactor(factors!.where((x) => x.id == factorPicked.value).first); onNext(); } catch (err) { showErrorAlert(err); return; } finally { isBusy.value = false; } } return Column( key: const ValueKey(1), crossAxisAlignment: CrossAxisAlignment.start, children: [ Align( alignment: Alignment.centerLeft, child: CircleAvatar( radius: 26, child: const Icon(Symbols.lock, size: 28), ).padding(bottom: 8), ), Text( 'loginPickFactor', style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900), ).tr().padding(left: 4), const Gap(8), Card( margin: const EdgeInsets.symmetric(vertical: 4), child: Column( children: factors ?.map( (x) => CheckboxListTile( shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(8)), ), secondary: Icon( kFactorTypes[x.type]?.$3 ?? Symbols.question_mark, ), title: Text(kFactorTypes[x.type]?.$1 ?? 'unknown').tr(), enabled: !ticket!.blacklistFactors.contains(x.id), value: factorPicked.value == x.id, onChanged: (value) { if (value == true) { factorPicked.value = x.id; } }, ), ) .toList() ?? List.empty(), ), ), const Gap(8), Text( 'loginMultiFactor'.plural(ticket!.stepRemain), style: TextStyle(color: unfocusColor, fontSize: 13), ).padding(horizontal: 16), const Gap(12), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: isBusy.value ? null : () => performGetFactorCode(), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text('next'.tr()), const Icon(Symbols.chevron_right), ], ), ), ], ), ], ); } } class _LoginLookupScreen extends HookConsumerWidget { final SnAuthChallenge? ticket; final Function(SnAuthChallenge?) onChallenge; final Function(List?) onFactor; final Function onNext; const _LoginLookupScreen({ super.key, required this.ticket, required this.onChallenge, required this.onFactor, required this.onNext, }); @override Widget build(BuildContext context, WidgetRef ref) { final isBusy = useState(false); final usernameController = useTextEditingController(); Future requestResetPassword() async { final uname = usernameController.value.text; if (uname.isEmpty) { showErrorAlert('loginResetPasswordHint'.tr()); return; } isBusy.value = true; try { final client = ref.watch(apiClientProvider); final lookupResp = await client.get('/users/lookup?probe=$uname'); await client.post( '/users/me/password-reset', data: {'user_id': lookupResp.data['id']}, ); showInfoAlert('done'.tr(), 'signinResetPasswordSent'.tr()); } catch (err) { showErrorAlert(err); } finally { isBusy.value = false; } } Future performNewTicket() async { final uname = usernameController.value.text; if (uname.isEmpty) return; isBusy.value = true; try { final client = ref.watch(apiClientProvider); final resp = await client.post( '/auth/challenge', data: { 'account': uname, 'device_id': await getUdid(), 'platform': kIsWeb ? 1 : switch (defaultTargetPlatform) { TargetPlatform.iOS => 2, TargetPlatform.android => 3, TargetPlatform.macOS => 4, TargetPlatform.windows => 5, TargetPlatform.linux => 6, _ => 0, }, }, ); final result = SnAuthChallenge.fromJson(resp.data); onChallenge(result); final factorResp = await client.get( '/auth/challenge/${result.id}/factors', ); onFactor( List.from( factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)), ), ); onNext(); } catch (err) { showErrorAlert(err); return; } finally { isBusy.value = false; } } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Align( alignment: Alignment.centerLeft, child: CircleAvatar( radius: 26, child: const Icon(Symbols.login, size: 28), ).padding(bottom: 8), ), Text( 'loginGreeting', style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900), ).tr().padding(left: 4, bottom: 16), TextField( autocorrect: false, enableSuggestions: false, controller: usernameController, autofillHints: const [AutofillHints.username], decoration: InputDecoration( isDense: true, border: const UnderlineInputBorder(), labelText: 'username'.tr(), helperText: 'usernameLookupHint'.tr(), ), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onSubmitted: isBusy.value ? null : (_) => performNewTicket(), ).padding(horizontal: 7), const Gap(12), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ TextButton( onPressed: isBusy.value ? null : () => requestResetPassword(), style: TextButton.styleFrom(foregroundColor: Colors.grey), child: Text('forgotPassword'.tr()), ), TextButton( onPressed: isBusy.value ? null : () => performNewTicket(), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text('next').tr(), const Icon(Symbols.chevron_right), ], ), ), ], ), const Gap(12), Align( alignment: Alignment.centerRight, child: StyledWidget( Container( constraints: const BoxConstraints(maxWidth: 290), child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( 'termAcceptNextWithAgree'.tr(), textAlign: TextAlign.end, style: Theme.of(context).textTheme.bodySmall!.copyWith( color: Theme.of( context, ).colorScheme.onSurface.withAlpha((255 * 0.75).round()), ), ), Material( color: Colors.transparent, child: InkWell( child: Row( mainAxisSize: MainAxisSize.min, children: [ Text('termAcceptLink'.tr()), const Gap(4), const Icon(Symbols.launch, size: 14), ], ), onTap: () { launchUrlString('https://solsynth.dev/terms'); }, ), ), ], ), ), ).padding(horizontal: 16), ), ], ); } }