diff --git a/mobile/lib/features/pairing/add_community_page.dart b/mobile/lib/features/pairing/add_community_page.dart index 8a99c70936..98f0f62779 100644 --- a/mobile/lib/features/pairing/add_community_page.dart +++ b/mobile/lib/features/pairing/add_community_page.dart @@ -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 onOpenCommunity; final ValueChanged 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, diff --git a/mobile/lib/features/pairing/add_community_page/add_community_connection.dart b/mobile/lib/features/pairing/add_community_page/add_community_connection.dart index a0928bfed7..a9cff8c0d2 100644 --- a/mobile/lib/features/pairing/add_community_page/add_community_connection.dart +++ b/mobile/lib/features/pairing/add_community_page/add_community_connection.dart @@ -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, diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 8860dea879..1dee8a7bf3 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -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( 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, diff --git a/mobile/lib/onboarding_app_entry.dart b/mobile/lib/onboarding_app_entry.dart index 6ac2dec14c..8051ac835d 100644 --- a/mobile/lib/onboarding_app_entry.dart +++ b/mobile/lib/onboarding_app_entry.dart @@ -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 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, diff --git a/mobile/lib/shared/hosted/use_known_communities.dart b/mobile/lib/shared/hosted/use_known_communities.dart new file mode 100644 index 0000000000..54cb750731 --- /dev/null +++ b/mobile/lib/shared/hosted/use_known_communities.dart @@ -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 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 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; +} diff --git a/mobile/test/features/onboarding/hosted_communities_test.dart b/mobile/test/features/onboarding/hosted_communities_test.dart index 36e4cc6e26..8edf99a919 100644 --- a/mobile/test/features/onboarding/hosted_communities_test.dart +++ b/mobile/test/features/onboarding/hosted_communities_test.dart @@ -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); diff --git a/mobile/test/features/onboarding/known_communities_refresh_test.dart b/mobile/test/features/onboarding/known_communities_refresh_test.dart new file mode 100644 index 0000000000..bd0493b72e --- /dev/null +++ b/mobile/test/features/onboarding/known_communities_refresh_test.dart @@ -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 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(); + 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()); + }); +} diff --git a/mobile/test/features/pairing/pairing_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart index 8c49c82792..5ea050a6b9 100644 --- a/mobile/test/features/pairing/pairing_page_test.dart +++ b/mobile/test/features/pairing/pairing_page_test.dart @@ -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 {