diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 7a2e62ce9f..4a3cd4320e 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -354,6 +354,19 @@ class App extends HookConsumerWidget { ? 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); @@ -413,8 +426,8 @@ class App extends HookConsumerWidget { navigatorObservers: [appRouteObserver], title: 'Buzz', debugShowCheckedModeBanner: false, - theme: mobileAppearanceTheme(Brightness.light, communityTheme.accent), - darkTheme: mobileAppearanceTheme(Brightness.dark, communityTheme.accent), + 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. diff --git a/mobile/test/shared/theme/app_theme_rebuild_test.dart b/mobile/test/shared/theme/app_theme_rebuild_test.dart new file mode 100644 index 0000000000..f8e5ffa9ea --- /dev/null +++ b/mobile/test/shared/theme/app_theme_rebuild_test.dart @@ -0,0 +1,72 @@ +import 'package:buzz/app.dart'; +import 'package:buzz/features/age_gate/age_signal_provider.dart'; +import 'package:buzz/shared/auth/auth.dart'; +import 'package:buzz/shared/theme/theme_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// The app rebuilds on every lifecycle change, so a resume rebuilds it several +// times. ThemeData has no value equality the app's themes satisfy, so new +// themes made MaterialApp animate between identical ones, and every frame of +// that animation made each mounted message re-parse its markdown. +void main() { + testWidgets('an app rebuild keeps its themes and markdown theme', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final auth = _SignedOutAuth(); + final container = ProviderContainer( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + authProvider.overrideWith(() => auth), + ageSignalProvider.overrideWith(_AllowedAgeSignal.new), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope(container: container, child: const App()), + ); + await tester.pump(const Duration(seconds: 1)); + + MaterialApp app() => tester.widget(find.byType(MaterialApp)); + GptMarkdownThemeData markdown() => tester + .widget(find.byType(GptMarkdownTheme).first) + .gptThemeData; + final theme = app().theme; + final darkTheme = app().darkTheme; + final markdownTheme = markdown(); + + // A new auth state rebuilds the app, as a lifecycle change does. + auth.republish(); + await tester.pump(); + // Midway through what would be a theme animation. + await tester.pump(const Duration(milliseconds: 100)); + + expect(identical(app().theme, theme), isTrue); + expect(identical(app().darkTheme, darkTheme), isTrue); + expect(identical(markdown(), markdownTheme), isTrue); + + await tester.pumpWidget(const SizedBox.shrink()); + }); +} + +class _SignedOutAuth extends AuthNotifier { + @override + Future build() async => + const AuthState(status: AuthStatus.unauthenticated); + + void republish() => + state = AsyncData(AuthState(status: state.requireValue.status)); +} + +class _AllowedAgeSignal extends AgeSignalNotifier { + @override + AgeSignalState build() => AgeSignalState.allowed; + + @override + Future request() async {} +}