mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
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:
1
.github/workflows/_ci-clients.yml
vendored
1
.github/workflows/_ci-clients.yml
vendored
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user