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:

![Previous organization
banner](https://github.com/user-attachments/assets/4aceabd1-c9bc-491f-888b-7ee5cf005afa)

| Light mode · 375 pt | Dark mode · 375 pt |
| --- | --- |
| ![Light mode
states](https://github.com/user-attachments/assets/b00bc956-8d27-4b99-ac1e-1f68349c2aa3)
| ![Dark mode
states](https://github.com/user-attachments/assets/930d84d7-b11f-4585-b5d7-77d1c78367ec)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-11-04 16:02:07 +00:00
committed by GitHub
parent 0bdad8ea96
commit 5b738eb647
6 changed files with 358 additions and 80 deletions

View File

@@ -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,
);
}
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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,
),
),
],

View File

@@ -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);

View File

@@ -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);
});
}