Files
buzz/mobile/lib/features/settings/settings_page.dart
Tom Brow 045b637b0d 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>
2026-11-11 04:11:38 -08:00

230 lines
8.0 KiB
Dart

import 'package:buzz/shared/widgets/feedback_snackbar.dart';
import 'dart:async';
import 'package:buzz/shared/crypto/event_signing.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
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';
import '../../shared/push/push_bootstrap.dart';
import '../../shared/relay/relay.dart';
import '../../shared/utils/string_utils.dart';
import '../pairing/pairing_provider.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/app_list.dart';
import '../../shared/widgets/app_list_card.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/ios_glass_navigation_button.dart';
import '../../shared/widgets/immediate_page_route.dart';
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();
class SettingsPage extends HookConsumerWidget {
/// Creates the settings page.
const SettingsPage({
super.key,
required this.profileHeader,
required this.identityRecoveryPageBuilder,
this.profileEditPageBuilder = _emptyProfileEditPage,
this.onSetStatus,
this.onEditDisplayName,
this.onEditProfileDescription,
});
/// Header widget displayed at the top of settings.
final Widget profileHeader;
/// Builds the identity-recovery page pushed from the recovery settings row.
final WidgetBuilder identityRecoveryPageBuilder;
/// Builds the current-user profile editor opened from the Photo row.
final WidgetBuilder profileEditPageBuilder;
/// Opens the current-user status editor.
final void Function(BuildContext context)? onSetStatus;
/// Opens the display-name editor from the settings section.
final Future<void> Function(BuildContext context)? onEditDisplayName;
/// Opens the profile-description editor from the settings section.
final Future<void> Function(BuildContext context)? onEditProfileDescription;
@override
Widget build(BuildContext context, WidgetRef ref) {
final packageInfoFuture = useMemoized(() => PackageInfo.fromPlatform());
final packageInfo = useFuture(packageInfoFuture);
final topSectionHeight = frostedAppBarHeight(
context,
bottomHeight: Grid.xxs,
);
// Keep feedback local even while Home is mounted during the transition.
return ScaffoldMessenger(
child: FrostedScaffold(
useUtilitySurfaceTheme: true,
appBar: FrostedAppBar(
nativeTitle: 'Settings',
nativeLeading: IosNavigationAction(
label: 'Close settings',
symbol: 'xmark',
onPressed: () => Navigator.of(context).pop(),
),
automaticallyImplyLeading: false,
horizontalInset: Grid.gutter,
leading: Theme.of(context).platform == TargetPlatform.iOS
? IosGlassNavigationButton(
key: const ValueKey('settings-ios-glass-close'),
icon: IosGlassNavigationIcon.close,
semanticLabel: 'Close settings',
onPressed: () {
unawaited(HapticFeedback.lightImpact());
Navigator.of(context).pop();
},
foregroundColor: navigationPrimaryForeground(context),
)
: SizedBox(
width: Grid.xl,
height: Grid.xl,
child: IconButton(
tooltip: 'Close settings',
onPressed: () {
unawaited(HapticFeedback.lightImpact());
Navigator.of(context).pop();
},
color: navigationPrimaryForeground(context),
icon: const Icon(BuzzIcons.x),
),
),
bottomHeight: Grid.xxs,
bottom: const SizedBox.expand(),
),
body: ListView(
padding: EdgeInsets.only(
top: topSectionHeight,
bottom: MediaQuery.paddingOf(context).bottom + Grid.xs,
),
children: [
profileHeader,
_StatusSection(onSetStatus: onSetStatus),
_ProfileSection(
profileEditPageBuilder: profileEditPageBuilder,
onEditDisplayName: onEditDisplayName,
onEditProfileDescription: onEditProfileDescription,
),
const _NotificationsSection(),
_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)
AppListCard(
verticalPadding: Grid.twelve,
children: [
AppListRow(
key: const Key('settings-sign-out'),
icon: BuzzIcons.logOut,
title: 'Sign out',
titleColor: context.colors.error,
onTap: () => confirmOrganizationSignOut(context, ref),
),
],
),
if (packageInfo.hasData)
_VersionFooter(
version: packageInfo.data!.version,
buildNumber: packageInfo.data!.buildNumber,
),
],
),
),
);
}
}
class _VersionFooter extends StatelessWidget {
const _VersionFooter({required this.version, required this.buildNumber});
final String version;
final String buildNumber;
@override
Widget build(BuildContext context) {
final label = buildNumber.isEmpty
? 'v$version'
: 'v$version ($buildNumber)';
return SafeArea(
top: false,
child: Center(
child: Semantics(
button: true,
onTapHint: 'Copy version',
child: InkWell(
borderRadius: BorderRadius.circular(Grid.xs),
onTap: () =>
copyToClipboard(context, label, message: 'Version copied'),
// Keep the text where it was and grow the tap target downward
// to the platform minimum.
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: kMinInteractiveDimension,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.xs,
Grid.xxs,
Grid.xs,
Grid.xs,
),
child: Text(
label,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant.withValues(
alpha: 0.6,
),
),
),
),
),
),
),
),
);
}
}
/// Trailing affordance shared by the rows that push a picker page.
class _RowChevron extends StatelessWidget {
const _RowChevron();
@override
Widget build(BuildContext context) {
return Icon(
BuzzIcons.chevronRight,
size: 18,
color: context.colors.onSurfaceVariant,
);
}
}