import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; import 'package:animations/animations.dart'; import 'package:auto_route/auto_route.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:dio/dio.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:flutter_otp_text_field/flutter_otp_text_field.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'; import 'captcha.dart'; final Map kFactorTypes = { 0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password), 1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email), 2: ( 'authFactorInAppNotify', 'authFactorInAppNotifyDescription', Symbols.notifications_active, ), 3: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer), }; @RoutePage() class LoginScreen extends HookConsumerWidget { const LoginScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final isBusy = useState(false); final period = useState(0); final currentTicket = useState(null); final factors = useState>([]); final factorPicked = useState(null); return AppScaffold( noBackground: false, appBar: AppBar( leading: const PageBackButton(), title: Text('login').tr(), ), body: Theme( data: Theme.of(context).copyWith(canvasColor: Colors.transparent), child: Column( children: [ if (isBusy.value) LinearProgressIndicator( minHeight: 4, borderRadius: BorderRadius.zero, trackGap: 0, stopIndicatorRadius: 0, ) else if (currentTicket.value != null) LinearProgressIndicator( minHeight: 4, borderRadius: BorderRadius.zero, trackGap: 0, stopIndicatorRadius: 0, value: 1 - (currentTicket.value!.stepRemain / currentTicket.value!.stepTotal), ) else const Gap(4), Expanded( 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++, onBusy: (value) => isBusy.value = value, ), 2 => _LoginCheckScreen( key: const ValueKey(2), challenge: currentTicket.value, factor: factorPicked.value, onChallenge: (SnAuthChallenge? p0) => currentTicket.value = p0, onNext: () => period.value = 1, onBusy: (value) => isBusy.value = value, ), _ => _LoginLookupScreen( key: const ValueKey(0), ticket: currentTicket.value, onChallenge: (SnAuthChallenge? p0) => currentTicket.value = p0, onFactor: (List? p0) => factors.value = p0 ?? [], onNext: () => period.value++, onBusy: (value) => isBusy.value = value, ), }, ).padding(all: 24), ).center(), ), const Gap(4), ], ), ), ); } } class _LoginCheckScreen extends HookConsumerWidget { final SnAuthChallenge? challenge; final SnAuthFactor? factor; final Function(SnAuthChallenge?) onChallenge; final VoidCallback onNext; final Function(bool) onBusy; const _LoginCheckScreen({ super.key, required this.challenge, required this.factor, required this.onChallenge, required this.onNext, required this.onBusy, }); @override Widget build(BuildContext context, WidgetRef ref) { final isBusy = useState(false); final passwordController = useTextEditingController(); useEffect(() { onBusy.call(isBusy.value); return null; }, [isBusy]); Future performCheckTicket() async { final pwd = passwordController.value.text; if (pwd.isEmpty) return; isBusy.value = true; try { // Pass challenge 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; } // Get token if challenge is completed final tokenResp = await client.post( '/auth/token', data: {'grant_type': 'authorization_code', 'code': result.id}, ); final token = tokenResp.data['token']; setToken(ref.watch(sharedPreferencesProvider), token); ref.invalidate(tokenProvider); if (!context.mounted) return; // Do post login tasks 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); }); // Update the sessions' device name is available if (!kIsWeb) { String? name; if (Platform.isIOS) { final deviceInfo = await DeviceInfoPlugin().iosInfo; name = deviceInfo.name; } else if (Platform.isAndroid) { final deviceInfo = await DeviceInfoPlugin().androidInfo; name = deviceInfo.name; } else if (Platform.isWindows) { final deviceInfo = await DeviceInfoPlugin().windowsInfo; name = deviceInfo.computerName; } if (name != null) { final client = ref.watch(apiClientProvider); await client.patch( '/accounts/me/sessions/current/label', data: jsonEncode(name), ); } } } catch (err) { showErrorAlert(err); return; } finally { isBusy.value = false; } } final width = math.min(380, MediaQuery.of(context).size.width); 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), if ([0].contains(factor!.type)) TextField( autocorrect: false, enableSuggestions: false, controller: passwordController, obscureText: true, autofillHints: [ factor!.type == 0 ? AutofillHints.password : AutofillHints.oneTimeCode, ], decoration: InputDecoration( isDense: true, border: const OutlineInputBorder(), labelText: 'password'.tr(), ), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onSubmitted: isBusy.value ? null : (_) => performCheckTicket(), ).padding(horizontal: 7) else OtpTextField( showCursor: false, numberOfFields: 6, obscureText: false, showFieldAsBox: true, focusedBorderColor: Theme.of(context).colorScheme.primary, fieldWidth: (width / 6) - 10, onSubmit: (value) { passwordController.text = value; performCheckTicket(); }, textStyle: Theme.of(context).textTheme.titleLarge!, ), const Gap(12), Card( child: ListTile( leading: Icon( kFactorTypes[factor!.type]?.$3 ?? Symbols.question_mark, ), title: Text(kFactorTypes[factor!.type]?.$1 ?? 'unknown').tr(), subtitle: Text(kFactorTypes[factor!.type]?.$2 ?? 'unknown').tr(), ), ), 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 VoidCallback onNext; final Function(bool) onBusy; const _LoginPickerScreen({ super.key, required this.ticket, required this.factors, required this.onChallenge, required this.onPickFactor, required this.onNext, required this.onBusy, }); @override Widget build(BuildContext context, WidgetRef ref) { final isBusy = useState(false); final factorPicked = useState(null); useEffect(() { onBusy.call(isBusy.value); return null; }, [isBusy]); final unfocusColor = Theme.of( context, ).colorScheme.onSurface.withAlpha((255 * 0.75).round()); final hintController = useTextEditingController(); void performGetFactorCode() async { if (factorPicked.value == null) return; isBusy.value = true; final client = ref.watch(apiClientProvider); try { await client.post( '/auth/challenge/${ticket!.id}/factors/${factorPicked.value!.id}', data: hintController.text.isNotEmpty ? jsonEncode(hintController.text) : null, ); onPickFactor(factors!.where((x) => x == factorPicked.value).first); onNext(); } catch (err) { if (err is DioException && err.response?.statusCode == 400) { onPickFactor(factors!.where((x) => x == factorPicked.value).first); onNext(); if (context.mounted) { showSnackBar(context, err.response!.data.toString()); } return; } 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, onChanged: (value) { if (value == true) { factorPicked.value = x; } }, ), ) .toList() ?? List.empty(), ), ), if ([1].contains(factorPicked.value?.type)) TextField( controller: hintController, decoration: InputDecoration( isDense: true, border: const OutlineInputBorder(), labelText: 'authFactorHint'.tr(), helperText: 'authFactorHintHelper'.tr(), ), ).padding(top: 12, bottom: 4, horizontal: 4), 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 VoidCallback onNext; final Function(bool) onBusy; const _LoginLookupScreen({ super.key, required this.ticket, required this.onChallenge, required this.onFactor, required this.onNext, required this.onBusy, }); @override Widget build(BuildContext context, WidgetRef ref) { final isBusy = useState(false); final usernameController = useTextEditingController(); useEffect(() { onBusy.call(isBusy.value); return null; }, [isBusy]); Future requestResetPassword() async { final uname = usernameController.value.text; if (uname.isEmpty) { showErrorAlert('loginResetPasswordHint'.tr()); return; } final captchaTk = await Navigator.of( context, ).push(MaterialPageRoute(builder: (context) => CaptchaScreen())); if (captchaTk == null) return; isBusy.value = true; try { final client = ref.watch(apiClientProvider); await client.post( '/accounts/recovery/password', data: {'account': uname, 'captcha_token': captchaTk}, ); showInfoAlert('loginResetPasswordSent'.tr(), 'done'.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), ), ], ); } }