← Back to blog
Mastering Riverpod: Theming · Part 4 of 6
September 2, 20268 min read

System Theme Detection with Riverpod — Respecting Brightness from the OS

RiverpodFlutterDart

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?":

  1. The OS setting — what the user picked in system settings (or a scheduled auto night mode).
  2. 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 ThemeMode is the thermostat: set to light or dark it ignores the weather; set to system it follows it. The effective temperature depends on the mode — and only in system mode 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 a WidgetsBindingObserver and returns the current OS brightness.
  • didChangePlatformBrightness() fires when the OS toggles dark mode; we push the new value into state.
  • ref.onDispose removes 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;

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 MaterialApp itself 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 ThemeMode override; the override wins, and the OS only matters in system mode.
  • Expose OS brightness to provider logic via a WidgetsBindingObserver notifier (didChangePlatformBrightness), with ref.onDispose cleanup.
  • Resolve them in a derived effectiveBrightnessProvider — a computed cell that recomputes when either input changes.
  • Inside widgets, Theme.of(context).brightness is 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.