feat(mobile): link to Builderlab web from Settings (#9300)

Neither mobile app offers a way to reach Builderlab's web-only account
features: GitHub linking, billing and creating a community. This adds a
**Builderlab account** row to Settings that opens the build's Builderlab
origin (app.builderlab.xyz on public builds) in the system browser.

The row shows only on public builds. An internal build's Builderlab is
its organization's tenant, where billing and the single community are
managed by the organization, so the link wouldn't lead anywhere useful.
Nostr-only builds have no Builderlab.

Sign-in uses a non-ephemeral `ASWebAuthenticationSession`, which shares
Safari's cookies, so Safari can reuse the Builderlab web session. A
browser other than Safari, or Android, may still ask the user to sign
in.

| Before | After |
| --- | --- |
| <img alt="before"
src="https://github.com/user-attachments/assets/a8f2157b-5a54-41fc-8937-013638b3347e"
width="300"> | <img alt="after"
src="https://github.com/user-attachments/assets/f1161f3b-c926-4a8c-b6a1-7532ef72749c"
width="300"> |

Nothing else gets worse. The change adds one row and one browser launch
on tap, and does no work at startup or in the background. A failure
stays inside this row: if the browser doesn't open, a toast says so.

### Validation

- Added tests: `builderlab_account_row_test.dart` checks that the row
opens `HostedEnvironment.origin` and shows a toast when the browser
fails to open (public build), and that the row is absent otherwise. It
is added to the CI public-build lane.
- `settings_page_test.dart`: the version-footer scroll check now finds
the footer after it scrolls just offscreen. The extra row pushed it
there under public-build defines.
- Settings tests pass under both public and Nostr-only defines, and
`flutter analyze` is clean.
- iOS simulator (public build, signed in to the public Builderlab): the
row appears below Connection, and tapping it opens Safari at Builderlab.
This simulator's session came from an identity import, not the in-app
browser sign-in, so Safari was signed out. I haven't yet verified that
Safari arrives signed in after a real in-app sign-in.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
This commit is contained in:
Tom Brow
2026-11-11 04:11:38 -08:00
committed by GitHub
parent 2c5c8343ef
commit 045b637b0d
5 changed files with 146 additions and 4 deletions

View File

@@ -163,6 +163,7 @@ jobs:
test/features/agents/agents_restore_prompt_test.dart
test/shared/hosted/relay_auth_isolation_test.dart
test/features/onboarding/hosted_app_gate_environment_test.dart
test/features/settings/builderlab_account_row_test.dart
test/features/onboarding/hosted_journeys_test.dart
test/features/onboarding/onboarding_sign_out_test.dart
test/features/onboarding/hosted_communities_test.dart

View File

@@ -10,10 +10,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/shared/theme/buzz_icons.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../shared/auth/auth.dart';
import '../../shared/identity_transfer/nsec_export_sheet.dart';
import '../../shared/clipboard_utils.dart';
import '../../shared/hosted/hosted_environment.dart';
import '../../shared/hosted/organization_environment.dart';
import '../../shared/hosted/organization_sign_out.dart';
import '../../shared/push/push_relay_capability_provider.dart';
@@ -33,6 +35,7 @@ part 'settings_page/profile_section.dart';
part 'settings_page/status_section.dart';
part 'settings_page/connection_section.dart';
part 'settings_page/notifications_section.dart';
part 'settings_page/builderlab_account_section.dart';
Widget _emptyProfileEditPage(BuildContext context) => const SizedBox.shrink();
@@ -132,6 +135,7 @@ class SettingsPage extends HookConsumerWidget {
_ConnectionSection(
identityRecoveryPageBuilder: identityRecoveryPageBuilder,
),
if (HostedEnvironment.enabled) const _BuilderlabAccountSection(),
// An internal build's only way out of its organization, rarely
// needed, so it sits last.
if (OrganizationEnvironment.enabled)

View File

@@ -0,0 +1,49 @@
part of '../settings_page.dart';
/// Opens a Builderlab web page outside the app. Overridable in tests.
final builderlabWebLauncherProvider = Provider<Future<bool> Function(Uri)>(
(ref) =>
(uri) => launchUrl(uri, mode: LaunchMode.externalApplication),
);
/// Links a public build to its Builderlab web dashboard, for the account
/// features mobile doesn't have: GitHub linking, billing and creating
/// communities. Sign-in's web session is not ephemeral, so it shares
/// Safari's cookies and Safari can reuse that Builderlab web session.
class _BuilderlabAccountSection extends ConsumerWidget {
const _BuilderlabAccountSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
return AppListCard(
verticalPadding: Grid.twelve,
children: [
AppListRow(
key: const Key('settings-builderlab-account'),
title: 'Builderlab account',
trailing: Icon(
BuzzIcons.externalLink,
size: 18,
color: context.colors.onSurfaceVariant,
),
onTap: () async {
bool opened;
try {
opened = await ref.read(builderlabWebLauncherProvider)(
HostedEnvironment.origin,
);
} on PlatformException {
// launchUrl throws instead of returning false on some failures.
opened = false;
}
if (opened || !context.mounted) return;
ScaffoldMessenger.of(context).showFeedbackSnackBar(
const SnackBar(content: Text('Couldn’t open Builderlab.')),
kind: ToastKind.error,
);
},
),
],
);
}
}

View File

@@ -0,0 +1,85 @@
import 'package:buzz/features/settings/settings_page.dart';
import 'package:buzz/shared/community/community_membership_provider.dart';
import 'package:buzz/shared/hosted/hosted_environment.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
Future<List<Uri>> pumpSettings(
WidgetTester tester, {
bool opens = true,
bool throws = false,
}) async {
PackageInfo.setMockInitialValues(
appName: 'Buzz',
packageName: 'xyz.block.buzz',
version: '0.16.0',
buildNumber: '432',
buildSignature: '',
);
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final opened = <Uri>[];
await tester.pumpWidget(
ProviderScope(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
currentCommunityRoleProvider.overrideWithValue(
const AsyncData<CommunityMemberRole?>(CommunityMemberRole.member),
),
builderlabWebLauncherProvider.overrideWithValue((uri) async {
opened.add(uri);
if (throws) throw PlatformException(code: 'ACTIVITY_NOT_FOUND');
return opens;
}),
],
child: MaterialApp(
theme: AppTheme.light(),
home: SettingsPage(
profileHeader: const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
),
),
),
);
await tester.pumpAndSettle();
return opened;
}
final row = find.byKey(const Key('settings-builderlab-account'));
testWidgets('opens the public Builderlab dashboard in the browser', (
tester,
) async {
final opened = await pumpSettings(tester);
await tester.scrollUntilVisible(row, 200);
await tester.tap(row);
await tester.pumpAndSettle();
expect(opened, [HostedEnvironment.origin]);
expect(find.text('Couldn’t open Builderlab.'), findsNothing);
}, skip: !HostedEnvironment.enabled);
for (final throws in [false, true]) {
testWidgets(
'says so when the browser ${throws ? 'throws' : 'does not open'}',
(tester) async {
await pumpSettings(tester, opens: false, throws: throws);
await tester.scrollUntilVisible(row, 200);
await tester.tap(row);
await tester.pumpAndSettle();
expect(find.text('Couldn’t open Builderlab.'), findsOneWidget);
},
skip: !HostedEnvironment.enabled,
);
}
testWidgets('is absent outside a public build', (tester) async {
await pumpSettings(tester);
expect(row, findsNothing);
}, skip: HostedEnvironment.enabled);
}

View File

@@ -74,9 +74,8 @@ void main() {
);
expect(find.text('Invite to community'), findsNothing);
await tester.pumpAndSettle();
final version = find.text(
buildNumber.isEmpty ? 'v0.16.0' : 'v0.16.0 ($buildNumber)',
);
final label = buildNumber.isEmpty ? 'v0.16.0' : 'v0.16.0 ($buildNumber)';
final version = find.text(label);
await tester.scrollUntilVisible(version, 200);
await tester.pumpAndSettle();
expect(version.hitTestable(), findsOneWidget);
@@ -87,7 +86,11 @@ void main() {
final beforeScroll = tester.getTopLeft(version).dy;
await tester.drag(find.byType(ListView), const Offset(0, 100));
await tester.pumpAndSettle();
expect(tester.getTopLeft(version).dy, greaterThan(beforeScroll));
// Dragging down can push the footer just below the viewport.
expect(
tester.getTopLeft(find.text(label, skipOffstage: false)).dy,
greaterThan(beforeScroll),
);
expect(tester.takeException(), isNull);
});
}