Simplify signed-in mobile community onboarding (#8852)

Replace the signed-in mobile welcome screen with **Add a community**: QR
scan first, then grey secondary actions for community links, finding
saved communities, and creation. Hide **Find my communities** when none
are known; refresh on entry/resume and every 30 seconds while active,
retaining results on transient failure.

Community switcher → Add keeps its two-action flow and uses the same
grey secondary link button.

Validation: Flutter analysis, full mobile suite (4,014 tests),
Builderlab-enabled full suite (4,036 tests), widget interactions, and
independent agent review. Draft pending native sign-in and human
testing. Snapshots render the production Flutter widgets; native
navigation/status bars are omitted.

| Communities available | No communities | Switcher → Add |
|---|---|---|
| ![Onboarding with
communities](https://github.com/user-attachments/assets/444b05c5-711f-41df-9c50-293ad9e13767)
| ![Onboarding without
communities](https://github.com/user-attachments/assets/db78a490-bd8e-4d78-97b4-a391b52290d8)
| ![Switcher Add with grey secondary
button](https://github.com/user-attachments/assets/de9e42d2-dd53-462b-98c7-da87223e5dd4)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-10-31 14:46:44 +00:00
committed by GitHub
parent eafd77e9f6
commit 9c3ba83adb
8 changed files with 307 additions and 9 deletions

View File

@@ -24,6 +24,9 @@ class AddCommunityPage extends StatelessWidget {
required this.onScan,
required this.onOpenCommunity,
required this.onSubmitPairing,
this.onboarding = false,
this.onFindCommunities,
this.onCreateCommunity,
this.enteringCommunityLink = false,
this.existingUser = false,
this.scannerProgress = 0,
@@ -40,6 +43,9 @@ class AddCommunityPage extends StatelessWidget {
final VoidCallback? onScan;
final ValueChanged<Uri> onOpenCommunity;
final ValueChanged<String> onSubmitPairing;
final bool onboarding;
final VoidCallback? onFindCommunities;
final VoidCallback? onCreateCommunity;
final bool enteringCommunityLink;
final bool existingUser;
final double scannerProgress;
@@ -60,6 +66,9 @@ class AddCommunityPage extends StatelessWidget {
? _AddCommunityConnection(
key: const ValueKey('add-community-connection'),
enteringLink: enteringCommunityLink,
onboarding: onboarding,
onFindCommunities: onFindCommunities,
onCreateCommunity: onCreateCommunity,
scannerProgress: scannerProgress,
accountStatus: accountStatus,
onBack: onBack,

View File

@@ -5,6 +5,9 @@ class _AddCommunityConnection extends HookWidget {
const _AddCommunityConnection({
super.key,
required this.enteringLink,
required this.onboarding,
this.onFindCommunities,
this.onCreateCommunity,
required this.scannerProgress,
required this.onBack,
required this.onCommunityLink,
@@ -19,6 +22,9 @@ class _AddCommunityConnection extends HookWidget {
});
final bool enteringLink;
final bool onboarding;
final VoidCallback? onFindCommunities;
final VoidCallback? onCreateCommunity;
final double scannerProgress;
final VoidCallback onBack;
final VoidCallback? onCommunityLink;
@@ -71,6 +77,8 @@ class _AddCommunityConnection extends HookWidget {
title: enteringLink ? 'Enter a community link' : 'Add a community',
subtitle: enteringLink
? 'Paste an invite link or community address.'
: onboarding
? 'Join your people or create a space of your own.'
: accountStatus ?? 'Scan a QR code or enter a community link.',
onBack: onBack,
foregroundProgress: scannerProgress,
@@ -163,7 +171,7 @@ class _AddCommunityConnection extends HookWidget {
key: const Key('add-community-link-action'),
style: FilledButton.styleFrom(
backgroundColor: Color.lerp(
Colors.transparent,
context.controlSurface,
context.colors.primary,
progress,
),
@@ -188,11 +196,33 @@ class _AddCommunityConnection extends HookWidget {
? busy
? 'Connecting…'
: 'Continue'
: onboarding
? 'Enter community link'
: 'Enter a community link',
key: ValueKey(enteringLink),
),
),
),
if (onboarding && !enteringLink) ...[
if (onFindCommunities != null)
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: context.controlSurface,
foregroundColor: context.colors.onSurface,
),
onPressed: busy ? null : onFindCommunities,
child: const Text('Find my communities'),
),
if (onCreateCommunity != null)
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: context.controlSurface,
foregroundColor: context.colors.onSurface,
),
onPressed: busy ? null : onCreateCommunity,
child: const Text('Create a community'),
),
],
if (pairingVisible.value && !enteringLink)
TextButton(
onPressed: busy ? null : onPairing,

View File

@@ -51,6 +51,8 @@ class PairingPage extends HookConsumerWidget {
/// (user is already authenticated with at least one community).
final bool addingCommunity;
final bool identityRecoveryOnly;
final bool signedInOnboarding;
final bool hasKnownCommunities;
final VoidCallback? onAlreadyUseBuzz;
final VoidCallback? onWelcomeBack;
final bool connectionLanding;
@@ -72,6 +74,8 @@ class PairingPage extends HookConsumerWidget {
super.key,
this.addingCommunity = false,
this.identityRecoveryOnly = false,
this.signedInOnboarding = false,
this.hasKnownCommunities = false,
this.onAlreadyUseBuzz,
this.onWelcomeBack,
this.connectionLanding = false,
@@ -290,11 +294,17 @@ class PairingPage extends HookConsumerWidget {
),
),
)
: addingCommunity && connectionLanding
: (addingCommunity || (signedInOnboarding && !creatingCommunity)) &&
connectionLanding
? ValueListenableBuilder<double>(
valueListenable: islandScannerProgress,
builder: (context, progress, _) => AddCommunityPage(
scannerProgress: progress,
onboarding: signedInOnboarding,
onFindCommunities: signedInOnboarding && hasKnownCommunities
? onBuilderlab
: null,
onCreateCommunity: signedInOnboarding ? onCreateCommunity : null,
enteringCommunityLink: enteringCommunityLink,
existingUser: existingUser,
onBack: onWelcomeBack ?? () => Navigator.of(context).pop(),
@@ -306,7 +316,7 @@ class PairingPage extends HookConsumerWidget {
ref.read(pendingDeepLinkProvider.notifier).open(uri);
},
onSubmitPairing: (code) => unawaited(handleScannerResult(code)),
accountStatus: accountStatus,
accountStatus: signedInOnboarding ? null : accountStatus,
busy:
isBusy ||
builderlabBusy ||
@@ -384,7 +394,7 @@ class PairingPage extends HookConsumerWidget {
ref.read(pendingDeepLinkProvider.notifier).open(uri);
},
onBuilderlab: onBuilderlab,
accountStatus: accountStatus,
accountStatus: signedInOnboarding ? null : accountStatus,
setupContent: setupContent,
builderlabLabel: builderlabLabel,
builderlabBusy: builderlabBusy,

View File

@@ -7,6 +7,8 @@ import 'features/pairing/pairing_page.dart';
import 'features/pairing/pairing_provider.dart';
import 'shared/hosted/hosted_flow_provider.dart';
import 'shared/hosted/hosted_flow.dart';
import 'shared/hosted/hosted_communities.dart';
import 'shared/hosted/use_known_communities.dart';
import 'shared/hosted/hosted_environment.dart';
import 'shared/hosted/hosted_community_creation.dart';
import 'shared/hosted/hosted_flow_problem_message.dart';
@@ -32,6 +34,7 @@ class OnboardingAppEntry extends HookConsumerWidget {
? ref.watch(hostedFlowProvider)
: null;
useListenable(flow);
useListenable(flow?.account.subjectChanges);
useEffect(() {
if (flow == null || !restoreAccount) return null;
Future<void> restore() async {
@@ -84,6 +87,18 @@ class OnboardingAppEntry extends HookConsumerWidget {
(flow.stage == HostedFlowStage.working ||
flow.stage == HostedFlowStage.signIn);
final connected = flow?.account.account != null;
final hasKnownCommunities = useKnownCommunities(
accountKey: flow?.account.account?.subject,
active:
!addingCommunity &&
!existingUser.value &&
!enteringCommunityLink.value &&
!creating.value &&
flow?.stage == HostedFlowStage.idle &&
(ModalRoute.of(context)?.isCurrent ?? true),
load: () async =>
(await loadKnownHostedCommunities(flow!.account)).isNotEmpty,
);
final problem = flow != null && flow.accountOnly ? flow.problem : null;
final status = flow == null
? null
@@ -150,6 +165,8 @@ class OnboardingAppEntry extends HookConsumerWidget {
showConnectionOptions:
!HostedEnvironment.enabled || addingCommunity || connected,
addingCommunity: addingCommunity,
signedInOnboarding: connected && !addingCommunity && !signingIn,
hasKnownCommunities: hasKnownCommunities,
existingUser: existingUser.value,
enteringCommunityLink: enteringCommunityLink.value,
creatingCommunity: creating.value,

View File

@@ -0,0 +1,56 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
/// Refreshes account destinations only while the entry screen is active.
/// A failed refresh preserves the last successful result for this account.
bool useKnownCommunities({
required String? accountKey,
required bool active,
required Future<bool> Function() load,
}) {
final available = useMemoized(() => ValueNotifier(false), [accountKey]);
useEffect(() => available.dispose, [available]);
useListenable(available);
final latestLoad = useRef(load)..value = load;
useEffect(() {
if (accountKey == null || !active) return null;
var disposed = false;
var loading = false;
Future<void> refresh() async {
if (loading) return;
loading = true;
try {
final result = await latestLoad.value();
if (!disposed) available.value = result;
} catch (_) {
// Discovery is optional; an unavailable service is not an empty list.
} finally {
loading = false;
}
}
Timer? timer;
void lifecycleChanged(AppLifecycleState? state) {
timer?.cancel();
timer = null;
if (state != null && state != AppLifecycleState.resumed) return;
unawaited(refresh());
timer = Timer.periodic(const Duration(seconds: 30), (_) {
unawaited(refresh());
});
}
// Lifecycle callbacks must stop timers immediately: paused apps do not
// render a frame, so a rebuild-driven effect may never run its cleanup.
final listener = AppLifecycleListener(onStateChange: lifecycleChanged);
lifecycleChanged(WidgetsBinding.instance.lifecycleState);
return () {
disposed = true;
timer?.cancel();
listener.dispose();
};
}, [accountKey, active, available]);
return available.value;
}

View File

@@ -249,7 +249,7 @@ void main() {
await f.flow.signInAccount(openBrowser: false);
await tester.pumpAndSettle();
f.expired = true;
await tester.tap(find.text('Continue with Builderlab'));
await tester.tap(find.text('Find my communities'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(f.flow.stage, HostedFlowStage.signIn);
@@ -265,9 +265,9 @@ void main() {
await tester.pumpAndSettle();
expect(f.flow.stage, HostedFlowStage.idle);
await tester.pumpAndSettle();
expect(find.text('Continue with Builderlab'), findsOneWidget);
expect(find.text('Find my communities'), findsOneWidget);
expect(find.byType(HostedConnectPage), findsNothing);
await tester.tap(find.text('Continue with Builderlab'));
await tester.tap(find.text('Find my communities'));
await tester.pumpAndSettle();
expect(f.flow.stage, HostedFlowStage.chooseCommunities);
expect(find.byType(CommunityGridOption), findsWidgets);
@@ -285,7 +285,7 @@ void main() {
throw const SavedCommunityIdentityConflict();
}
};
await tester.tap(find.text('Continue with Builderlab'));
await tester.tap(find.text('Find my communities'));
await tester.pumpAndSettle();
expect(f.flow.stage, HostedFlowStage.chooseCommunities);
expect(find.byType(CommunityGridOption), findsWidgets);
@@ -301,7 +301,7 @@ void main() {
f.beforeAdmission = (name) async {
if (name == 'two.communities.buzz.xyz') throw AccountFailure.rejected;
};
await tester.tap(find.text('Continue with Builderlab'));
await tester.tap(find.text('Find my communities'));
await tester.pumpAndSettle();
expect(f.flow.stage, HostedFlowStage.chooseCommunities);
expect(find.byType(CommunityGridOption), findsWidgets);

View File

@@ -0,0 +1,117 @@
import 'dart:async';
import 'package:buzz/shared/hosted/use_known_communities.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('discovery refreshes, retains results on error and pauses', (
tester,
) async {
var calls = 0;
var active = true;
var fail = false;
Future<bool> load() async {
calls++;
if (fail) throw StateError('offline');
return calls > 1;
}
Widget page() => MaterialApp(
home: HookBuilder(
builder: (_) {
final found = useKnownCommunities(
accountKey: 'a',
active: active,
load: load,
);
return Text(found ? 'found' : 'empty');
},
),
);
await tester.pumpWidget(page());
await tester.pump();
expect(calls, 1);
expect(find.text('empty'), findsOneWidget);
await tester.pump(const Duration(seconds: 30));
await tester.pump();
expect(find.text('found'), findsOneWidget);
fail = true;
await tester.pump(const Duration(seconds: 30));
await tester.pump();
expect(find.text('found'), findsOneWidget);
active = false;
await tester.pumpWidget(page());
final pausedCalls = calls;
await tester.pump(const Duration(minutes: 1));
expect(calls, pausedCalls);
await tester.pumpWidget(const SizedBox());
});
testWidgets('account change discards old results and in-flight responses', (
tester,
) async {
var account = 'a';
final first = Completer<bool>();
Widget page() => MaterialApp(
home: HookBuilder(
builder: (_) {
final found = useKnownCommunities(
accountKey: account,
active: true,
load: () => account == 'a' ? first.future : Future.value(false),
);
return Text(found ? 'found' : 'empty');
},
),
);
await tester.pumpWidget(page());
await tester.pump(const Duration(seconds: 30));
account = 'b';
await tester.pumpWidget(page());
await tester.pump();
first.complete(true);
await tester.pump();
expect(find.text('empty'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
});
testWidgets('backgrounding stops requests and resuming refreshes', (
tester,
) async {
var calls = 0;
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pumpWidget(
MaterialApp(
home: HookBuilder(
builder: (_) {
useKnownCommunities(
accountKey: 'a',
active: true,
load: () async {
calls++;
return true;
},
);
return const SizedBox();
},
),
),
);
await tester.pump();
expect(calls, 1);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pump();
await tester.pump(const Duration(minutes: 1));
expect(calls, 1);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(calls, 2);
await tester.pumpWidget(const SizedBox());
});
}

View File

@@ -20,6 +20,65 @@ import '../../helpers/widget_helpers.dart';
void main() {
group('PairingPage', () {
testWidgets(
'signed-in onboarding has ordered actions and switcher Add stays separate',
(tester) async {
var found = false;
var created = false;
for (final hasCommunities in [true, false]) {
await tester.pumpWidget(
WidgetHelpers.testable(
child: PairingPage(
connectionLanding: true,
signedInOnboarding: true,
hasKnownCommunities: hasCommunities,
onBuilderlab: () => found = true,
onCreateCommunity: () => created = true,
onCommunityLink: () {},
onWelcomeBack: () {},
),
),
);
await tester.pumpAndSettle();
expect(find.bySemanticsLabel('Buzz'), findsNothing);
expect(find.text('Scan a QR code'), findsOneWidget);
expect(find.text('Enter community link'), findsOneWidget);
expect(find.text('Create a community'), findsOneWidget);
expect(
find.text('Find my communities'),
hasCommunities ? findsOneWidget : findsNothing,
);
expect(
tester.getTopLeft(find.text('Scan a QR code')).dy,
lessThan(tester.getTopLeft(find.text('Enter community link')).dy),
);
if (hasCommunities) {
await tester.tap(find.text('Find my communities'));
expect(found, isTrue);
}
await tester.tap(find.text('Create a community'));
expect(created, isTrue);
}
await tester.pumpWidget(
WidgetHelpers.testable(
child: PairingPage(
connectionLanding: true,
addingCommunity: true,
onWelcomeBack: () {},
onCommunityLink: () {},
onBuilderlab: () {},
onCreateCommunity: () {},
),
),
);
await tester.pumpAndSettle();
expect(find.text('Scan a QR code'), findsOneWidget);
expect(find.text('Enter a community link'), findsOneWidget);
expect(find.text('Find my communities'), findsNothing);
expect(find.text('Create a community'), findsNothing);
},
);
testWidgets(
'verified pairing stays on loading through transfer and import',
(tester) async {