Responsive Layout
catalyst_ui provides a small, theme-aware responsive layout system built
from three pieces: Breakpoints (thresholds), Breakpoint (a named,
comparable enum), and ResponsiveBuilder (a width-aware layout widget).
Breakpoints
Breakpoints lives in src/tokens/breakpoints.dart and is threaded through
ThemeData like every other design token — accessed via
context.breakpoints and overridable via ThemeData.copyWith(breakpoints: ...).
It holds the minimum width (in logical pixels) at which each named breakpoint becomes active:
class Breakpoints {
const Breakpoints({
this.sm = 640,
this.md = 768,
this.lg = 1024,
this.xl = 1280,
this.xxl = 1536,
});
final double sm;
final double md;
final double lg;
final double xl;
final double xxl;
/// Returns the active [Breakpoint] for the given [width].
Breakpoint resolve(double width) {
if (width >= xxl) return Breakpoint.xxl;
if (width >= xl) return Breakpoint.xl;
if (width >= lg) return Breakpoint.lg;
if (width >= md) return Breakpoint.md;
if (width >= sm) return Breakpoint.sm;
return Breakpoint.xs;
}
Breakpoints copyWith({...});
}
Defaults follow common web conventions. Override any threshold by
supplying a custom instance to ThemeData:
ThemeData.light().copyWith(
breakpoints: const Breakpoints(md: 900, lg: 1200),
)
Breakpoint
Breakpoint is an enhanced enum — it carries comparison operators so
callers can compare breakpoints directly instead of comparing raw pixel
widths:
enum Breakpoint {
xs, // below Breakpoints.sm — phone portrait
sm, // at/above Breakpoints.sm — phone landscape / large phone
md, // at/above Breakpoints.md — tablet portrait
lg, // at/above Breakpoints.lg — tablet landscape / small desktop
xl, // at/above Breakpoints.xl — standard desktop
xxl; // at/above Breakpoints.xxl — large desktop
bool operator >=(Breakpoint other) => index >= other.index;
bool operator >(Breakpoint other) => index > other.index;
bool operator <=(Breakpoint other) => index <= other.index;
bool operator <(Breakpoint other) => index < other.index;
}
Because the enum's declaration order is narrowest-to-widest, the comparison operators work intuitively against that ordering:
if (breakpoint >= Breakpoint.md) {
// tablet portrait or wider
}
ResponsiveBuilder
ResponsiveBuilder, in src/utils/responsive_builder.dart, wraps
LayoutBuilder and resolves the current Breakpoint from the available
width. Critically, it does not use MediaQuery.sizeOf — it reads the
parent's constraints, so it works correctly both for full-screen layouts and
for components embedded inside a narrower parent (a side panel, a card, a
split view, and so on):
class ResponsiveBuilder extends StatelessWidget {
const ResponsiveBuilder({required this.builder, super.key});
final Widget Function(
BuildContext context,
Breakpoint breakpoint,
) builder;
Widget build(BuildContext context) {
final breakpoints = context.breakpoints;
return LayoutBuilder(
builder: (context, constraints) =>
builder(context, breakpoints.resolve(constraints.maxWidth)),
);
}
}
Breakpoint thresholds are read from the nearest theme, so they respect any
ThemeData.copyWith(breakpoints: ...) override in scope.
Usage
ResponsiveBuilder(
builder: (context, breakpoint) {
if (breakpoint >= Breakpoint.md) {
return const TwoColumnLayout();
}
return const SingleColumnLayout();
},
)
Because ResponsiveBuilder reacts to the parent's constraints rather than
the device's screen size, the same widget behaves correctly whether it's the
root of the page or nested three levels deep inside a narrower container.