mirror of
https://github.com/block/buzz.git
synced 2026-11-10 19:32:00 +00:00
The mobile app can now be built as a public build or an internal build, set explicitly with `BUZZ_DISTRIBUTION=internal|public`. Each build trusts exactly one Builderlab (`BUZZ_BUILDERLAB_URL`), and sends that Builderlab's relay assertions only to the relays in `BUZZ_BUILDERLAB_RELAYS`. The three settings are set together or not at all; with none set, the app is a Nostr-only development build. The decision is recorded in `docs/adr/0001-internal-and-public-mobile-builds.md`, and the terms are defined in `CONTEXT.md`. Before this change, one build supported a public Builderlab and an organization Builderlab at the same time, with two sessions. An organization's members could reach public communities from the organization's app. ### Behavior - **Public build:** QR pairing, email sign-in and multiple communities are unchanged. It has no knowledge of any organization. An organization's relay already rejects clients that can't present its Builderlab's assertion, and the public build relies on that. - **Internal build:** the first screen has a single **Sign in** button that goes straight to the organization's single sign-on and then joins the organization's community. There is no QR pairing, community link or community creation, and the community switcher is hidden. Only the exact relay hosts listed in `BUZZ_BUILDERLAB_RELAYS` are reachable. - **Identity (internal):** a member whose account has no Nostr identity yet gets a new key on first sign-in; the first relay assertion binds it, and the app backs it up to the organization automatically. A member whose account is already bound uses that key from this device, or restores it from the organization's backup. If neither is possible, Sign in says it couldn't restore the identity and saves nothing. - **Sign-out (internal):** a destructive **Sign out** row at the bottom of Settings, the build's only way out. It removes the community, closes Settings and returns to Sign in. The Builderlab session is cleared from storage immediately and can never be reused to sign back in. The relay push-stop keeps the retired credential in memory only, for up to 10 minutes, and uses it only to get an assertion for a key that is already bound. It is a best-effort request. - **Upgrade to an internal build:** a saved public Builderlab session is cleared. Communities on other relays are hidden, not deleted, and their keys stay on the device. - `BUZZ_HOSTED_RELAY_AUTH` is unchanged here. ### Screenshots (internal build) | Sign in | Community menu | Settings | | --- | --- | --- | |  |  |  | Gateway-side push revocation, which needs no Builderlab session, is planned as a separate PR. ### Validation Added tests: - An internal-build journey: one Sign in creates and binds a key, and a retry after a cancelled join reuses it. The app uploads that key's backup itself. Sign out from Settings closes it and returns to the landing, which ends the session, and Sign in then restores the same identity from the backup. Communities on other relays stay stored but hidden. - Internal Sign in for a bound account: the key on this device, a key restored from backup, and no backup (clear error, nothing saved). Each guard was shown to fail when removed. - Organization relay assertions: assertions go only to exact listed hosts, not subdomains. After sign-out, only a push stop for a bound key gets an assertion, and nothing can sign back in. Proven to fail without the fix. - Builderlab build setting parsing. CI runs the internal and public journeys in their own lanes with those settings. The full suite passes in the default configuration. The full suite isn't meant to run under the internal configuration, because most fixtures use relays that an internal build hides. Automated full-app run on an iPhone 17 Pro simulator at the current head: - Public build, upgraded in place over a signed-in install: home loads, a channel opens and a message sends. - Internal build: a fresh install shows only Sign in. With a saved organization community, the community menu has no Switch community, and Settings → Sign out → confirm returns to Sign in. Real single sign-on can't run in the simulator. 🤖 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@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
541 lines
20 KiB
Dart
541 lines
20 KiB
Dart
import 'features/activity/activity_provider.dart';
|
|
import 'features/channels/default_agent_launcher.dart';
|
|
import 'features/agents/agents_page.dart';
|
|
|
|
import 'dart:async';
|
|
|
|
import 'hosted_app_gate.dart';
|
|
import 'onboarding_app_entry.dart';
|
|
import 'organization_app_entry.dart';
|
|
import 'shared/hosted/organization_environment.dart';
|
|
import 'shared/widgets/ios_navigation_metrics.dart';
|
|
import 'shared/community/paired_community_landing.dart';
|
|
import 'shared/community/community_loading_surface.dart';
|
|
|
|
import 'package:app_badge_plus/app_badge_plus.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
import 'features/age_gate/age_restriction_page.dart';
|
|
import 'features/age_gate/age_signal_provider.dart';
|
|
import 'features/channels/channel.dart';
|
|
import 'features/channels/channel_management_provider.dart';
|
|
import 'features/channels/channels_provider.dart';
|
|
import 'features/channels/unread_badge/unread_badge_provider.dart';
|
|
import 'features/home/home_page.dart';
|
|
import 'features/channels/me/me_page.dart';
|
|
import 'features/channels/me/me_unread_provider.dart';
|
|
import 'features/invites/invite_create_page.dart';
|
|
import 'features/invites/invite_join_provider.dart';
|
|
import 'features/pairing/pairing_page.dart';
|
|
import 'features/pairing/pairing_provider.dart';
|
|
import 'features/channels/agent_activity/observer_subscription.dart';
|
|
import 'features/channels/channel_detail_page.dart';
|
|
import 'shared/widgets/app_route_observer.dart';
|
|
import 'features/profile/user_status_cache_provider.dart';
|
|
import 'features/profile/settings_profile_header.dart';
|
|
import 'features/profile/set_status_sheet.dart';
|
|
import 'features/profile/user_status_provider.dart';
|
|
import 'features/profile/profile_edit_page.dart';
|
|
import 'features/profile/profile_text_editor.dart';
|
|
import 'features/settings/settings_page.dart';
|
|
import 'features/settings/theme_picker_page.dart';
|
|
import 'shared/auth/auth.dart';
|
|
import 'shared/deeplink/pending_deep_link_provider.dart';
|
|
import 'shared/emoji/emoji_burst.dart';
|
|
import 'shared/relay/relay.dart';
|
|
import 'shared/theme/theme.dart';
|
|
import 'shared/widgets/buzz_loading_indicator.dart';
|
|
import 'shared/widgets/buzz_toast_host.dart';
|
|
import 'shared/widgets/covered_route_keyboard_insets.dart';
|
|
|
|
const _starterChannelNamespace = '3ce33bea-8f09-5f1b-9c85-8a7d2659e6b0';
|
|
|
|
const _starterChannels = [
|
|
(slug: 'general', description: 'General conversation and community updates.'),
|
|
(
|
|
slug: 'welcome-everyone',
|
|
description: 'Say hi, ask a question, or share what brought you here.',
|
|
),
|
|
];
|
|
|
|
/// Waits for this attempt's community to hold an authenticated connection.
|
|
///
|
|
/// Each attempt observes the live session, so a retry never reuses an earlier
|
|
/// attempt's failure or a connection that has since been lost.
|
|
Future<void> _waitForInviteRelay(Ref ref, bool Function() isScopeCurrent) {
|
|
final connected = Completer<void>();
|
|
final sub = ref.listen(relaySessionProvider, (_, next) {
|
|
if (connected.isCompleted) return;
|
|
if (!isScopeCurrent()) {
|
|
connected.completeError(
|
|
StateError('Active community changed during invite recovery'),
|
|
);
|
|
} else if (next.status == SessionStatus.connected) {
|
|
connected.complete();
|
|
}
|
|
}, fireImmediately: true);
|
|
return connected.future
|
|
.timeout(const Duration(seconds: 15))
|
|
.whenComplete(sub.close);
|
|
}
|
|
|
|
/// App-level bridge from invite joining to the channels feature.
|
|
class MobileInviteJoinRecovery implements InviteJoinRecovery {
|
|
final Future<List<Channel>> Function() _loadChannels;
|
|
final Future<Channel?> Function(String channelId) _fetchChannel;
|
|
final Future<Channel> Function({
|
|
required String channelId,
|
|
required String name,
|
|
required String channelType,
|
|
required String visibility,
|
|
String? description,
|
|
int? ttlSeconds,
|
|
})
|
|
_createChannel;
|
|
final Future<void> Function(String channelId) _joinChannel;
|
|
final String _relayHttpOrigin;
|
|
final bool Function() _isScopeCurrent;
|
|
|
|
/// Creates recovery from channel-loading, single-channel lookup, creation,
|
|
/// and join operations.
|
|
MobileInviteJoinRecovery({
|
|
required Future<List<Channel>> Function() loadChannels,
|
|
required Future<Channel?> Function(String channelId) fetchChannel,
|
|
required Future<Channel> Function({
|
|
required String channelId,
|
|
required String name,
|
|
required String channelType,
|
|
required String visibility,
|
|
String? description,
|
|
int? ttlSeconds,
|
|
})
|
|
createChannel,
|
|
required Future<void> Function(String channelId) joinChannel,
|
|
required String relayHttpOrigin,
|
|
bool Function()? isScopeCurrent,
|
|
}) : _loadChannels = loadChannels,
|
|
_fetchChannel = fetchChannel,
|
|
_createChannel = createChannel,
|
|
_joinChannel = joinChannel,
|
|
_relayHttpOrigin = relayHttpOrigin,
|
|
_isScopeCurrent = isScopeCurrent ?? _alwaysCurrent;
|
|
|
|
/// Ensures memberships in the same public starter channels as desktop.
|
|
///
|
|
/// All starter work is fenced to the community and identity that started it.
|
|
/// A partial setup remains recoverable, so a failed operation deliberately
|
|
/// propagates to the invite flow instead of being reported as success. The
|
|
/// one exception is a starter whose name or id the relay reports as taken
|
|
/// by a channel the directory shows cannot serve as the starter, or an
|
|
/// archived channel at the starter's own id: both
|
|
/// are permanent, so the starter is skipped instead of blocking the join.
|
|
@override
|
|
Future<String?> ensureStarterChannels() async {
|
|
_ensureScopeCurrent();
|
|
var channels = await _loadChannels();
|
|
_ensureScopeCurrent();
|
|
String? welcomeEveryoneId;
|
|
|
|
for (final starter in _starterChannels) {
|
|
_ensureScopeCurrent();
|
|
var channel = _findStarterChannel(channels, starter.slug);
|
|
if (channel == null) {
|
|
final channelId = desktopStarterChannelId(
|
|
relayHttpOrigin: _relayHttpOrigin,
|
|
slug: starter.slug,
|
|
);
|
|
try {
|
|
channel = await _createChannel(
|
|
channelId: channelId,
|
|
name: starter.slug,
|
|
channelType: 'stream',
|
|
visibility: 'open',
|
|
description: starter.description,
|
|
);
|
|
_ensureScopeCurrent();
|
|
} catch (error) {
|
|
final nameTaken = _isChannelNameTakenError(error);
|
|
final archivedAtId = _isArchivedChannelError(error);
|
|
if (!nameTaken && !archivedAtId && !_isDuplicateChannelError(error)) {
|
|
rethrow;
|
|
}
|
|
_ensureScopeCurrent();
|
|
channels = await _loadChannels();
|
|
_ensureScopeCurrent();
|
|
final holder =
|
|
_findStarterChannel(channels, starter.slug) ??
|
|
channels
|
|
.where((candidate) => candidate.id == channelId)
|
|
.firstOrNull ??
|
|
(nameTaken ? _findListedNamesake(channels, starter.slug) : null);
|
|
if (holder == null) {
|
|
// The relay refuses a create at an archived channel's id before
|
|
// its duplicate check, and keeps that id, so the starter can never
|
|
// be created there.
|
|
if (archivedAtId) continue;
|
|
// The relay keeps the id (desktop uses the same one), so a
|
|
// duplicate with no match in the reload may be an archived
|
|
// starter whose metadata the reload did not return. Read that id
|
|
// directly.
|
|
if (!nameTaken && _isDuplicateChannelError(error)) {
|
|
final atId = await _fetchChannel(channelId);
|
|
_ensureScopeCurrent();
|
|
if (atId != null && atId.isArchived) continue;
|
|
}
|
|
// A holder not visible yet may still be usable, so keep the setup
|
|
// recoverable.
|
|
rethrow;
|
|
}
|
|
// The relay keeps a channel's id and a listed channel's name, archived
|
|
// ones included, so an unusable holder never frees the starter. Skip
|
|
// it rather than strand the invite in permanent recovery.
|
|
if (!_isUsableStarter(holder)) continue;
|
|
channel = holder;
|
|
}
|
|
}
|
|
|
|
_ensureScopeCurrent();
|
|
if (!channel.isMember) {
|
|
await _joinChannel(channel.id);
|
|
_ensureScopeCurrent();
|
|
}
|
|
if (starter.slug == 'welcome-everyone') {
|
|
welcomeEveryoneId = channel.id;
|
|
}
|
|
}
|
|
|
|
return welcomeEveryoneId;
|
|
}
|
|
|
|
static bool _alwaysCurrent() => true;
|
|
|
|
void _ensureScopeCurrent() {
|
|
if (!_isScopeCurrent()) {
|
|
throw StateError('Active community changed during invite recovery');
|
|
}
|
|
}
|
|
}
|
|
|
|
Channel? _findStarterChannel(List<Channel> channels, String name) => channels
|
|
.where((channel) => _hasName(channel, name) && _isUsableStarter(channel))
|
|
.firstOrNull;
|
|
|
|
/// The channel that holds [name] in the relay's listed-name rule: an open,
|
|
/// not unlisted stream or forum (`LISTED_CHANNEL_SQL` in buzz-db).
|
|
Channel? _findListedNamesake(List<Channel> channels, String name) => channels
|
|
.where(
|
|
(channel) =>
|
|
_hasName(channel, name) &&
|
|
channel.visibility == 'open' &&
|
|
!channel.isUnlisted &&
|
|
(channel.isStream || channel.isForum),
|
|
)
|
|
.firstOrNull;
|
|
|
|
bool _hasName(Channel channel, String name) =>
|
|
channel.name.trim().toLowerCase() == name.trim().toLowerCase();
|
|
|
|
bool _isUsableStarter(Channel channel) =>
|
|
channel.isStream && channel.visibility == 'open' && !channel.isArchived;
|
|
|
|
bool _isDuplicateChannelError(Object error) =>
|
|
error.toString().contains('duplicate: channel already exists');
|
|
|
|
/// The relay refuses any channel event whose `h` is an archived channel,
|
|
/// a create at that id included: `invalid: channel is archived`.
|
|
bool _isArchivedChannelError(Object error) =>
|
|
error.toString().contains('invalid: channel is archived');
|
|
|
|
/// The relay refuses creating an open channel whose name another listed
|
|
/// channel already holds, ignoring case:
|
|
/// `invalid: an open channel named "general" already exists`.
|
|
bool _isChannelNameTakenError(Object error) {
|
|
final message = error.toString().toLowerCase();
|
|
return message.contains('invalid: an open channel named') &&
|
|
message.contains('already exists');
|
|
}
|
|
|
|
/// Returns desktop's deterministic per-relay UUID for a public starter.
|
|
@visibleForTesting
|
|
String desktopStarterChannelId({
|
|
required String relayHttpOrigin,
|
|
required String slug,
|
|
}) {
|
|
final scope = relayHttpOrigin.trim().replaceFirst(RegExp(r'/+$'), '');
|
|
return const Uuid().v5(
|
|
_starterChannelNamespace,
|
|
'starter-channel:v1:$scope:$slug',
|
|
);
|
|
}
|
|
|
|
/// Builds one fresh, identity-scoped invite recovery against the app container.
|
|
InviteJoinRecovery buildMobileInviteJoinRecovery(
|
|
Ref ref,
|
|
InviteJoinRecoveryScope scope,
|
|
) {
|
|
final expectedConfig = ref.read(relayConfigProvider);
|
|
if (expectedConfig.baseUrl != scope.relayHttpOrigin ||
|
|
expectedConfig.nsec != scope.nsec) {
|
|
throw StateError('Active community changed before invite recovery');
|
|
}
|
|
final channelActions = ref.read(channelActionsProvider);
|
|
|
|
bool isScopeCurrent() {
|
|
final currentConfig = ref.read(relayConfigProvider);
|
|
return currentConfig.baseUrl == scope.relayHttpOrigin &&
|
|
currentConfig.nsec == scope.nsec;
|
|
}
|
|
|
|
void ensureScopeCurrent() {
|
|
if (!isScopeCurrent()) {
|
|
throw StateError('Active community changed during invite recovery');
|
|
}
|
|
}
|
|
|
|
return MobileInviteJoinRecovery(
|
|
loadChannels: () async {
|
|
ensureScopeCurrent();
|
|
await ref.read(activeCommunityProvider.future);
|
|
ensureScopeCurrent();
|
|
await _waitForInviteRelay(ref, isScopeCurrent);
|
|
ensureScopeCurrent();
|
|
final channels = await ref
|
|
.read(channelsProvider.notifier)
|
|
.fetchFresh(fetchDirectory: true);
|
|
ensureScopeCurrent();
|
|
return channels;
|
|
},
|
|
fetchChannel: (channelId) async {
|
|
ensureScopeCurrent();
|
|
final channel = await ref
|
|
.read(channelsProvider.notifier)
|
|
.fetchOpenChannel(channelId);
|
|
ensureScopeCurrent();
|
|
return channel;
|
|
},
|
|
createChannel:
|
|
({
|
|
required channelId,
|
|
required name,
|
|
required channelType,
|
|
required visibility,
|
|
description,
|
|
ttlSeconds,
|
|
}) => channelActions.createChannel(
|
|
channelId: channelId,
|
|
name: name,
|
|
channelType: channelType,
|
|
visibility: visibility,
|
|
description: description,
|
|
ttlSeconds: ttlSeconds,
|
|
),
|
|
joinChannel: channelActions.joinChannel,
|
|
relayHttpOrigin: scope.relayHttpOrigin,
|
|
isScopeCurrent: isScopeCurrent,
|
|
);
|
|
}
|
|
|
|
final _mobileRootNavigatorKey = GlobalKey<NavigatorState>();
|
|
|
|
class App extends HookConsumerWidget {
|
|
const App({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final ageSignalState = ref.watch(ageSignalProvider);
|
|
ref.listen(ageSignalProvider, (_, next) {
|
|
if (next == AgeSignalState.restricted) {
|
|
ref.read(pairingProvider.notifier).reset();
|
|
}
|
|
});
|
|
final communityTheme = ageSignalState != AgeSignalState.restricted
|
|
? ref.watch(communityThemeProvider)
|
|
: defaultCommunityTheme;
|
|
final themeMode = communityTheme.mode;
|
|
// The app rebuilds on every lifecycle change. A new ThemeData never
|
|
// equals the last one, so MaterialApp would animate between identical
|
|
// themes, and each frame of that makes every message re-parse its
|
|
// markdown. Build the themes only when the accent changes.
|
|
final accent = communityTheme.accent;
|
|
final lightTheme = useMemoized(
|
|
() => mobileAppearanceTheme(Brightness.light, accent),
|
|
[accent],
|
|
);
|
|
final darkTheme = useMemoized(
|
|
() => mobileAppearanceTheme(Brightness.dark, accent),
|
|
[accent],
|
|
);
|
|
final authState = ref.watch(authProvider);
|
|
final pairedCommunity = ref.watch(pairedCommunityLandingProvider);
|
|
|
|
useEffect(() {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
unawaited(ref.read(ageSignalProvider.notifier).request());
|
|
});
|
|
return null;
|
|
}, const []);
|
|
|
|
// Eagerly initialize websocket session and lifecycle observer when
|
|
// authenticated. These providers connect and manage the websocket.
|
|
if (ageSignalState != AgeSignalState.restricted &&
|
|
authState.value?.status == AuthStatus.authenticated) {
|
|
ref.watch(relaySessionProvider);
|
|
// Listen, not watch: these publish often, and an App rebuild rebuilds
|
|
// every route, including an open chat.
|
|
ref.listen(observerRelayProvider, (_, _) {});
|
|
ref.watch(appLifecycleProvider);
|
|
ref.listen(userStatusCacheProvider, (_, _) {});
|
|
// Activity owns hidden-DM recovery even when Inbox is never opened.
|
|
ref.listen(activityProvider, (_, _) {});
|
|
// Preserve initial read seeding even while Solo is hidden.
|
|
ref.listen(meUnreadChannelIdsProvider, (_, _) {});
|
|
}
|
|
|
|
// Start listening for buzz:// links immediately (even pre-auth) so a
|
|
// cold-start link survives until the authenticated UI can dispatch it.
|
|
ref.watch(pendingDeepLinkProvider);
|
|
|
|
void applyBadge(UnreadBadgeState state) {
|
|
if (state.highPriorityCount > 0) {
|
|
AppBadgePlus.updateBadge(state.highPriorityCount);
|
|
} else if (state.generalUnreadCount > 0) {
|
|
AppBadgePlus.updateBadge(1);
|
|
} else {
|
|
AppBadgePlus.updateBadge(0);
|
|
}
|
|
}
|
|
|
|
useEffect(() {
|
|
if (ageSignalState != AgeSignalState.restricted) {
|
|
applyBadge(ref.read(unreadBadgeProvider));
|
|
} else {
|
|
AppBadgePlus.updateBadge(0);
|
|
}
|
|
return null;
|
|
}, [ageSignalState]);
|
|
if (ageSignalState != AgeSignalState.restricted) {
|
|
ref.listen<UnreadBadgeState>(unreadBadgeProvider, (_, next) {
|
|
applyBadge(next);
|
|
});
|
|
}
|
|
|
|
return MaterialApp(
|
|
navigatorKey: _mobileRootNavigatorKey,
|
|
navigatorObservers: [appRouteObserver],
|
|
title: 'Buzz',
|
|
debugShowCheckedModeBanner: false,
|
|
theme: lightTheme,
|
|
darkTheme: darkTheme,
|
|
themeMode: themeMode,
|
|
// Above the navigator, so an age restriction cannot be bypassed by a
|
|
// route that was pushed while the store signal request was in flight.
|
|
builder: (context, child) => switch (ageSignalState) {
|
|
AgeSignalState.restricted => const AgeRestrictionPage(),
|
|
_ => BuzzToastHost(
|
|
child: IosNavigationMetricsHost(
|
|
child: AppMarkdownTheme(
|
|
child: MobileHuddleShell(
|
|
navigatorKey: _mobileRootNavigatorKey,
|
|
child: EmojiBurstOverlay(
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
ExcludeSemantics(
|
|
excluding: pairedCommunity != null,
|
|
child: AbsorbPointer(
|
|
absorbing: pairedCommunity != null,
|
|
child: child ?? const SizedBox.shrink(),
|
|
),
|
|
),
|
|
// Cover the navigator while onboarding is removed and the
|
|
// destination installs the matching avatar-flight route.
|
|
if (pairedCommunity != null)
|
|
CommunityLoadingSurface(
|
|
name: pairedCommunity.name,
|
|
relayUrl: pairedCommunity.relayUrl,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
},
|
|
home: authState.when(
|
|
loading: () => const _SplashScreen(),
|
|
// An internal build never offers pairing, even when auth state is
|
|
// unreadable: pairing could make another relay the active community.
|
|
error: (_, _) => OrganizationEnvironment.enabled
|
|
? const OrganizationAppEntry()
|
|
: const PairingPage(),
|
|
data: (state) => switch (state.status) {
|
|
AuthStatus.authenticated => HostedAppGate(
|
|
authenticated: true,
|
|
child: CoveredRouteKeyboardInsets(
|
|
child: HomePage(
|
|
onAgents: showAgents,
|
|
defaultAgentBuilder: (context) => DefaultAgentLauncher(
|
|
onChooseAgent: () => showAgents(context, createOnOpen: true),
|
|
),
|
|
mePageBuilder: (_, chat, workspace) => MePage(
|
|
sliver: true,
|
|
newChatAction: chat,
|
|
newWorkspaceAction: workspace,
|
|
),
|
|
settingsPageBuilder: _buildSettingsPage,
|
|
communityInvitePageBuilder: (_) => const CommunityInvitePage(),
|
|
communityAppearancePageBuilder: (_) => const ThemePickerPage(),
|
|
),
|
|
),
|
|
),
|
|
_ => const HostedAppGate(
|
|
authenticated: false,
|
|
child: OnboardingAppEntry(),
|
|
),
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildSettingsPage(BuildContext context) => const _SettingsPageContent();
|
|
|
|
class _SettingsPageContent extends ConsumerWidget {
|
|
const _SettingsPageContent();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
return SettingsPage(
|
|
profileHeader: const SettingsProfileHeader(),
|
|
profileEditPageBuilder: (_) =>
|
|
const ProfileEditPage(startInPhotoEditor: true),
|
|
onSetStatus: (context) => showSetStatusSheet(
|
|
context,
|
|
currentStatus: ref.read(userStatusProvider).asData?.value,
|
|
),
|
|
onEditDisplayName: showProfileDisplayNameEditor,
|
|
onEditProfileDescription: showProfileDescriptionEditor,
|
|
identityRecoveryPageBuilder: (_) =>
|
|
const PairingPage(addingCommunity: true, identityRecoveryOnly: true),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SplashScreen extends StatelessWidget {
|
|
const _SplashScreen();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return const Scaffold(
|
|
body: Center(
|
|
child: BuzzLoadingIndicator(size: 56, semanticLabel: 'Starting Buzz'),
|
|
),
|
|
);
|
|
}
|
|
}
|