From d832729278c24ab5b591dd0655ca91fe9052ad86 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 7 Jun 2025 12:14:57 +0800 Subject: [PATCH] :lipstick: Optimized the auth experience --- assets/i18n/en-US.json | 4 +- lib/screens/account/me/settings.dart | 112 ++++++++---- lib/screens/auth/login.dart | 260 +++++++++++++++++++-------- lib/screens/chat/chat.dart | 6 +- lib/screens/settings.dart | 19 +- lib/widgets/app_scaffold.dart | 4 +- 6 files changed, 275 insertions(+), 130 deletions(-) diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 5b8d0e5..391431d 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -336,5 +336,7 @@ "authFactorNoQrCode": "No QR code available for this authentication factor", "cancel": "Cancel", "confirm": "Confirm", - "authFactorAdditional": "One more step" + "authFactorAdditional": "One more step", + "authFactorHint": "Contact method hint", + "authFactorHintHelper": "You need provide a part of your contact method and we will send the verification code to that contact method if it matched our records" } diff --git a/lib/screens/account/me/settings.dart b/lib/screens/account/me/settings.dart index 1a605ea..ee69d24 100644 --- a/lib/screens/account/me/settings.dart +++ b/lib/screens/account/me/settings.dart @@ -106,13 +106,44 @@ class AccountSettingsScreen extends HookConsumerWidget { ListTile( minLeadingWidth: 48, contentPadding: const EdgeInsets.only( - left: 24, + left: 16, right: 17, + top: 2, + bottom: 4, ), - title: Text(kFactorTypes[factor.type]!.$1).tr(), - subtitle: Text(kFactorTypes[factor.type]!.$2).tr(), - leading: Icon(kFactorTypes[factor.type]!.$3), + 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(); @@ -341,44 +372,47 @@ class _AuthFactorSheet extends HookConsumerWidget { Future enableFactor() async { String? password; - final confirmed = await showDialog( - context: context, - builder: - (context) => AlertDialog( - title: Text('authFactorEnable').tr(), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text('authFactorEnableHint').tr(), - const SizedBox(height: 16), - OtpTextField( - numberOfFields: 6, - obscureText: false, - showFieldAsBox: true, - focusedBorderColor: Theme.of(context).colorScheme.primary, - onSubmit: (String verificationCode) { - password = verificationCode; - }, - textStyle: Theme.of(context).textTheme.titleLarge!, + if ([3].contains(factor.type)) { + final confirmed = await showDialog( + 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(), ), ], ), - 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; + ); + if (confirmed == false || + (password?.isEmpty ?? true) || + !context.mounted) { + return; + } } try { final client = ref.read(apiClientProvider); diff --git a/lib/screens/auth/login.dart b/lib/screens/auth/login.dart index b73f08b..757c7df 100644 --- a/lib/screens/auth/login.dart +++ b/lib/screens/auth/login.dart @@ -1,9 +1,13 @@ +import 'dart:convert'; +import 'dart:math' as math; + 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: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'; @@ -38,10 +42,13 @@ class LoginScreen extends HookConsumerWidget { @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( @@ -50,54 +57,83 @@ class LoginScreen extends HookConsumerWidget { ), 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(), + child: Column( + children: [ + if (isBusy.value) + LinearProgressIndicator( + minHeight: 4, + borderRadius: BorderRadius.zero, + ) + 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(), + ), + if (currentTicket.value != null) + LinearProgressIndicator( + minHeight: 4, + borderRadius: BorderRadius.zero, + value: + 1 - + (currentTicket.value!.stepRemain / + currentTicket.value!.stepTotal), + ) + else + const Gap(4), + ], + ), ), ); } @@ -107,7 +143,8 @@ class _LoginCheckScreen extends HookConsumerWidget { final SnAuthChallenge? challenge; final SnAuthFactor? factor; final Function(SnAuthChallenge?) onChallenge; - final Function onNext; + final VoidCallback onNext; + final Function(bool) onBusy; const _LoginCheckScreen({ super.key, @@ -115,6 +152,7 @@ class _LoginCheckScreen extends HookConsumerWidget { required this.factor, required this.onChallenge, required this.onNext, + required this.onBusy, }); @override @@ -122,6 +160,11 @@ class _LoginCheckScreen extends HookConsumerWidget { 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; @@ -162,6 +205,8 @@ class _LoginCheckScreen extends HookConsumerWidget { } } + final width = math.min(380, MediaQuery.of(context).size.width); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -176,24 +221,49 @@ class _LoginCheckScreen extends HookConsumerWidget { '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(), + 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!, ), - onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), - onSubmitted: isBusy.value ? null : (_) => performCheckTicket(), - ).padding(horizontal: 7), + 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, @@ -220,7 +290,8 @@ class _LoginPickerScreen extends HookConsumerWidget { final List? factors; final Function(SnAuthChallenge?) onChallenge; final Function(SnAuthFactor) onPickFactor; - final Function onNext; + final VoidCallback onNext; + final Function(bool) onBusy; const _LoginPickerScreen({ super.key, @@ -229,17 +300,25 @@ class _LoginPickerScreen extends HookConsumerWidget { 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); + 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; @@ -247,11 +326,14 @@ class _LoginPickerScreen extends HookConsumerWidget { final client = ref.watch(apiClientProvider); try { - // Request one-time-password code await client.post( - '/auth/challenge/${ticket!.id}/factors/${factorPicked.value}', + '/auth/challenge/${ticket!.id}/factors/${factorPicked.value!.id}', + data: + hintController.text.isNotEmpty + ? jsonEncode(hintController.text) + : null, ); - onPickFactor(factors!.where((x) => x.id == factorPicked.value).first); + onPickFactor(factors!.where((x) => x == factorPicked.value).first); onNext(); } catch (err) { showErrorAlert(err); @@ -261,6 +343,20 @@ class _LoginPickerScreen extends HookConsumerWidget { } } + useEffect(() { + if (ticket == null || (factors?.isEmpty ?? true)) return; + if (ticket!.blacklistFactors.isEmpty) { + Future(() { + var password = factors!.where((x) => x.type == 0).firstOrNull; + if (password != null) { + factorPicked.value = password; + performGetFactorCode(); + } + }); + } + return null; + }, [ticket, factors]); + return Column( key: const ValueKey(1), crossAxisAlignment: CrossAxisAlignment.start, @@ -292,10 +388,10 @@ class _LoginPickerScreen extends HookConsumerWidget { ), title: Text(kFactorTypes[x.type]?.$1 ?? 'unknown').tr(), enabled: !ticket!.blacklistFactors.contains(x.id), - value: factorPicked.value == x.id, + value: factorPicked.value == x, onChanged: (value) { if (value == true) { - factorPicked.value = x.id; + factorPicked.value = x; } }, ), @@ -304,6 +400,15 @@ class _LoginPickerScreen extends HookConsumerWidget { List.empty(), ), ), + if ([1].contains(factorPicked.value?.type)) + TextField( + controller: hintController, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: 'authFactorHint'.tr(), + helperText: 'authFactorHintHelper'.tr(), + ), + ).padding(top: 12, bottom: 4, horizontal: 4), const Gap(8), Text( 'loginMultiFactor'.plural(ticket!.stepRemain), @@ -334,7 +439,8 @@ class _LoginLookupScreen extends HookConsumerWidget { final SnAuthChallenge? ticket; final Function(SnAuthChallenge?) onChallenge; final Function(List?) onFactor; - final Function onNext; + final VoidCallback onNext; + final Function(bool) onBusy; const _LoginLookupScreen({ super.key, @@ -342,6 +448,7 @@ class _LoginLookupScreen extends HookConsumerWidget { required this.onChallenge, required this.onFactor, required this.onNext, + required this.onBusy, }); @override @@ -349,6 +456,11 @@ class _LoginLookupScreen extends HookConsumerWidget { 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) { diff --git a/lib/screens/chat/chat.dart b/lib/screens/chat/chat.dart index cc33f54..e4eb856 100644 --- a/lib/screens/chat/chat.dart +++ b/lib/screens/chat/chat.dart @@ -347,7 +347,11 @@ class ChatListScreen extends HookConsumerWidget { builder: (context, ref, _) { final summaryState = ref.watch(chatSummaryProvider); return summaryState.maybeWhen( - loading: () => const LinearProgressIndicator(), + loading: + () => const LinearProgressIndicator( + minHeight: 2, + borderRadius: BorderRadius.zero, + ), orElse: () => const SizedBox.shrink(), ); }, diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 1fa6dbe..5858a2a 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -147,6 +147,7 @@ class SettingsScreen extends HookConsumerWidget { title: Text('settingsColorScheme').tr(), content: SingleChildScrollView( child: ColorPicker( + paletteType: PaletteType.rgbWithBlue, enableAlpha: false, pickerColor: selectedColor, onColorChanged: (color) { @@ -174,8 +175,9 @@ class SettingsScreen extends HookConsumerWidget { ); }, child: Container( - width: 40, - height: 40, + width: 24, + height: 24, + margin: EdgeInsets.symmetric(horizontal: 2, vertical: 8), decoration: BoxDecoration( color: settings.appColorScheme != null @@ -198,18 +200,7 @@ class SettingsScreen extends HookConsumerWidget { title: Text('settingsBackgroundImage').tr(), contentPadding: const EdgeInsets.only(left: 24, right: 17), leading: const Icon(Symbols.image), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isDesktop) - Tooltip( - message: 'settingsBackgroundImageTooltip'.tr(), - padding: EdgeInsets.only(left: 8), - child: const Icon(Symbols.info, size: 18), - ), - const Icon(Symbols.chevron_right), - ], - ), + trailing: const Icon(Symbols.chevron_right), onTap: () async { final imagePicker = ref.read(imagePickerProvider); final image = await imagePicker.pickImage( diff --git a/lib/widgets/app_scaffold.dart b/lib/widgets/app_scaffold.dart index 4c48e2a..8f7b50e 100644 --- a/lib/widgets/app_scaffold.dart +++ b/lib/widgets/app_scaffold.dart @@ -290,7 +290,9 @@ class _WebSocketIndicator extends HookConsumerWidget { return AnimatedPositioned( duration: Duration(milliseconds: 1850), top: - !user.hasValue || websocketState == WebSocketState.connected() + !user.hasValue || + user.value == null || + websocketState == WebSocketState.connected() ? -indicatorHeight : 0, curve: Curves.fastLinearToSlowEaseIn,