diff --git a/mobile/lib/features/onboarding/organization_sign_in_banner.dart b/mobile/lib/features/onboarding/organization_sign_in_banner.dart index 789aab8904..1a2727fdcf 100644 --- a/mobile/lib/features/onboarding/organization_sign_in_banner.dart +++ b/mobile/lib/features/onboarding/organization_sign_in_banner.dart @@ -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 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, ); } } diff --git a/mobile/lib/shared/widgets/buzz_toast.dart b/mobile/lib/shared/widgets/buzz_toast.dart index bb0ceee64a..2d3c2be15f 100644 --- a/mobile/lib/shared/widgets/buzz_toast.dart +++ b/mobile/lib/shared/widgets/buzz_toast.dart @@ -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 { - 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 { @override bool shouldReclip(_ToastRevealClipper oldClipper) => - oldClipper.progress != progress; + oldClipper.progress != progress || oldClipper.radius != radius; } diff --git a/mobile/lib/shared/widgets/reconnect_capsule.dart b/mobile/lib/shared/widgets/reconnect_capsule.dart index 62f9ffeda1..585294b195 100644 --- a/mobile/lib/shared/widgets/reconnect_capsule.dart +++ b/mobile/lib/shared/widgets/reconnect_capsule.dart @@ -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, diff --git a/mobile/lib/shared/widgets/toast_capsule_content.dart b/mobile/lib/shared/widgets/toast_capsule_content.dart index e9ea24eaad..7fdf50ad29 100644 --- a/mobile/lib/shared/widgets/toast_capsule_content.dart +++ b/mobile/lib/shared/widgets/toast_capsule_content.dart @@ -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, ), ), ], diff --git a/mobile/test/features/onboarding/hosted_journeys_test.dart b/mobile/test/features/onboarding/hosted_journeys_test.dart index dc4ed6e2e2..fe5179defe 100644 --- a/mobile/test/features/onboarding/hosted_journeys_test.dart +++ b/mobile/test/features/onboarding/hosted_journeys_test.dart @@ -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); diff --git a/mobile/test/features/onboarding/organization_sign_in_banner_test.dart b/mobile/test/features/onboarding/organization_sign_in_banner_test.dart new file mode 100644 index 0000000000..e001c9c360 --- /dev/null +++ b/mobile/test/features/onboarding/organization_sign_in_banner_test.dart @@ -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 reconnect() async { + retries++; + } +} + +void main() { + late OrganizationSignIn signIn; + final logins = []; + 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 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( + 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); + }); +}