Skip to content

Commit 23a059a

Browse files
committed
fix(authenticator): show loading spinner only on clicked sign-in button
When clicking 'with password' or 'with passkey', only the clicked button shows a loading spinner while the other is disabled. Previously both buttons showed spinners which was confusing. Changes: - Add busyButtonKey and isBusyFor() to AuthenticatorState to track which button initiated the busy state - Update _AmplifyElevatedButtonState to show spinner only for the active button (others just disable) - Pass buttonKey from each sign-in button's onPressed handler - Fix ContinueSignInWithFirstFactorSelectionForm to track which action (password or factor) is submitting, showing spinner only on that button - Update MockAuthenticatorState in tests to implement new members
1 parent e24e6ff commit 23a059a

4 files changed

Lines changed: 93 additions & 33 deletions

File tree

packages/authenticator/amplify_authenticator/lib/src/state/authenticator_state.dart

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,32 @@ class AuthenticatorState extends ChangeNotifier {
7171
}
7272
}
7373

74-
/// Indicates if the form is currently in a loading state
74+
/// Indicates if the form is currently in a loading state.
7575
///
76-
/// Will be set to true when an asynchronous action (such as login) in
77-
/// initiated, and will be set to false when that asynchronous action completes
76+
/// Will be set to true when an asynchronous action (such as login) is
77+
/// initiated, and will be set to false when that asynchronous action completes.
7878
bool get isBusy => _isBusy;
7979

