fix(mobile): reuse shared sheet for Builderlab setup

Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-10-15 18:19:07 -07:00
parent 6891b52a1d
commit a63664315c
6 changed files with 199 additions and 211 deletions

View File

@@ -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<String?>(null);
Future<void> 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'),
),
],
);

View File

@@ -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<dynamic>? sheetRoute;
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!active || !context.mounted) return;
await showBuzzModalBottomSheet<void>(
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();
}
}

View File

@@ -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',
),
),
],
),
),
);
}
}

View File

@@ -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) {

View File

@@ -443,28 +443,6 @@ class HostedFlow extends ChangeNotifier {
return _enqueue(() => _continue(generation));
}
Future<void> refreshCommunities() {
if (!accountOnly || stage != HostedFlowStage.chooseCommunities) {
return Future.value();
}
_addingCommunities = false;
return retry();
}
Future<void> 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<void> _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<void> leave() async {
Future<void> 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;

View File

@@ -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);
}
},