diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index 442e66034b..d33a826251 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -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 diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 28da21583d..64750a2724 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.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) diff --git a/mobile/lib/features/settings/settings_page/builderlab_account_section.dart b/mobile/lib/features/settings/settings_page/builderlab_account_section.dart new file mode 100644 index 0000000000..a08960a31e --- /dev/null +++ b/mobile/lib/features/settings/settings_page/builderlab_account_section.dart @@ -0,0 +1,49 @@ +part of '../settings_page.dart'; + +/// Opens a Builderlab web page outside the app. Overridable in tests. +final builderlabWebLauncherProvider = Provider 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, + ); + }, + ), + ], + ); + } +} diff --git a/mobile/test/features/settings/builderlab_account_row_test.dart b/mobile/test/features/settings/builderlab_account_row_test.dart new file mode 100644 index 0000000000..e4101d8346 --- /dev/null +++ b/mobile/test/features/settings/builderlab_account_row_test.dart @@ -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> 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 = []; + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + currentCommunityRoleProvider.overrideWithValue( + const AsyncData(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); +} diff --git a/mobile/test/features/settings/settings_page_test.dart b/mobile/test/features/settings/settings_page_test.dart index 6f741d25ad..fa542092af 100644 --- a/mobile/test/features/settings/settings_page_test.dart +++ b/mobile/test/features/settings/settings_page_test.dart @@ -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); }); }