System Theme Detection with Riverpod
This is Part 4 of Mastering Riverpod: Theming. Our ThemeMode supports system, and MaterialApp already honors it. So why a whole part on system brightness?
Because "respect the OS" has sharp edges: you need to read the OS brightness, react when the user changes it while your app is open, expose it to your own logic (not just MaterialApp), and resolve the effective brightness when a user override and the OS setting disagree. This part handles all four, the Riverpod way.
Two sources of truth, one effective answer
There are two independent inputs to "is the app dark right now?":
- The OS setting — what the user picked in system settings (or a scheduled auto night mode).
- The user's in-app choice — your
ThemeMode(light/dark/system).
Analogy — the house thermostat with a "follow weather" mode. The OS is the outside weather. Your
ThemeModeis the thermostat: set tolightordarkit ignores the weather; set tosystemit follows it. The effective temperature depends on the mode — and only insystemmode does the weather actually matter.
MaterialApp resolves this for its own rendering. But your own code (a logo swapper, analytics, a conditional layout) often needs the effective brightness too — so let's make it a first-class, reactive provider.
Reading the OS brightness reactively
The OS brightness lives on the platform dispatcher and is surfaced through MediaQuery:
// Inside build, with a context under MaterialApp:
final osBrightness = MediaQuery.platformBrightnessOf(context); // Brightness.light/dark
platformBrightnessOf is reactive: when the user flips their phone to dark while your app is open, widgets that read it rebuild. The catch: it needs a BuildContext, while Riverpod providers don't have one. We bridge that gap.
Bridge the OS brightness into a provider
The clean approach: read platformBrightnessOf(context) in a Consumer/widget and push it into a Notifier, or listen to the platform dispatcher directly. The direct listener avoids needing a context:
import 'dart:ui' show PlatformDispatcher;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class PlatformBrightnessNotifier extends Notifier<Brightness>
with WidgetsBindingObserver {
@override
Brightness build() {
final binding = WidgetsBinding.instance;
binding.addObserver(this);
// Clean up when the provider is disposed (Core Concepts onDispose).
ref.onDispose(() => binding.removeObserver(this));
return binding.platformDispatcher.platformBrightness;
}
@override
void didChangePlatformBrightness() {
// Called by Flutter when the OS light/dark setting changes.
state = WidgetsBinding.instance.platformDispatcher.platformBrightness;
}
}
final platformBrightnessProvider =
NotifierProvider<PlatformBrightnessNotifier, Brightness>(
PlatformBrightnessNotifier.new);
What's happening:
build()registers aWidgetsBindingObserverand returns the current OS brightness.didChangePlatformBrightness()fires when the OS toggles dark mode; we push the new value intostate.ref.onDisposeremoves the observer when the provider is gone — the cleanup discipline from Core Concepts Part 7.
Now any provider or widget can ref.watch(platformBrightnessProvider) to react to the OS — no BuildContext required.
Simpler alternative: if you only need OS brightness inside widgets, skip the notifier and just call
MediaQuery.platformBrightnessOf(context)— it's already reactive. Use the provider when other providers (pure Dart logic) need to depend on the OS brightness.
Resolving the effective brightness
Now combine the two sources into the single answer your app should act on. A derived Provider is the perfect home — it's a computed cell that watches both inputs:
final effectiveBrightnessProvider = Provider<Brightness>((ref) {
final mode = ref.watch(themeModeProvider).valueOrNull ?? ThemeMode.system;
switch (mode) {
case ThemeMode.light:
return Brightness.light; // override wins
case ThemeMode.dark:
return Brightness.dark; // override wins
case ThemeMode.system:
return ref.watch(platformBrightnessProvider); // follow the OS
}
});
This is the crux of the part: a user override beats the OS; only in system mode does the OS brightness apply. Because it's a derived provider, it recomputes automatically when either the mode changes (user picks dark) or the OS flips (in system mode). Anything that needs "is the app effectively dark?" watches this one provider:
final isDark = ref.watch(effectiveBrightnessProvider) == Brightness.dark;
Using it: a brightness-aware logo
A concrete payoff — swap an asset based on the effective brightness, correctly handling an in-app override:
class BrandLogo extends ConsumerWidget {
const BrandLogo({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isDark = ref.watch(effectiveBrightnessProvider) == Brightness.dark;
return Image.asset(isDark ? 'assets/logo_light.png' : 'assets/logo_dark.png');
}
}
If the user forces Dark while their OS is Light, this logo still shows the dark-mode (light-colored) asset — because effectiveBrightnessProvider factored in the override. A naïve MediaQuery.platformBrightnessOf would have shown the wrong logo.
The bug this prevents: reading only the OS brightness ignores in-app overrides, so forced-dark users on a light OS get light-mode assets/colors in your custom widgets — even though
MaterialAppitself is dark. Always resolve through the effective provider for your conditional logic.
Inside widgets: which API when?
| You need… | Use |
| --- | --- |
| The OS setting, raw | MediaQuery.platformBrightnessOf(context) |
| The currently applied theme's brightness | Theme.of(context).brightness |
| The effective brightness in provider/Dart logic | ref.watch(effectiveBrightnessProvider) |
| To react to OS change in non-widget code | ref.watch(platformBrightnessProvider) |
Theme.of(context).brightness already reflects the effective theme for rendering (since MaterialApp resolved themeMode), so inside widgets it's often the simplest correct choice. The providers shine when pure logic (other providers, repositories, analytics) needs the answer.
Don't forget: system needs both themes
A reminder that ties back to Foundations Part 5: for ThemeMode.system to ever go dark, MaterialApp must have a non-null darkTheme. System detection is pointless if there's no dark theme to switch to. Our setup already provides both via the themeFor(Brightness) factory.
Practice Challenges
Challenge 1 — Two sources. Name the two inputs to "is the app dark," and which wins.
Show solution
The OS brightness and the user's ThemeMode. A light/dark override wins; the OS brightness only applies when the mode is system.
Challenge 2 — OS provider. Why use a WidgetsBindingObserver notifier instead of MediaQuery to expose OS brightness to other providers?
Show solution
MediaQuery.platformBrightnessOf needs a BuildContext, which providers don't have. A WidgetsBindingObserver (via didChangePlatformBrightness) reads the platform dispatcher directly and pushes changes into provider state — usable from pure Dart logic.
Challenge 3 — Effective provider. Write effectiveBrightnessProvider resolving override-vs-OS.
Show solution
final effectiveBrightnessProvider = Provider<Brightness>((ref) {
final mode = ref.watch(themeModeProvider).valueOrNull ?? ThemeMode.system;
return switch (mode) {
ThemeMode.light => Brightness.light,
ThemeMode.dark => Brightness.dark,
ThemeMode.system => ref.watch(platformBrightnessProvider),
};
});
Challenge 4 — Don't leak. What cleanup must the OS-brightness notifier do, and where?
Show solution
Remove its WidgetsBindingObserver when disposed: ref.onDispose(() => WidgetsBinding.instance.removeObserver(this));. Otherwise the observer leaks past the provider's life.
Challenge 5 — Logo bug. A forced-dark user on a light OS sees the light-mode logo. Which API was used and what's the fix?
Show solution
MediaQuery.platformBrightnessOf(context) (OS only) was used, ignoring the override. Resolve through effectiveBrightnessProvider (or Theme.of(context).brightness inside a widget) so the override is honored.
Questions to test yourself
Q1 (basic). What does MediaQuery.platformBrightnessOf(context) return, and is it reactive?
Show answer
The OS-level light/dark Brightness. Yes — widgets reading it rebuild when the user changes the OS setting while the app is open.
Q2 (basic). Why can't a Riverpod provider call MediaQuery.platformBrightnessOf directly?
Show answer
It requires a BuildContext, which providers don't have. You bridge the OS brightness in via a WidgetsBindingObserver notifier (or push it from a widget).
Q3 (intermediate). Which callback fires when the OS dark-mode setting changes, and what do you do in it?
Show answer
WidgetsBindingObserver.didChangePlatformBrightness(). In it, read platformDispatcher.platformBrightness and assign it to the notifier's state so watchers react.
Q4 (intermediate). Explain the resolution rule encoded by effectiveBrightnessProvider.
Show answer
If ThemeMode is light or dark, that override determines the brightness (OS ignored). If it's system, the effective brightness equals the OS brightness. The derived provider recomputes when either input changes.
Q5 (intermediate). Inside a widget, when is Theme.of(context).brightness simpler than the providers?
Show answer
Almost always for rendering logic: MaterialApp already resolved themeMode, so Theme.of(context).brightness reflects the effective theme. Use the providers when non-widget logic (other providers, repositories) needs the answer.
Q6 (advanced). Why is a derived Provider (not a Notifier) the right tool for effective brightness, and what makes it stay correct over time?
Show answer
Effective brightness is a pure function of two reactive inputs (mode + OS brightness) with no mutable state of its own — exactly what a computed Provider models. Because it ref.watches both inputs, Riverpod recomputes and re-notifies it whenever either changes (user picks dark, or OS flips in system mode), so it's always consistent without manual updates. Using a Notifier would add needless mutable state you'd have to keep in sync by hand.
Wrapping up
- "Is the app dark?" has two inputs: the OS brightness and the user's
ThemeModeoverride; the override wins, and the OS only matters insystemmode. - Expose OS brightness to provider logic via a
WidgetsBindingObservernotifier (didChangePlatformBrightness), withref.onDisposecleanup. - Resolve them in a derived
effectiveBrightnessProvider— a computed cell that recomputes when either input changes. - Inside widgets,
Theme.of(context).brightnessis usually the simplest correct read; use the providers for non-widget logic. Reading only the OS brightness is the classic override bug.
In Part 5 we finish the course with scoping: per-feature theme overrides with ProviderScope — giving one screen or subtree its own theme without disturbing the rest.