Files
buzz/mobile/lib/app.dart
05150c1188 feat(mobile): sync themes per community (#3767)
**Category:** new-feature
**User Impact:** Mobile now keeps each community’s appearance in sync
with desktop, including theme, accent, and system-mode preference.

**Problem:** Appearance choices were device-local, so the same account
could look different between desktop and mobile. Live sync could also
stop after the relay closed a subscription.

**Solution:** Store each community’s encrypted appearance preference on
its relay using the shared desktop wire contract, restore it from a
local identity-scoped cache, and apply replacement events live. Closed
subscriptions now recover with guarded backoff and fetch the latest
preference so no update is lost during the gap.

<details>
<summary>File changes</summary>

**mobile/lib/app.dart**
Connects community appearance state to the authenticated app lifecycle.

**mobile/lib/features/settings/accent_picker_page.dart**
Aligns mobile accent choices and selection behavior with the shared
catalog.

**mobile/lib/features/settings/settings_page/appearance_section.dart**
Clarifies the active appearance and hides accent controls when the Buzz
theme owns its neutral accent.

**mobile/lib/features/settings/theme_picker_page.dart**
Persists catalog theme choices through the community-scoped provider.

**mobile/lib/shared/theme/accent_colors.dart**
Matches desktop’s accent catalog and wire values.

**mobile/lib/shared/theme/buzz_theme.dart**
Keeps Buzz visually neutral without discarding the user’s stored accent
for other themes.

**mobile/lib/shared/theme/community_theme_preference.dart**
Defines and validates the versioned desktop-compatible appearance
payload.

**mobile/lib/shared/theme/community_theme_provider.dart**
Coordinates cache-first appearance loading with account and community
changes.

**mobile/lib/shared/theme/community_theme_sync.dart**
Adds encrypted NIP-78 relay persistence, live replacement handling,
deterministic ordering, safe seeding, and resilient subscription
recovery.

**mobile/lib/shared/theme/theme.dart**
Exports the community appearance modules.

**mobile/test/features/settings/theme_picker_page_test.dart**
Covers the updated settings behavior.

**mobile/test/shared/crypto/nip44_interop_test.dart**
Proves Dart decrypts a desktop-produced nostr-rs NIP-44 v2 preference.

**mobile/test/shared/theme/buzz_theme_test.dart**
Covers Buzz’s neutral rendering and stored-accent restoration.

**mobile/test/shared/theme/community_theme_preference_test.dart**
Covers wire parsing, validation, migration, and future-version handling.

**mobile/test/shared/theme/community_theme_sync_test.dart**
Covers cache/relay lifecycle, replacement ordering, switching races,
absence-only seeding, and closed-subscription recovery.

</details>

## Reproduction steps

1. Sign into desktop and mobile with the same account and join the same
community relay.
2. On desktop, choose a distinctive non-Buzz theme and accent; mobile
should update without a local toggle.
3. Restart mobile and confirm it restores the same appearance.
4. Change the mobile theme and accent and confirm desktop follows.
5. Leave mobile idle or backgrounded through a relay reconnect, then
change desktop again; mobile should resubscribe and catch up
automatically.
6. Switch communities and confirm each community restores only its own
appearance.

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
2026-08-05 13:18:53 -07:00

165 lines
5.7 KiB
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 'features/activity/activity_provider.dart';
import 'features/activity/inbox_local_state_provider.dart';
import 'features/activity/inbox_read_state.dart';
import 'features/channels/unread_badge/unread_badge_provider.dart';
import 'features/home/home_page.dart';
import 'features/pairing/pairing_page.dart';
import 'features/channels/agent_activity/observer_subscription.dart';
import 'features/channels/deep_link_dispatcher.dart';
import 'features/profile/user_status_cache_provider.dart';
import 'features/profile/settings_profile_header.dart';
import 'features/settings/settings_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/read_state/read_state_provider.dart';
import 'shared/theme/theme.dart';
import 'shared/widgets/buzz_loading_indicator.dart';
/// App-shell projection that joins Activity state for the Home navigation.
///
/// This belongs at the composition root because it deliberately aggregates
/// Activity feature providers for a sibling navigation surface.
final _unreadInboxItemCountProvider = Provider<int>((ref) {
final readState = ref.watch(readStateProvider);
if (!readState.isReady) return 0;
final localState = ref.watch(inboxLocalStateProvider);
final items = ref.watch(inboxItemsProvider);
return items
.where(
(item) => !isInboxItemDone(
item,
markerOf: readState.effectiveTimestamp,
localUnreadOverrides: localState.unreadIds,
localDoneSet: localState.doneIds,
),
)
.length;
});
class App extends HookConsumerWidget {
const App({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final communityTheme = ref.watch(communityThemeProvider);
final themeMode = communityTheme.mode;
final accentIndex = effectiveAccentIndex(
communityTheme.theme,
communityTheme.accent,
);
final schemeName = communityTheme.theme;
final authState = ref.watch(authProvider);
final resolved = resolveSchemes(schemeName, themeMode);
final lightScheme = applyAccent(resolved.light, accentIndex);
final darkScheme = applyAccent(resolved.dark, accentIndex);
// Light/Dark modes pin the brightness; System leaves it null so Flutter
// follows the OS across the selected theme and its pair.
final effectiveMode = resolved.forcedMode ?? themeMode;
// Derive the gradient from the themes that produced each color scheme.
// This keeps fallbacks and pinned brightness changes aligned with the
// rendered palette rather than the raw persisted selection.
final buzzLightGradient = buzzTopSectionGradient(
resolved.lightTheme?.name ?? '',
lightScheme.brightness,
);
final buzzDarkGradient = buzzTopSectionGradient(
resolved.darkTheme?.name ?? '',
darkScheme.brightness,
);
// Eagerly initialize websocket session and lifecycle observer when
// authenticated. These providers connect and manage the websocket.
var hasUnreadInbox = false;
if (authState.value?.status == AuthStatus.authenticated) {
ref.watch(relaySessionProvider);
ref.watch(observerRelayProvider);
ref.watch(appLifecycleProvider);
ref.watch(userStatusCacheProvider);
hasUnreadInbox = ref.watch(_unreadInboxItemCountProvider) > 0;
}
// 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(() {
applyBadge(ref.read(unreadBadgeProvider));
return null;
}, const []);
ref.listen<UnreadBadgeState>(unreadBadgeProvider, (_, next) {
applyBadge(next);
});
return MaterialApp(
title: 'Buzz',
theme: AppTheme.light(
colorScheme: lightScheme,
topSectionGradient: buzzLightGradient,
),
darkTheme: AppTheme.dark(
colorScheme: darkScheme,
topSectionGradient: buzzDarkGradient,
),
themeMode: effectiveMode,
// Above the navigator, so a burst keeps playing over a pushed thread page
// or a modal sheet — the same reason desktop pins its canvas to the
// viewport rather than to the message row.
builder: (context, child) =>
EmojiBurstOverlay(child: child ?? const SizedBox.shrink()),
home: authState.when(
loading: () => const _SplashScreen(),
error: (_, _) => const PairingPage(),
data: (state) => switch (state.status) {
AuthStatus.authenticated => DeepLinkDispatcher(
child: HomePage(
settingsPageBuilder: _buildSettingsPage,
hasUnreadInbox: hasUnreadInbox,
),
),
_ => const DeepLinkDispatcher(
dispatchMessageLinks: false,
child: PairingPage(),
),
},
),
);
}
}
Widget _buildSettingsPage(BuildContext context) =>
const SettingsPage(profileHeader: SettingsProfileHeader());
class _SplashScreen extends StatelessWidget {
const _SplashScreen();
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: BuzzLoadingIndicator(size: 56, semanticLabel: 'Starting Buzz'),
),
);
}
}