80+
/// The [ButtonResolverKey] of the button that initiated the current busy
81+
/// state, or `null` when idle or when the originating button is unknown.
82+
///
83+
/// Use [isBusyFor] to check whether a specific button should show its
84+
/// loading indicator.
85+
ButtonResolverKey? get busyButtonKey => _busyButtonKey;
86+
87+
/// Returns `true` when the authenticator is busy **and** the operation was
88+
/// initiated by a button with the given [key].
89+
///
90+
/// Buttons whose [key] does not match should disable themselves without
91+
/// showing a spinner.
92+
bool isBusyFor(ButtonResolverKey key) => _isBusy && _busyButtonKey == key;
93+
8094
bool _isBusy = false;
81-
void _setIsBusy(bool busy) {
95+
ButtonResolverKey? _busyButtonKey;
96+
97+
void _setIsBusy(bool busy, {ButtonResolverKey? buttonKey}) {
8298
_isBusy = busy;
99+
_busyButtonKey = busy ? buttonKey : null;
83100
notifyListeners();
84101
}
85102

@@ -510,13 +527,16 @@ class AuthenticatorState extends ChangeNotifier {
510527
_setIsBusy(false);
511528
}
512529

513-
/// Sign in with [username], and [password]
514-
Future<void> signIn() async {
530+
/// Sign in with [username], and [password].
531+
///
532+
/// The optional [buttonKey] identifies which button initiated the operation
533+
/// so that only that button displays a loading spinner.
534+
Future<void> signIn({ButtonResolverKey? buttonKey}) async {
515535
if (!_formKey.currentState!.validate()) {
516536
return;
517537
}
518538

519-
_setIsBusy(true);
539+
_setIsBusy(true, buttonKey: buttonKey);
520540

521541
TextInput.finishAutofillContext(shouldSave: true);
522542

@@ -540,11 +560,17 @@ class AuthenticatorState extends ChangeNotifier {
540560
}
541561

542562
/// Sign in with username and password, bypassing any passwordless preference.
543-
Future<void> signInWithPassword() async {
563+
///
564+
/// The optional [buttonKey] identifies which button initiated the operation
565+
/// so that only that button displays a loading spinner.
566+
Future<void> signInWithPassword({ButtonResolverKey? buttonKey}) async {
544567
if (!_formKey.currentState!.validate()) {
545568
return;
546569
}
547-
_setIsBusy(true);
570+
_setIsBusy(
571+
true,
572+
buttonKey: buttonKey ?? ButtonResolverKey.signInWithPassword,
573+
);
548574
TextInput.finishAutofillContext(shouldSave: true);
549575
_authBloc.add(
550576
AuthSignIn(
@@ -561,12 +587,17 @@ class AuthenticatorState extends ChangeNotifier {
561587
/// Sign in with a specific passwordless factor (webAuthn, emailOtp, smsOtp).
562588
///
563589
/// Only validates that a username is present — password validation is skipped
564-
/// since passwordless methods don't require one.
565-
Future<void> signInWithFactor(AuthFactorType factor) async {
590+
/// since passwordless methods don't require one. The optional [buttonKey]
591+
/// identifies which button initiated the operation so that only that button
592+
/// displays a loading spinner.
593+
Future<void> signInWithFactor(
594+
AuthFactorType factor, {
595+
ButtonResolverKey? buttonKey,
596+
}) async {
566597
if (_username.trim().isEmpty) {
567598
return;
568599
}
569-
_setIsBusy(true);
600+
_setIsBusy(true, buttonKey: buttonKey);
570601
_authBloc.add(
571602
AuthSignIn(
572603
AuthPasswordlessSignInData(

packages/authenticator/amplify_authenticator/lib/src/widgets/button.dart

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,14 @@ class _AmplifyElevatedButtonState
109109
Widget build(BuildContext context) {
110110
final buttonResolver = stringResolver.buttons;
111111
final loadingIndicator = widget.loadingIndicator;
112+
final isThisButtonBusy = state.isBusyFor(widget.labelKey);
113+
final isBusyNoKey = state.isBusy && state.busyButtonKey == null;
114+
final showSpinner =
115+
(isThisButtonBusy || isBusyNoKey) && loadingIndicator != null;
112116
final onPressed = state.isBusy
113117
? null
114118
: () => widget.onPressed(context, state);
115-
final child = state.isBusy && loadingIndicator != null
119+
final child = showSpinner
116120
? loadingIndicator
117121
: Row(
118122
mainAxisSize: MainAxisSize.min,
@@ -181,7 +185,7 @@ class SignInButton extends AuthenticatorElevatedButton {
181185

182186
@override
183187
void onPressed(BuildContext context, AuthenticatorState state) =>
184-
state.signIn();
188+
state.signIn(buttonKey: labelKey);
185189
}
186190

187191
/// A sign-in button that always uses the password flow, bypassing any
@@ -194,7 +198,7 @@ class SignInWithPasswordButton extends AuthenticatorElevatedButton {
194198

195199
@override
196200
void onPressed(BuildContext context, AuthenticatorState state) =>
197-
state.signInWithPassword();
201+
state.signInWithPassword(buttonKey: labelKey);
198202
}
199203

200204
/// Password sign-in button with "Sign in with password" label.
@@ -206,7 +210,7 @@ class SignInWithPasswordLabeledButton extends AuthenticatorElevatedButton {
206210

207211
@override
208212
void onPressed(BuildContext context, AuthenticatorState state) =>
209-
state.signInWithPassword();
213+
state.signInWithPassword(buttonKey: labelKey);
210214
}
211215

212216
/// A sign-in button that uses the passkey (passwordless) flow.
@@ -218,7 +222,7 @@ class SignInWithPasskeyButton extends AuthenticatorElevatedButton {
218222

219223
@override
220224
void onPressed(BuildContext context, AuthenticatorState state) =>
221-
state.signIn();
225+
state.signIn(buttonKey: labelKey);
222226
}
223227

224228
/// A sign-in button for a specific [AuthFactorType] (passwordless).
@@ -243,7 +247,7 @@ class FactorSignInButton extends AuthenticatorElevatedButton {
243247

244248
@override
245249
void onPressed(BuildContext context, AuthenticatorState state) =>
246-
state.signInWithFactor(factor);
250+
state.signInWithFactor(factor, buttonKey: labelKey);
247251
}
248252

249253
/// {@category Prebuilt Widgets}

packages/authenticator/amplify_authenticator/lib/src/widgets/form.dart

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -931,6 +931,9 @@ class _ContinueSignInWithFirstFactorSelectionFormState
931931
bool _isPasskeySupported = false;
932932
bool _isPasskeySupportChecked = false;
933933
bool _isSubmitting = false;
934+
935+
/// Tracks which action initiated the submission (password or a factor key).
936+
String? _submittingAction;
934937
final _passwordController = TextEditingController();
935938

936939
@override
@@ -1003,7 +1006,10 @@ class _ContinueSignInWithFirstFactorSelectionFormState
10031006
final password = _passwordController.text.trim();
10041007
if (password.isEmpty) return;
10051008

1006-
setState(() => _isSubmitting = true);
1009+
setState(() {
1010+
_isSubmitting = true;
1011+
_submittingAction = 'password';
1012+
});
10071013

10081014
final confirm = AuthConfirmSignInData(confirmationValue: password);
10091015
final bloc = InheritedAuthBloc.of(context, listen: false);
@@ -1012,12 +1018,18 @@ class _ContinueSignInWithFirstFactorSelectionFormState
10121018
await state.nextBlocEvent();
10131019

10141020
if (mounted) {
1015-
setState(() => _isSubmitting = false);
1021+
setState(() {
1022+
_isSubmitting = false;
1023+
_submittingAction = null;
1024+
});
10161025
}
10171026
}
10181027

10191028
Future<void> _selectFactor(AuthFactorType factor) async {
1020-
setState(() => _isSubmitting = true);
1029+
setState(() {
1030+
_isSubmitting = true;
1031+
_submittingAction = factor.value;
1032+
});
10211033

10221034
final confirm = AuthConfirmSignInData(confirmationValue: factor.value);
10231035
final bloc = InheritedAuthBloc.of(context, listen: false);
@@ -1026,7 +1038,10 @@ class _ContinueSignInWithFirstFactorSelectionFormState
10261038
await state.nextBlocEvent();
10271039

10281040
if (mounted) {
1029-
setState(() => _isSubmitting = false);
1041+
setState(() {
1042+
_isSubmitting = false;
1043+
_submittingAction = null;
1044+
});
10301045
}
10311046
}
10321047

@@ -1088,10 +1103,10 @@ class _ContinueSignInWithFirstFactorSelectionFormState
10881103
const SizedBox(height: 12),
10891104
SizedBox(
10901105
width: double.infinity,
1091-
child: _isSubmitting
1106+
child: _submittingAction == 'password'
10921107
? const Center(child: CircularProgressIndicator())
10931108
: ElevatedButton(
1094-
onPressed: _submitPassword,
1109+
onPressed: _isSubmitting ? null : _submitPassword,
10951110
child: Text(
10961111
buttonResolver.resolve(
10971112
context,
@@ -1127,15 +1142,19 @@ class _ContinueSignInWithFirstFactorSelectionFormState
11271142
padding: const EdgeInsets.only(bottom: 8),
11281143
child: SizedBox(
11291144
width: double.infinity,
1130-
child: OutlinedButton(
1131-
onPressed: _isSubmitting ? null : () => _selectFactor(factor),
1132-
child: Text(
1133-
buttonResolver.resolve(
1134-
context,
1135-
_buttonKeyForFactor(factor),
1136-
),
1137-
),
1138-
),
1145+
child: _submittingAction == factor.value
1146+
? const Center(child: CircularProgressIndicator())
1147+
: OutlinedButton(
1148+
onPressed: _isSubmitting
1149+
? null
1150+
: () => _selectFactor(factor),
1151+
child: Text(
1152+
buttonResolver.resolve(
1153+
context,
1154+
_buttonKeyForFactor(factor),
1155+
),
1156+
),
1157+
),
11391158
),
11401159
),
11411160
),

packages/authenticator/amplify_authenticator/test/authenticated_view_test.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,12 @@ class MockAuthenticatorState extends Mock implements AuthenticatorState {
234234
@override
235235
bool get isBusy => _isBusy;
236236

237+
@override
238+
ButtonResolverKey? get busyButtonKey => null;
239+
240+
@override
241+
bool isBusyFor(ButtonResolverKey key) => _isBusy && busyButtonKey == key;
242+
237243
set isBusy(bool busy) {
238244
_isBusy = busy;
239245
}

0 commit comments

Comments
 (0)