Files
buzz/mobile/lib/features/settings/settings_page/notifications_section.dart
Tom Brow 5996e5dd8b fix(mobile): isolate push setup failures from app rendering (#8610)
Push subscription setup ran synchronously inside the root widget build,
so an invalid subscription could replace the entire app with Flutter's
error surface. Push initialization and the cold-start native
notification read could also prevent the app from rendering.

Keep push work independent of the app subtree, observe asynchronous
persistence failures, and retain the original errors and stack traces.
Setup retries use bounded backoff; Settings shows a failure and offers
manual retry without resetting navigation. Retry preserves a healthy
revocation worker, and community-removal callbacks follow a recovered
worker so lease cleanup continues after setup recovery.

Related: [#8608](https://github.com/block/buzz/pull/8608) fixes the
specific unrelated-mute input that exposed this startup coupling.

### Validation

Added Flutter journeys cover invalid subscriptions, asynchronous storage
failure, dependency initialization failure, retry exhaustion and manual
recovery, navigation preservation, accessible retry actions, and
pending/failed native startup reads with notification-target recovery.
Both original rendering regressions failed before the fix.
Community-removal coverage also verifies cleanup after a worker
initialization failure and after retrying healthy setup; that regression
failed before the worker fix.

The full mobile suite passed (3,782 tests; 19 existing skips), as did
the four unconfigured-build tests and static analysis. The corrected
head also passed the full pre-push mobile checks and tests, plus 42
focused push and community tests. Rendered widget evidence uses mocked
native platform channels; no device APNs delivery test was performed.

Rendered Flutter widget evidence with synthetic data and mocked native
platform views.

### Setup failure
The app remains usable and Settings offers a retry after push
initialization fails.


![notification-setup-failed](https://raw.githubusercontent.com/block/buzz/af12b52923a75c6ad9fda774b9f4b3af37961422/pr-8610--notification-setup-failed.png)

### Recovery
Retry clears the error without replacing the navigator or leaving
Settings.


![notification-setup-recovered](https://raw.githubusercontent.com/block/buzz/af12b52923a75c6ad9fda774b9f4b3af37961422/pr-8610--notification-setup-recovered.png)

<!-- buzz-review-completed -->

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-26 19:32:38 -07:00

79 lines
2.6 KiB
Dart

part of '../settings_page.dart';
class _NotificationsSection extends ConsumerWidget {
const _NotificationsSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
if (defaultTargetPlatform != TargetPlatform.iOS) {
return const SizedBox.shrink();
}
final community = ref.watch(activeCommunityProvider).value;
if (community == null) return const SizedBox.shrink();
final setup = ref.watch(buzzPushSetupStatusProvider);
final capability = ref.watch(currentRelayPushDescriptorProvider);
final hasCapability =
!capability.isLoading &&
!capability.hasError &&
capability.value != null;
final optOutPending =
community.pushSubscriptionState.pendingTombstoneGeneration != null;
if (!hasCapability &&
!community.pushNotificationsEnabled &&
!optOutPending &&
!setup.hasError) {
return const SizedBox.shrink();
}
final canToggle = hasCapability || community.pushNotificationsEnabled;
void setEnabled(bool enabled) {
unawaited(
ref
.read(communityListProvider.notifier)
.setPushNotificationsEnabled(community.id, enabled),
);
}
return AppListCard(
verticalPadding: Grid.twelve,
children: [
if (setup.hasError)
AppListRow(
key: const ValueKey('push-setup-retry'),
title: 'Notification setup failed',
subtitle: 'You can keep using Buzz. Tap to retry notifications.',
onTap: () => retryBuzzPushSetup(ref),
),
MergeSemantics(
key: const ValueKey('push-notifications-setting'),
child: Stack(
alignment: Alignment.centerRight,
children: [
AppListRow(
key: const ValueKey('push-notifications-enabled'),
title: 'Notifications',
// Reserve horizontal room without letting the switch's 48dp
// layout height expand this single-line row.
trailing: const SizedBox(width: 60),
onTap: canToggle
? () => setEnabled(!community.pushNotificationsEnabled)
: null,
),
Positioned.fill(
right: Grid.xs,
child: Align(
alignment: Alignment.centerRight,
child: Switch.adaptive(
value: community.pushNotificationsEnabled,
onChanged: canToggle ? setEnabled : null,
),
),
),
],
),
),
],
);
}
}