mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
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 | |---|---|---| |  |  |  | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
56
mobile/lib/shared/hosted/use_known_communities.dart
Normal file
56
mobile/lib/shared/hosted/use_known_communities.dart
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user