mirror of
https://github.com/block/buzz.git
synced 2026-11-14 13:21:02 +00:00
Archive, Leave, Unarchive and Delete in channel settings wait for the relay, then close the settings sheet by popping whatever route is on top of the root navigator. If the user closes the sheet themselves during that wait (for example: long-press a chat on Home, Archive, confirm, then tap the close button), that pop lands on Home instead. The navigator ends up empty: a blank white or black screen with no controls and no way back short of killing the app. ### Change - **Close only the route that offered the action.** (Main has since gained the same guard; after merging main, this part of the diff is a comment plus the tests below.) After the action completes, the settings sheet or page closes itself only if it is still on top, with its "close the conversation too" result, as before. If the user opened something over it meanwhile (for example a member's profile), it stays open under that route, which keeps working. If the user already dismissed it, nothing is popped. The sheet still closes on its own after a successful archive. - **Home can no longer be popped.** The app's root route refuses pops. A stray pop that reaches it is reported through `FlutterError.reportError` (which fails widget tests) and ignored, so no other caller can leave an empty navigator either. Local-history pops on the root route (a drawer or persistent bottom sheet) still close normally. The root route still rebuilds from the current auth state, so sign-in and sign-out transitions are unchanged. ### What changes for the user Only the case where a settings action finishes after the user moved on: - Settings dismissed mid-flight: before, the completion popped whatever was on top (Home, the conversation, a thread or a profile). Now nothing is popped. - Something opened over settings mid-flight: before, the completion popped that new route and left settings open. Now that route stays, and settings stays open under it, as on main. That settings page shows the channel as it was before the action (for example still offering Archive after an archive, or a channel that is gone after Delete or Leave), as on main. Settings following lifecycle changes is part of the follow-up below. - Delete or Leave from the pushed settings page, then back out before the relay answers: before, the stray pop happened to close the conversation. Now the conversation stays open until the user leaves it (Leave and Archive show the read-only notice; a deleted conversation keeps showing its last state). Closing it reliably needs the conversation to notice its channel is gone, which is a follow-up. Pops that reach Home were always a bug and are now refused. Every other pop (sheets, pushed pages, system back above Home) takes the same path as before. ### Validation Added tests: - Channel actions sheet journey: confirm Archive with the relay call still pending, close the sheet, then let the archive finish. Home stays. A second pass leaves the sheet open and checks it closes by itself once the archive completes. Fails on the old code with Home popped. - Conversation journey: open settings from a conversation, confirm Archive with the relay call pending, open a member's profile, then let the archive finish. The profile stays open and current, and its Message action still opens the DM. Fails if the completion pops the top route, and fails if it removes the covered settings page (the profile loses the context it navigates from). - Root route: normal push and pop still work, a drawer on Home closes on back, and popping Home is refused, reported, and Home stays on screen. Full mobile test suite and `mobile-check` pass, including after merging main. Automated app run at [c158c17](c158c1793d) on an iOS 27 simulator (iPhone 17 Pro, debug build, public Builderlab community): long-press a solo chat on Home, archive it from the settings sheet, and confirm the sheet closes by itself with the channel list intact. Then the race: confirm Archive and immediately drag the sheet closed while the archive is in flight. Home stays intact with the chat archived, and no refused-pop error is logged. Limitation: AXe taps don't reach the native glass close button on the simulator, so the run dismissed the sheet with the drag gesture, which takes the same route pop. buzz-review-completed --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
554 lines
20 KiB
Dart
554 lines
20 KiB
Dart
import 'features/activity/activity_provider.dart';
|
|
import 'features/channels/bestie_conversation.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 'shared/widgets/root_page_route.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);
|
|
});
|
|
}
|
|
|
|
final 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),
|
|
),
|
|
openBestieConversation: openBestieConversation,
|
|
mePageBuilder: (_, chat, workspace) => MePage(
|
|
sliver: true,
|
|
newChatAction: chat,
|
|
newWorkspaceAction: workspace,
|
|
),
|
|
settingsPageBuilder: _buildSettingsPage,
|
|
communityInvitePageBuilder: (_) => const CommunityInvitePage(),
|
|
communityAppearancePageBuilder: (_) => const ThemePickerPage(),
|
|
),
|
|
),
|
|
),
|
|
_ => const HostedAppGate(
|
|
authenticated: false,
|
|
child: OnboardingAppEntry(),
|
|
),
|
|
},
|
|
);
|
|
final latestHome = useRef<Widget?>(null)..value = home;
|
|
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
},
|
|
// A root route that refuses pops, so a stray pop can never leave an
|
|
// empty navigator. It reads [home] when built, so it follows auth.
|
|
onGenerateRoute: (settings) => settings.name == Navigator.defaultRouteName
|
|
? RootPageRoute<void>(
|
|
settings: settings,
|
|
builder: (_) => latestHome.value!,
|
|
)
|
|
: null,
|
|
);
|
|
}
|
|
}
|
|
|
|
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'),
|
|
),
|
|
);
|
|
}
|
|
}
|