mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
Restyle mobile organization sign-in prompts as toasts (#9050)
Replace the mobile organization sign-in bottom card with the existing top toast treatment. Use shorter status copy, a solid-red Cancel button with white text in both modes, and stacked Retry / Use a different account buttons for denied access with 12 pt side and bottom padding. The account-switch action uses a dark secondary treatment. Recovery callbacks and visibility rules are preserved. Validation: `just mobile-install mobile-check mobile-test`; widget journeys cover sign-in/cancel/completion, saved-session errors, account switching at 200% text size, and transient-toast coordination. The cancel regression was verified to fail with its callback disabled. Local Flutter previews using production widgets, with synthetic state; not device captures. Manual test: sign in, cancel, retry, and switch accounts after denied access; verify each action remains reachable and the toast clears after successful sign-in. Before:  | Light mode · 375 pt | Dark mode · 375 pt | | --- | --- | |  |  | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -9,8 +9,7 @@ import '../../shared/hosted/hosted_relay_auth.dart';
|
||||
import '../../shared/hosted/organization_sign_in.dart';
|
||||
import '../../shared/relay/relay_provider.dart';
|
||||
import '../../shared/relay/relay_session.dart';
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
import '../../shared/theme/grid.dart';
|
||||
import '../../shared/widgets/buzz_toast.dart';
|
||||
|
||||
/// Offers organization sign-in for the active community when it trusts the
|
||||
/// build's organization. Cached content stays available underneath.
|
||||
@@ -30,69 +29,43 @@ class OrganizationSignInBanner extends HookConsumerWidget {
|
||||
}
|
||||
final relayFailure = organizationRelayFailure(auth, config);
|
||||
final String message;
|
||||
final List<Widget> actions;
|
||||
final String actionLabel;
|
||||
final VoidCallback onAction;
|
||||
({String label, VoidCallback onPressed})? secondaryAction;
|
||||
switch (signIn.status) {
|
||||
case OrganizationSessionStatus.checking:
|
||||
// Unreachable: organizationSignInPromptVisible hides checking.
|
||||
return const SizedBox.shrink();
|
||||
case OrganizationSessionStatus.signingIn:
|
||||
message = 'Finishing organization sign-in…';
|
||||
actions = [
|
||||
TextButton(
|
||||
onPressed: () => unawaited(signIn.cancel()),
|
||||
child: const Text('Cancel sign-in'),
|
||||
),
|
||||
];
|
||||
message = 'Finish sign-in in browser';
|
||||
actionLabel = 'Cancel';
|
||||
onAction = () => unawaited(signIn.cancel());
|
||||
case OrganizationSessionStatus.signedOut:
|
||||
message = switch (signIn.failure) {
|
||||
null =>
|
||||
'Sign in with your organization to stay connected to this community.',
|
||||
AccountFailure.storage =>
|
||||
'Your saved organization sign-in could not be read. Sign in again to replace it.',
|
||||
_ => 'Organization sign-in did not finish. Try again.',
|
||||
};
|
||||
actions = [
|
||||
TextButton(
|
||||
onPressed: () => unawaited(signIn.signIn()),
|
||||
child: const Text('Sign in'),
|
||||
),
|
||||
];
|
||||
message = signIn.failure == null
|
||||
? "It looks like you're signed out."
|
||||
: 'Sign in failed. Try again.';
|
||||
actionLabel = 'Sign in';
|
||||
onAction = () => unawaited(signIn.signIn());
|
||||
case OrganizationSessionStatus.signedIn:
|
||||
// The check above hides signed out and unavailable, which reconnects.
|
||||
message = relayFailure == AccountFailure.denied
|
||||
? 'Your organization account cannot use this community with this key.'
|
||||
: 'Could not reconnect with your organization account.';
|
||||
actions = [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
unawaited(ref.read(relaySessionProvider.notifier).reconnect()),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
// Retrying the same refused account fails the same way.
|
||||
if (relayFailure == AccountFailure.denied)
|
||||
TextButton(
|
||||
onPressed: () => unawaited(signIn.changeAccount()),
|
||||
child: const Text('Use a different account'),
|
||||
),
|
||||
];
|
||||
final denied = relayFailure == AccountFailure.denied;
|
||||
message = denied
|
||||
? 'Denied access to this community.'
|
||||
: 'Could not connect.';
|
||||
actionLabel = 'Retry';
|
||||
onAction = () =>
|
||||
unawaited(ref.read(relaySessionProvider.notifier).reconnect());
|
||||
if (denied) {
|
||||
secondaryAction = (
|
||||
label: 'Use a different account',
|
||||
onPressed: () => unawaited(signIn.changeAccount()),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Positioned(
|
||||
left: Grid.sm,
|
||||
right: Grid.sm,
|
||||
bottom: 0,
|
||||
child: SafeArea(
|
||||
child: Material(
|
||||
elevation: 4,
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.sm),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [Text(message), ...actions],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
return ReconnectCapsule(
|
||||
message: message,
|
||||
actionLabel: actionLabel,
|
||||
onSignIn: onAction,
|
||||
destructiveAction: signIn.status == OrganizationSessionStatus.signingIn,
|
||||
secondaryAction: secondaryAction,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,8 @@ class _ToastBubble extends HookWidget {
|
||||
this.visible = true,
|
||||
this.persistent = false,
|
||||
this.actionLabel = 'Retry',
|
||||
this.destructiveAction = false,
|
||||
this.secondaryAction,
|
||||
});
|
||||
|
||||
final String message;
|
||||
@@ -143,10 +145,16 @@ class _ToastBubble extends HookWidget {
|
||||
final bool visible;
|
||||
final bool persistent;
|
||||
final String actionLabel;
|
||||
final bool destructiveAction;
|
||||
final ({String label, VoidCallback onPressed})? secondaryAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final origin = islandOrigin ?? _toastIslandOrigin(context);
|
||||
// The taller two-action card uses the standard toast entrance rather than
|
||||
// morphing from a capsule-shaped Dynamic Island.
|
||||
final origin = secondaryAction == null
|
||||
? islandOrigin ?? _toastIslandOrigin(context)
|
||||
: null;
|
||||
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||||
final accessible = MediaQuery.accessibleNavigationOf(context);
|
||||
final program = useFuture(
|
||||
@@ -286,7 +294,12 @@ class _ToastBubble extends HookWidget {
|
||||
progress: progress,
|
||||
),
|
||||
child: PhysicalShape(
|
||||
clipper: _ToastRevealClipper(reveal),
|
||||
clipper: _ToastRevealClipper(
|
||||
reveal,
|
||||
radius: secondaryAction == null
|
||||
? null
|
||||
: Radii.navigationCapsule,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
color: island != null && shader != null
|
||||
? Colors.transparent
|
||||
@@ -299,6 +312,8 @@ class _ToastBubble extends HookWidget {
|
||||
message: message,
|
||||
foreground: foreground,
|
||||
actionLabel: actionLabel,
|
||||
destructiveAction: destructiveAction,
|
||||
secondaryAction: secondaryAction,
|
||||
onAction: onRetry,
|
||||
),
|
||||
),
|
||||
@@ -314,16 +329,17 @@ class _ToastBubble extends HookWidget {
|
||||
}
|
||||
|
||||
class _ToastRevealClipper extends CustomClipper<Path> {
|
||||
const _ToastRevealClipper(this.progress);
|
||||
const _ToastRevealClipper(this.progress, {this.radius});
|
||||
|
||||
final double progress;
|
||||
final double? radius;
|
||||
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
final collapsedWidth = size.height.clamp(0.0, size.width);
|
||||
final width = collapsedWidth + (size.width - collapsedWidth) * progress;
|
||||
return RoundedSuperellipseBorder(
|
||||
borderRadius: BorderRadius.circular(size.height / 2),
|
||||
borderRadius: BorderRadius.circular(radius ?? size.height / 2),
|
||||
).getOuterPath(
|
||||
Rect.fromCenter(
|
||||
center: size.center(Offset.zero),
|
||||
@@ -335,5 +351,5 @@ class _ToastRevealClipper extends CustomClipper<Path> {
|
||||
|
||||
@override
|
||||
bool shouldReclip(_ToastRevealClipper oldClipper) =>
|
||||
oldClipper.progress != progress;
|
||||
oldClipper.progress != progress || oldClipper.radius != radius;
|
||||
}
|
||||
|
||||
@@ -6,12 +6,28 @@ class ReconnectCapsule extends HookWidget {
|
||||
super.key,
|
||||
required this.onSignIn,
|
||||
this.visible = true,
|
||||
this.message = 'Reconnect to Builderlab',
|
||||
this.actionLabel = 'Sign in',
|
||||
this.destructiveAction = false,
|
||||
this.secondaryAction,
|
||||
this.islandOrigin,
|
||||
this.islandSpeed = 0.75,
|
||||
});
|
||||
|
||||
final VoidCallback onSignIn;
|
||||
final bool visible;
|
||||
|
||||
/// Persistent status displayed beside or above the actions.
|
||||
final String message;
|
||||
|
||||
/// Label for the primary recovery action.
|
||||
final String actionLabel;
|
||||
|
||||
/// Uses the theme error colors for the primary action.
|
||||
final bool destructiveAction;
|
||||
|
||||
/// Adds a second recovery action and stacks both buttons below the message.
|
||||
final ({String label, VoidCallback onPressed})? secondaryAction;
|
||||
final Rect? islandOrigin;
|
||||
final double islandSpeed;
|
||||
|
||||
@@ -22,8 +38,10 @@ class ReconnectCapsule extends HookWidget {
|
||||
// Remounting after dismissal restarts the persistent entrance if needed.
|
||||
if (transientVisible) return const SizedBox.shrink();
|
||||
return _ToastBubble(
|
||||
message: 'Reconnect to Builderlab',
|
||||
actionLabel: 'Sign in',
|
||||
message: message,
|
||||
actionLabel: actionLabel,
|
||||
destructiveAction: destructiveAction,
|
||||
secondaryAction: secondaryAction,
|
||||
onRetry: onSignIn,
|
||||
onDismiss: () {},
|
||||
persistent: true,
|
||||
|
||||
@@ -9,12 +9,20 @@ class ToastCapsuleContent extends StatelessWidget {
|
||||
required this.message,
|
||||
required this.foreground,
|
||||
this.actionLabel = 'Retry',
|
||||
this.destructiveAction = false,
|
||||
this.secondaryAction,
|
||||
this.onAction,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final Color foreground;
|
||||
final String actionLabel;
|
||||
|
||||
/// Uses the theme error colors for the primary action.
|
||||
final bool destructiveAction;
|
||||
|
||||
/// Adds a second recovery action and stacks both buttons below the message.
|
||||
final ({String label, VoidCallback onPressed})? secondaryAction;
|
||||
final VoidCallback? onAction;
|
||||
|
||||
@override
|
||||
@@ -34,6 +42,54 @@ class ToastCapsuleContent extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
if (onAction == null) return text;
|
||||
Widget action(
|
||||
String label,
|
||||
VoidCallback onPressed, {
|
||||
bool destructive = false,
|
||||
bool secondary = false,
|
||||
}) => FilledButton(
|
||||
onPressed: onPressed,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: destructive
|
||||
? lightColorScheme.error
|
||||
: secondary
|
||||
? darkColorScheme.secondaryContainer
|
||||
: Colors.white,
|
||||
foregroundColor: destructive
|
||||
? lightColorScheme.onError
|
||||
: secondary
|
||||
? darkColorScheme.secondary
|
||||
: Colors.black,
|
||||
shape: const RoundedSuperellipseBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(999)),
|
||||
),
|
||||
minimumSize: const Size(48, 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Text(label),
|
||||
);
|
||||
final secondary = secondaryAction;
|
||||
if (secondary != null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.twelve,
|
||||
6,
|
||||
Grid.twelve,
|
||||
Grid.twelve,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
text,
|
||||
action(actionLabel, onAction!, destructive: destructiveAction),
|
||||
const SizedBox(height: 6),
|
||||
action(secondary.label, secondary.onPressed, secondary: true),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return IntrinsicWidth(
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
@@ -43,19 +99,10 @@ class ToastCapsuleContent extends StatelessWidget {
|
||||
Expanded(child: Center(widthFactor: 1, child: text)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6, right: 6, bottom: 6),
|
||||
child: FilledButton(
|
||||
onPressed: onAction,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
shape: const RoundedSuperellipseBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(999)),
|
||||
),
|
||||
minimumSize: const Size(48, 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Text(actionLabel),
|
||||
child: action(
|
||||
actionLabel,
|
||||
onAction!,
|
||||
destructive: destructiveAction,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -564,8 +564,7 @@ void main() {
|
||||
);
|
||||
addTearDown(sessionSubscription.close);
|
||||
await f.settle(tester);
|
||||
const refused =
|
||||
'Your organization account cannot use this community with this key.';
|
||||
const refused = 'Denied access to this community.';
|
||||
expect(find.text(refused), findsOneWidget);
|
||||
expectAboveTabBar(tester, 'Use a different account');
|
||||
expect(f.sent('/identity/assertions'), isEmpty);
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import 'dart:convert';
|
||||
import 'package:buzz/features/onboarding/organization_sign_in_banner.dart';
|
||||
import 'package:buzz/shared/hosted/browser_auth.dart';
|
||||
import 'package:buzz/shared/hosted/builderlab_account.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_relay_auth.dart';
|
||||
import 'package:buzz/shared/hosted/organization_sign_in.dart';
|
||||
import 'package:buzz/shared/relay/relay_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay_session.dart';
|
||||
import 'package:buzz/shared/theme/app_theme.dart';
|
||||
import 'package:buzz/shared/widgets/buzz_toast.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
class _Config extends RelayConfigNotifier {
|
||||
@override
|
||||
RelayConfig build() =>
|
||||
const RelayConfig(baseUrl: 'https://organization.example');
|
||||
}
|
||||
|
||||
class _Session extends RelaySessionNotifier {
|
||||
int retries = 0;
|
||||
@override
|
||||
SessionState build() =>
|
||||
const SessionState(status: SessionStatus.disconnected);
|
||||
@override
|
||||
Future<void> reconnect() async {
|
||||
retries++;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late OrganizationSignIn signIn;
|
||||
final logins = <Uri>[];
|
||||
late HostedRelayAuth auth;
|
||||
late _Session session;
|
||||
setUp(() async {
|
||||
FlutterSecureStorage.setMockInitialValues({});
|
||||
logins.clear();
|
||||
final expiry = DateTime.now().toUtc().add(const Duration(days: 1));
|
||||
final account = BuilderlabAccountClient(
|
||||
accountOrigin: Uri.parse('https://organization.example'),
|
||||
httpClient: MockClient(
|
||||
(request) async => http.Response(
|
||||
jsonEncode({
|
||||
if (request.url.path.endsWith('/exchange'))
|
||||
'session_credential': 'org-secret'
|
||||
else
|
||||
'subject': 'member',
|
||||
'expires_at': expiry.toIso8601String(),
|
||||
}),
|
||||
200,
|
||||
),
|
||||
),
|
||||
);
|
||||
signIn = OrganizationSignIn(
|
||||
account: account,
|
||||
browser: BrowserAuth(
|
||||
origin: account.accountOrigin,
|
||||
openBrowser: (uri) async {
|
||||
logins.add(uri);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
reconnect: () async {},
|
||||
);
|
||||
await signIn.initialize();
|
||||
auth = HostedRelayAuth(
|
||||
account,
|
||||
organization: account,
|
||||
organizationRelay: (_) => true,
|
||||
);
|
||||
session = _Session();
|
||||
addTearDown(() {
|
||||
signIn.dispose();
|
||||
auth.dispose();
|
||||
account.dispose();
|
||||
});
|
||||
});
|
||||
Future<void> mount(WidgetTester tester, {double scale = 1}) async {
|
||||
await tester.binding.setSurfaceSize(const Size(375, 667));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
organizationSignInProvider.overrideWithValue(signIn),
|
||||
hostedRelayAuthProvider.overrideWithValue(auth),
|
||||
relayConfigProvider.overrideWith(_Config.new),
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: MediaQuery(
|
||||
data: MediaQueryData(
|
||||
size: const Size(375, 667),
|
||||
textScaler: TextScaler.linear(scale),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
const OrganizationSignInBanner(),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
child: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => showBuzzToast(context, 'Saved'),
|
||||
child: const Text('Toast'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('sign in, cancel, retry sign-in and successful completion', (
|
||||
tester,
|
||||
) async {
|
||||
await mount(tester);
|
||||
expect(find.text("It looks like you're signed out."), findsOneWidget);
|
||||
await tester.tap(find.text('Sign in'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(logins, hasLength(1));
|
||||
expect(find.text('Finish sign-in in browser'), findsOneWidget);
|
||||
final button = tester.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, 'Cancel'),
|
||||
);
|
||||
expect(
|
||||
button.style!.backgroundColor!.resolve({}),
|
||||
AppTheme.light().colorScheme.error,
|
||||
);
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await pumpEventQueue();
|
||||
});
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text("It looks like you're signed out."), findsOneWidget);
|
||||
await tester.tap(find.text('Sign in'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.runAsync(() async {
|
||||
await signIn.browser.receive(
|
||||
Uri.parse(BrowserAuth.callback).replace(
|
||||
queryParameters: {
|
||||
'state': logins.last.queryParameters['state']!,
|
||||
'code': 'code',
|
||||
},
|
||||
),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
});
|
||||
expect(signIn.account.account?.subject, 'member');
|
||||
expect(
|
||||
await const FlutterSecureStorage().read(
|
||||
key: 'buzz_hosted_session_organization_example_v1',
|
||||
),
|
||||
contains('member'),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(ReconnectCapsule), findsNothing);
|
||||
});
|
||||
for (final failure in [AccountFailure.storage, AccountFailure.unavailable]) {
|
||||
testWidgets('$failure keeps sign-in recovery available', (tester) async {
|
||||
signIn.failure = failure;
|
||||
await mount(tester);
|
||||
expect(find.text('Sign in failed. Try again.'), findsOneWidget);
|
||||
await tester.tap(find.text('Sign in'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(logins, hasLength(1));
|
||||
});
|
||||
}
|
||||
testWidgets('denied access retains both actions at large text sizes', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.runAsync(() async {
|
||||
await signIn.signIn();
|
||||
await signIn.browser.receive(
|
||||
Uri.parse(BrowserAuth.callback).replace(
|
||||
queryParameters: {
|
||||
'state': logins.last.queryParameters['state']!,
|
||||
'code': 'code',
|
||||
},
|
||||
),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
});
|
||||
expect(signIn.account.account?.subject, 'member');
|
||||
logins.clear();
|
||||
auth.failedRelay = 'https://organization.example';
|
||||
auth.failure = AccountFailure.denied;
|
||||
await mount(tester, scale: 2);
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('Denied access to this community.'), findsOneWidget);
|
||||
expect(
|
||||
tester.getTopLeft(find.text('Use a different account')).dy,
|
||||
greaterThan(tester.getBottomLeft(find.text('Retry')).dy),
|
||||
);
|
||||
await tester.tap(find.text('Retry'));
|
||||
expect(session.retries, 1);
|
||||
await tester.tap(find.text('Use a different account'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(logins, hasLength(1));
|
||||
expect(signIn.account.account, isNull);
|
||||
expect(find.text('Cancel'), findsOneWidget);
|
||||
});
|
||||
testWidgets('transient toast yields back to persistent sign-in prompt', (
|
||||
tester,
|
||||
) async {
|
||||
await mount(tester);
|
||||
await tester.tap(find.text('Toast'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Saved'), findsOneWidget);
|
||||
expect(find.text('Sign in'), findsNothing);
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Sign in'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user