mirror of
https://github.com/block/buzz.git
synced 2026-11-03 16:01:35 +00:00
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:
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
112
mobile/lib/features/onboarding/hosted_connect_sheet.dart
Normal file
112
mobile/lib/features/onboarding/hosted_connect_sheet.dart
Normal 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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user