feat(legacy-auth): improve 2FA sheet UX and signup layout
This commit is contained in:
@@ -12,7 +12,7 @@ import 'package:sf_tracking/sf_tracking.dart';
|
||||
|
||||
part 'login_controller.g.dart';
|
||||
|
||||
const int resendCooldownSeconds = 30;
|
||||
const int resendCooldownSeconds = 90;
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class LoginController extends _$LoginController with LoginFormValidation {
|
||||
|
||||
@@ -11,9 +11,9 @@ class LegacyTwoFactorBottomSheetView extends StatelessWidget {
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.maskedEmail,
|
||||
required this.verifyText,
|
||||
required this.resendText,
|
||||
required this.closeText,
|
||||
required this.isOtpLoading,
|
||||
required this.canResend,
|
||||
required this.otpCode,
|
||||
@@ -21,14 +21,13 @@ class LegacyTwoFactorBottomSheetView extends StatelessWidget {
|
||||
required this.onChanged,
|
||||
required this.onVerify,
|
||||
required this.onResend,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String maskedEmail;
|
||||
final String verifyText;
|
||||
final String resendText;
|
||||
final String closeText;
|
||||
|
||||
final bool isOtpLoading;
|
||||
final bool canResend;
|
||||
@@ -38,20 +37,26 @@ class LegacyTwoFactorBottomSheetView extends StatelessWidget {
|
||||
final ValueChanged<String> onChanged;
|
||||
final Future<void> Function() onVerify;
|
||||
final Future<void> Function() onResend;
|
||||
final VoidCallback onClose;
|
||||
|
||||
bool get _isValidOtp => otpCode.trim().length == 6;
|
||||
bool get _canVerify => _isValidOtp && !isOtpLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
final primaryColor = context.sfColors.legacyPrimary;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 24 + bottomInset),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
12,
|
||||
20,
|
||||
24 + bottomInset + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -76,6 +81,18 @@ class LegacyTwoFactorBottomSheetView extends StatelessWidget {
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
if (maskedEmail.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
maskedEmail,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
|
||||
LegacyOtpCodeFields(
|
||||
@@ -91,36 +108,36 @@ class LegacyTwoFactorBottomSheetView extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
PrimaryButton(
|
||||
onPressed: (isOtpLoading || !_isValidOtp)
|
||||
? () {}
|
||||
: () => unawaited(onVerify()),
|
||||
text: verifyText,
|
||||
color: context.sfColors.legacyPrimary,
|
||||
leading: isOtpLoading
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
Opacity(
|
||||
opacity: _canVerify ? 1.0 : 0.5,
|
||||
child: PrimaryButton(
|
||||
onPressed: _canVerify ? () => unawaited(onVerify()) : () {},
|
||||
text: verifyText,
|
||||
color: primaryColor,
|
||||
leading: isOtpLoading
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextButton(
|
||||
onPressed: canResend ? () => unawaited(onResend()) : null,
|
||||
child: Text(resendText),
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
TextButton(
|
||||
onPressed: isOtpLoading ? null : onClose,
|
||||
child: Text(closeText),
|
||||
child: Text(
|
||||
resendText,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: canResend ? primaryColor : Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,82 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:legacy_auth/src/features/login/domain/entities/legacy_auth_error_event.dart';
|
||||
import 'package:legacy_auth/src/features/login/presentation/providers/login_controller.dart';
|
||||
import 'package:legacy_auth/src/features/login/presentation/widgets/two_factor_bottom_sheet.dart';
|
||||
import 'package:sf_localizations/sf_localizations.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
|
||||
String _twoFactorErrorI18nKey(LegacyAuthErrorEvent event) {
|
||||
return switch (event) {
|
||||
LegacyAuthErrorEvent.invalidCode => I18n.errorTwoFactorCodeInvalid,
|
||||
LegacyAuthErrorEvent.invalidToken => I18n.authErrorInvalidToken,
|
||||
LegacyAuthErrorEvent.tooManyAttempts => I18n.authErrorTooManyAttempts,
|
||||
LegacyAuthErrorEvent.noMethodsAvailable => I18n.errorTwoFactorNoMethods,
|
||||
LegacyAuthErrorEvent.network => I18n.authErrorNetwork,
|
||||
_ => I18n.errorGeneric,
|
||||
};
|
||||
}
|
||||
|
||||
void showTwoFactorSheet(BuildContext context) {
|
||||
final container = ProviderScope.containerOf(context);
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
isDismissible: false,
|
||||
enableDrag: false,
|
||||
enableDrag: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (sheetContext) {
|
||||
return Consumer(
|
||||
builder: (sheetContext, ref, _) {
|
||||
final notifier = ref.read(loginControllerProvider.notifier);
|
||||
final state = ref.watch(loginControllerProvider);
|
||||
|
||||
final otpErrorKey = ref
|
||||
.watch(loginControllerProvider.select((s) => s.codeError));
|
||||
final isLoading = ref
|
||||
.watch(loginControllerProvider.select((s) => s.isLoading));
|
||||
final code =
|
||||
ref.watch(loginControllerProvider.select((s) => s.code));
|
||||
final resendCooldown = ref
|
||||
.watch(loginControllerProvider.select((s) => s.resendCooldown));
|
||||
|
||||
final otpErrorText = otpErrorKey.isEmpty
|
||||
final otpErrorText = state.codeError.isEmpty
|
||||
? ''
|
||||
: sheetContext.translate(otpErrorKey);
|
||||
: sheetContext.translate(state.codeError);
|
||||
|
||||
ref.listen(
|
||||
loginControllerProvider.select(
|
||||
(s) => (errorEvent: s.errorEvent, verified: s.twoFAVerified),
|
||||
),
|
||||
(previous, next) async {
|
||||
final errorEvent = next.errorEvent;
|
||||
if (errorEvent != null && errorEvent != previous?.errorEvent) {
|
||||
await showErrorDialog(
|
||||
sheetContext,
|
||||
_twoFactorErrorI18nKey(errorEvent),
|
||||
);
|
||||
ref.read(loginControllerProvider.notifier).clearErrorEvent();
|
||||
}
|
||||
if (next.verified) {
|
||||
if (sheetContext.mounted) {
|
||||
Navigator.of(sheetContext).pop();
|
||||
}
|
||||
loginControllerProvider.select((s) => s.twoFAVerified),
|
||||
(_, verified) {
|
||||
if (verified && sheetContext.mounted) {
|
||||
Navigator.of(sheetContext).pop();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final canResend = !isLoading && resendCooldown == 0;
|
||||
final canResend = !state.isLoading && state.resendCooldown == 0;
|
||||
final maskedEmail = _maskEmail(state.email);
|
||||
|
||||
return LegacyTwoFactorBottomSheetView(
|
||||
title: sheetContext.translate(I18n.twoFactorTitle),
|
||||
subtitle: sheetContext.translate(I18n.twoFactorSubtitle),
|
||||
maskedEmail: maskedEmail,
|
||||
verifyText: sheetContext.translate(I18n.twoFactorVerify),
|
||||
resendText: resendCooldown > 0
|
||||
? '${sheetContext.translate(I18n.twoFactorResend)} (${resendCooldown}s)'
|
||||
resendText: state.resendCooldown > 0
|
||||
? '${sheetContext.translate(I18n.twoFactorResend)} (${_formatCooldown(state.resendCooldown)})'
|
||||
: sheetContext.translate(I18n.twoFactorResend),
|
||||
closeText: sheetContext.translate(I18n.close),
|
||||
isOtpLoading: isLoading,
|
||||
isOtpLoading: state.isLoading,
|
||||
canResend: canResend,
|
||||
otpCode: code,
|
||||
otpCode: state.code,
|
||||
otpErrorText: otpErrorText,
|
||||
onChanged: notifier.setCode,
|
||||
onVerify: () async {
|
||||
@@ -84,13 +54,28 @@ void showTwoFactorSheet(BuildContext context) {
|
||||
await notifier.submitTwoFACode();
|
||||
},
|
||||
onResend: () => notifier.resendCode(),
|
||||
onClose: () {
|
||||
notifier.dismissTwoFA();
|
||||
Navigator.of(sheetContext).pop();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
).then((_) {
|
||||
container.read(loginControllerProvider.notifier).dismissTwoFA();
|
||||
});
|
||||
}
|
||||
|
||||
String _formatCooldown(int seconds) {
|
||||
final min = seconds ~/ 60;
|
||||
final sec = seconds % 60;
|
||||
if (min <= 0) return '${sec}s';
|
||||
return '$min:${sec.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _maskEmail(String email) {
|
||||
if (email.isEmpty) return '';
|
||||
final parts = email.split('@');
|
||||
if (parts.length != 2) return email;
|
||||
final name = parts[0];
|
||||
final domain = parts[1];
|
||||
if (name.length <= 2) return '${name[0]}***@$domain';
|
||||
return '${name[0]}${'*' * (name.length - 2)}${name[name.length - 1]}@$domain';
|
||||
}
|
||||
|
||||
@@ -6,6 +6,33 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:navigation/navigation.dart';
|
||||
import 'package:sf_localizations/sf_localizations.dart';
|
||||
|
||||
class _BrandHintText extends StatelessWidget {
|
||||
final String text;
|
||||
final String brand;
|
||||
|
||||
const _BrandHintText({required this.text, required this.brand});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final parts = text.split(brand);
|
||||
if (parts.length < 2) return Text(text, textAlign: TextAlign.center);
|
||||
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: parts[0]),
|
||||
TextSpan(
|
||||
text: brand,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
TextSpan(text: parts[1]),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LegacyAccountCreatedScreen extends ConsumerWidget {
|
||||
final NavigationContract navigationContract;
|
||||
|
||||
@@ -19,8 +46,8 @@ class LegacyAccountCreatedScreen extends ConsumerWidget {
|
||||
|
||||
final state = ref.watch(signUpControllerProvider);
|
||||
|
||||
final String email = state.email;
|
||||
final String fullName = '${state.firstName} ${state.lastName}'.trim();
|
||||
final String email = state.email;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
@@ -59,24 +86,16 @@ class LegacyAccountCreatedScreen extends ConsumerWidget {
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
Text.rich(
|
||||
Text(
|
||||
email,
|
||||
textAlign: TextAlign.center,
|
||||
TextSpan(
|
||||
text:
|
||||
'${context.translate(I18n.accountCreatedEmailVerificationSentLabel)}\n',
|
||||
children: [
|
||||
TextSpan(
|
||||
text: email,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
context.translate(I18n.accountCreatedChildSetupHint),
|
||||
textAlign: TextAlign.center,
|
||||
_BrandHintText(
|
||||
text: context.translate(I18n.accountCreatedChildSetupHint),
|
||||
brand: 'SaveFamily',
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
@@ -37,7 +37,7 @@ class LegacySignUpLayout extends StatelessWidget {
|
||||
if (isLoading)
|
||||
const ColoredBox(
|
||||
color: Colors.black26,
|
||||
child: Center(child: AppLoadingIndicator()),
|
||||
child: Center(child: LegacyLoadingIndicator()),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -45,10 +45,10 @@ class LegacySignUpLayout extends StatelessWidget {
|
||||
|
||||
Widget _buildScaffold(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: SafeArea(
|
||||
child: Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 24, right: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -85,7 +85,9 @@ class LegacySignUpLayout extends StatelessWidget {
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
|
||||
),
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user