diff --git a/mobile/lib/features/onboarding/hosted_community_selection.dart b/mobile/lib/features/onboarding/hosted_community_selection.dart index 0612934e22..535461870f 100644 --- a/mobile/lib/features/onboarding/hosted_community_selection.dart +++ b/mobile/lib/features/onboarding/hosted_community_selection.dart @@ -2,10 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../shared/deeplink/pending_deep_link_provider.dart'; import '../../shared/hosted/hosted_flow.dart'; import '../../shared/theme/grid.dart'; -import 'community_link_input.dart'; /// Public community choices only; identity material remains in secure storage. class HostedCommunitySelection extends HookConsumerWidget { @@ -15,70 +13,24 @@ class HostedCommunitySelection extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { useListenable(flow); - final invite = useTextEditingController(); - final error = useState(null); - Future openLink() async { - final uri = parseCommunityLinkInput(invite.text); - if (uri == null) { - error.value = 'Enter a valid community address or invite link.'; - return; - } - final pending = ref.read(pendingDeepLinkProvider.notifier); - await flow.leave(); - if (flow.stage == HostedFlowStage.idle) pending.open(uri); - } - - if (flow.communities.isNotEmpty) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final community in flow.communities) - CheckboxListTile( - key: ValueKey(community.relayUrl), - title: Text(community.name), - subtitle: Text(Uri.parse(community.relayUrl).host), - value: flow.selectedRelays.contains(community.relayUrl), - onChanged: (value) => - flow.selectCommunity(community.relayUrl, value ?? false), - ), - const SizedBox(height: Grid.sm), - FilledButton( - onPressed: flow.selectedRelays.isEmpty - ? null - : flow.addSelectedCommunities, - child: const Text('Continue'), - ), - ], - ); - } return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - FilledButton( - onPressed: flow.openCommunityCreation, - child: const Text('Create a community in Builderlab'), - ), - TextButton( - onPressed: flow.refreshCommunities, - child: const Text('Refresh communities'), - ), - const SizedBox(height: Grid.sm), - TextField( - controller: invite, - keyboardType: TextInputType.url, - textInputAction: TextInputAction.go, - onSubmitted: (_) => openLink(), - onChanged: (_) => error.value = null, - autocorrect: false, - enableSuggestions: false, - decoration: InputDecoration( - labelText: 'Community address or invite link', - errorText: error.value, + for (final community in flow.communities) + CheckboxListTile( + key: ValueKey(community.relayUrl), + title: Text(community.name), + subtitle: Text(Uri.parse(community.relayUrl).host), + value: flow.selectedRelays.contains(community.relayUrl), + onChanged: (value) => + flow.selectCommunity(community.relayUrl, value ?? false), ), - ), - OutlinedButton( - onPressed: openLink, - child: const Text('Open community link'), + const SizedBox(height: Grid.sm), + FilledButton( + onPressed: flow.selectedRelays.isEmpty + ? null + : flow.addSelectedCommunities, + child: const Text('Continue'), ), ], ); diff --git a/mobile/lib/features/onboarding/hosted_connect_page.dart b/mobile/lib/features/onboarding/hosted_connect_page.dart index d2b0ab102d..3911a99770 100644 --- a/mobile/lib/features/onboarding/hosted_connect_page.dart +++ b/mobile/lib/features/onboarding/hosted_connect_page.dart @@ -3,13 +3,10 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/hosted/hosted_flow.dart'; -import '../../shared/hosted/hosted_flow_problem_message.dart'; -import '../../shared/utils/string_utils.dart'; -import '../../shared/theme/grid.dart'; -import '../../shared/theme/theme_extensions.dart'; -import 'hosted_community_selection.dart'; +import '../../shared/widgets/modal_presentation.dart'; +import 'hosted_connect_sheet.dart'; -/// Presents the app-scoped real flow without retaining credentials or keys. +/// Presents setup with the same modal chrome as the app's other sheets. class HostedConnectPage extends HookConsumerWidget { const HostedConnectPage({ super.key, @@ -21,118 +18,34 @@ class HostedConnectPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - useListenable(flow); - final working = flow.stage == HostedFlowStage.working; - final message = switch (flow.stage) { - HostedFlowStage.idle => 'Continue connecting your account.', - HostedFlowStage.working => - flow.addingCommunityName == null - ? 'Securing your account and finding communities…' - : 'Adding ${flow.addingCommunityName}…', - HostedFlowStage.chooseCommunities => - flow.communities.isEmpty - ? 'No communities to add. Create one in Builderlab, then refresh, or enter a community link.' - : 'Choose your communities', - HostedFlowStage.communityConflict => - '${flow.addingCommunityName} is already saved with a different identity. ' - 'Keep that identity and skip this addition, or return to your choices?', - HostedFlowStage.chooseIdentity => - flow.identityChoices.any((choice) => choice.fromCloud) - ? 'Use your Builderlab identity on this device? It differs from your saved community identities. Those keys and your cloud backups will be kept. This app cannot change the selected Builderlab identity later.' - : 'Choose the identity to connect to Builderlab. It will be bound to your account and backed up. Existing keys will be kept. This app cannot change the selected Builderlab identity later.', - HostedFlowStage.signIn => 'Complete sign-in in your browser.', - HostedFlowStage.browserReturned => - 'Continue setup in your browser. Open your community in Buzz when it is ready.', - HostedFlowStage.complete => 'Your community is ready.', - HostedFlowStage.blocked => hostedFlowProblemMessage(flow.problem), - }; - void close() { - onClose(); - } - - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, _) { - if (!didPop) close(); - }, - child: Stack( - children: [ - ModalBarrier(color: Colors.black54, dismissible: false), - AlertDialog( - title: const Text('Connect to Builderlab'), - content: SizedBox( - width: 440, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(message, style: context.textTheme.titleLarge), - const SizedBox(height: Grid.sm), - for (final choice in flow.identityChoices) - Padding( - padding: const EdgeInsets.only(bottom: Grid.sm), - child: OutlinedButton( - onPressed: () => flow.chooseIdentity(choice.pubkey), - child: Padding( - padding: const EdgeInsets.all(Grid.sm), - child: Column( - children: [ - Text(choice.label), - Text(fullNpub(choice.pubkey) ?? choice.pubkey), - ], - ), - ), - ), - ), - if (flow.stage == HostedFlowStage.chooseCommunities) - HostedCommunitySelection(flow: flow), - if (flow.stage == HostedFlowStage.communityConflict) - FilledButton( - onPressed: flow.skipConflictingCommunity, - child: const Text('Keep saved identity and skip'), - ), - if (flow.canChangeCommunities) - OutlinedButton( - onPressed: flow.backToCommunityChoices, - child: const Text('Back to choices'), - ), - if ((flow.stage == HostedFlowStage.blocked || - flow.stage == HostedFlowStage.communityConflict) && - flow.addedCommunityCount > 0) - Text( - '${flow.addedCommunityCount} ' - '${flow.addedCommunityCount == 1 ? "community" : "communities"} added and kept.' - '${flow.canRetry && flow.stage == HostedFlowStage.blocked ? " Retry continues the remaining additions." : ""}', - ), - if (working) - const Center(child: CircularProgressIndicator()), - if (!working && - flow.stage != HostedFlowStage.chooseIdentity && - flow.stage != HostedFlowStage.chooseCommunities && - flow.stage != HostedFlowStage.communityConflict && - flow.stage != HostedFlowStage.complete && - flow.stage != HostedFlowStage.browserReturned && - flow.canRetry) - FilledButton( - onPressed: flow.retry, - child: Text( - flow.stage == HostedFlowStage.signIn - ? 'Start login again' - : 'Try again', - ), - ), - TextButton( - onPressed: close, - child: const Text('Leave setup'), - ), - ], - ), - ), - ), - ), - ], - ), - ); + useEffect(() { + var active = true; + ModalRoute? sheetRoute; + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!active || !context.mounted) return; + await showBuzzModalBottomSheet( + context: context, + title: 'Connect to Builderlab', + useRootNavigator: true, + isScrollControlled: true, + useSafeArea: true, + builder: (sheetContext) { + sheetRoute = ModalRoute.of(sheetContext); + return HostedConnectSheet(flow: flow); + }, + ); + if (active) onClose(); + }); + return () { + active = false; + final route = sheetRoute; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (route != null && route.isActive) { + route.navigator?.removeRoute(route); + } + }); + }; + }, [flow]); + return const SizedBox.shrink(); } } diff --git a/mobile/lib/features/onboarding/hosted_connect_sheet.dart b/mobile/lib/features/onboarding/hosted_connect_sheet.dart new file mode 100644 index 0000000000..8d2b1bf6e2 --- /dev/null +++ b/mobile/lib/features/onboarding/hosted_connect_sheet.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/hosted/hosted_flow.dart'; +import '../../shared/hosted/hosted_flow_problem_message.dart'; +import '../../shared/utils/string_utils.dart'; +import '../../shared/theme/grid.dart'; +import '../../shared/theme/theme_extensions.dart'; +import 'hosted_community_selection.dart'; + +/// Presents the app-scoped real flow without retaining credentials or keys. +class HostedConnectSheet extends HookConsumerWidget { + const HostedConnectSheet({super.key, required this.flow}); + final HostedFlow flow; + + @override + Widget build(BuildContext context, WidgetRef ref) { + useListenable(flow); + final working = flow.stage == HostedFlowStage.working; + final message = switch (flow.stage) { + HostedFlowStage.idle => 'Continue connecting your account.', + HostedFlowStage.working => + flow.addingCommunityName == null + ? 'Securing your account and finding communities…' + : 'Adding ${flow.addingCommunityName}…', + HostedFlowStage.chooseCommunities => 'Choose your communities', + HostedFlowStage.communityConflict => + '${flow.addingCommunityName} is already saved with a different identity. ' + 'Keep that identity and skip this addition, or return to your choices?', + HostedFlowStage.chooseIdentity => + flow.identityChoices.any((choice) => choice.fromCloud) + ? 'Use your Builderlab identity on this device? It differs from your saved community identities. Those keys and your cloud backups will be kept. This app cannot change the selected Builderlab identity later.' + : 'Choose the identity to connect to Builderlab. It will be bound to your account and backed up. Existing keys will be kept. This app cannot change the selected Builderlab identity later.', + HostedFlowStage.signIn => 'Complete sign-in in your browser.', + HostedFlowStage.browserReturned => + 'Continue setup in your browser. Open your community in Buzz when it is ready.', + HostedFlowStage.complete => 'Your community is ready.', + HostedFlowStage.blocked => hostedFlowProblemMessage(flow.problem), + }; + return Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + Grid.sm, + Grid.gutter, + Grid.lg, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(message, style: context.textTheme.bodyLarge), + const SizedBox(height: Grid.sm), + for (final choice in flow.identityChoices) + Padding( + padding: const EdgeInsets.only(bottom: Grid.sm), + child: OutlinedButton( + onPressed: () => flow.chooseIdentity(choice.pubkey), + child: Padding( + padding: const EdgeInsets.all(Grid.sm), + child: Column( + children: [ + Text(choice.label), + Text(fullNpub(choice.pubkey) ?? choice.pubkey), + ], + ), + ), + ), + ), + if (flow.stage == HostedFlowStage.chooseCommunities) + HostedCommunitySelection(flow: flow), + if (flow.stage == HostedFlowStage.communityConflict) + FilledButton( + onPressed: flow.skipConflictingCommunity, + child: const Text('Keep saved identity and skip'), + ), + if (flow.canChangeCommunities) + OutlinedButton( + onPressed: flow.backToCommunityChoices, + child: const Text('Back to choices'), + ), + if ((flow.stage == HostedFlowStage.blocked || + flow.stage == HostedFlowStage.communityConflict) && + flow.addedCommunityCount > 0) + Text( + '${flow.addedCommunityCount} ' + '${flow.addedCommunityCount == 1 ? "community" : "communities"} added and kept.' + '${flow.canRetry && flow.stage == HostedFlowStage.blocked ? " Retry continues the remaining additions." : ""}', + ), + if (working) const Center(child: CircularProgressIndicator()), + if (!working && + flow.stage != HostedFlowStage.chooseIdentity && + flow.stage != HostedFlowStage.chooseCommunities && + flow.stage != HostedFlowStage.communityConflict && + flow.stage != HostedFlowStage.complete && + flow.stage != HostedFlowStage.browserReturned && + flow.canRetry) + FilledButton( + onPressed: flow.retry, + child: Text( + flow.stage == HostedFlowStage.signIn + ? 'Start login again' + : 'Try again', + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/onboarding_app_entry.dart b/mobile/lib/onboarding_app_entry.dart index 7f2dbc55cd..9e9208318a 100644 --- a/mobile/lib/onboarding_app_entry.dart +++ b/mobile/lib/onboarding_app_entry.dart @@ -34,7 +34,9 @@ class OnboardingAppEntry extends HookConsumerWidget { flow.stage == HostedFlowStage.signIn); final connected = flow.account.account != null; final problem = flow.accountOnly ? flow.problem : null; - final status = connected + final status = flow.noCommunitiesToAdd + ? 'No communities available to add. Scan a QR code or use an invite link.' + : connected ? 'Connected to Builderlab. Your session is saved securely on this device.' : problem != null ? switch (problem) { diff --git a/mobile/lib/shared/hosted/hosted_flow.dart b/mobile/lib/shared/hosted/hosted_flow.dart index fa2c92ddb0..e75dd61e58 100644 --- a/mobile/lib/shared/hosted/hosted_flow.dart +++ b/mobile/lib/shared/hosted/hosted_flow.dart @@ -443,28 +443,6 @@ class HostedFlow extends ChangeNotifier { return _enqueue(() => _continue(generation)); } - Future refreshCommunities() { - if (!accountOnly || stage != HostedFlowStage.chooseCommunities) { - return Future.value(); - } - _addingCommunities = false; - return retry(); - } - - Future openCommunityCreation() async { - if (stage != HostedFlowStage.chooseCommunities) return; - final generation = _generation; - try { - if (!await _openWeb(HostedEnvironment.origin.resolve('/buzz'))) { - throw BrowserAuthFailure.unavailable; - } - } catch (error) { - if (!_disposed && generation == _generation) { - _set(HostedFlowStage.blocked, _problemFor(error)); - } - } - } - Future _continueCommunities( int generation, String subject, @@ -480,7 +458,7 @@ class HostedFlow extends ChangeNotifier { communities = listed; selectedRelays = Set.unmodifiable(listed.map((item) => item.relayUrl)); if (listed.isEmpty) { - await leave(); + await leave(noCommunities: true); return; } if (listed.length != 1) { @@ -514,7 +492,7 @@ class HostedFlow extends ChangeNotifier { communities = List.unmodifiable( communities.where((item) => selectedRelays.contains(item.relayUrl)), ); - await leave(); + await leave(noCommunities: true); return; } await browser.clearIntent(); @@ -606,7 +584,10 @@ class HostedFlow extends ChangeNotifier { notifyListeners(); } + bool noCommunitiesToAdd = false; + void _clearCommunityBatch() { + noCommunitiesToAdd = false; communities = const []; selectedRelays = const {}; _addedRelays.clear(); @@ -616,12 +597,13 @@ class HostedFlow extends ChangeNotifier { addingCommunityName = null; } - Future leave() async { + Future leave({bool noCommunities = false}) async { ++_generation; final generation = _generation; _target = null; accountOnly = false; _clearCommunityBatch(); + noCommunitiesToAdd = noCommunities; try { await browser.cancel(); if (_disposed || generation != _generation) return; diff --git a/mobile/test/features/onboarding/hosted_communities_test.dart b/mobile/test/features/onboarding/hosted_communities_test.dart index d87ac14914..b3027c46b2 100644 --- a/mobile/test/features/onboarding/hosted_communities_test.dart +++ b/mobile/test/features/onboarding/hosted_communities_test.dart @@ -1,4 +1,5 @@ import 'package:buzz/features/onboarding/hosted_connect_page.dart'; +import 'package:buzz/shared/widgets/buzz_sheet_header.dart'; import 'package:buzz/hosted_app_gate.dart'; import 'package:buzz/onboarding_app_entry.dart'; import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; @@ -127,15 +128,41 @@ void main() { expect(find.byType(HostedConnectPage), findsNothing); expect(find.text('Scan a QR code'), findsOneWidget); expect(find.text('Use pairing code'), findsOneWidget); + expect( + find.text( + 'No communities available to add. Scan a QR code or use an invite link.', + ), + findsOneWidget, + ); f.rows = [f.row('denied')]; f.beforeAdmission = (_) async => throw AccountFailure.denied; await tester.tap(find.text('Continue with Builderlab')); await tester.pumpAndSettle(); - expect(find.byType(AlertDialog), findsOneWidget); - expect(find.text('Scan a QR code'), findsOneWidget); - await tester.tap(find.text('Leave setup').hitTestable()); + expect(find.byType(BuzzSheetHeader), findsOneWidget); + expect( + find.text( + 'No communities available to add. Scan a QR code or use an invite link.', + ), + findsNothing, + ); + final semantics = tester.ensureSemantics(); await tester.pumpAndSettle(); - expect(find.byType(AlertDialog), findsNothing); + expect( + tester + .binding + .renderViews + .single + .owner! + .semanticsOwner! + .rootSemanticsNode! + .toStringDeep(), + isNot(contains('Scan a QR code')), + ); + semantics.dispose(); + expect(find.text('Scan a QR code'), findsOneWidget); + await tester.tap(find.byTooltip('Close sheet').hitTestable()); + await tester.pumpAndSettle(); + expect(find.byType(BuzzSheetHeader), findsNothing); expect(find.text('Scan a QR code'), findsOneWidget); } },