zenui 0.1.0
zenui: ^0.1.0 copied to clipboard
Fine-grained reactive UI for Flutter — aspect-level rebuilds powered by ZenSignals.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:zenui/zenui.dart';
import 'package:zensignals/zensignals.dart';
void main() {
runApp(const SurgicalMediaQueryDemoApp());
}
/// Demonstrates ZenUI fine-grained reactive [ReactiveMediaQuery] with [zensignals].
///
/// Timers can mutate width or [EdgeInsets.top] while [EdgeInsets.bottom] stays
/// fixed. [ReactiveMediaQuery.paddingOf] subscribes to the whole padding
/// computed value — any edge change rebuilds. A [ComputedNotifier] on
/// `padding().bottom` only rebuilds when bottom actually changes.
class SurgicalMediaQueryDemoApp extends StatelessWidget {
const SurgicalMediaQueryDemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'ZenUI — Fine-grained MediaQuery',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
useMaterial3: true,
),
home: const _DemoPage(),
);
}
}
class _DemoPage extends StatefulWidget {
const _DemoPage();
@override
State<_DemoPage> createState() => _DemoPageState();
}
class _DemoPageState extends State<_DemoPage> {
static const _baseSize = Size(390, 844);
double _paddingBottom = 34;
double _paddingTop = 0;
double _widthNoise = 0;
double _paddingTopNoise = 0;
Timer? _widthTimer;
Timer? _paddingTopTimer;
bool _widthTickerRunning = false;
bool _paddingTopTickerRunning = true;
@override
void initState() {
super.initState();
_startWidthTicker();
_startPaddingTopTicker();
}
void _startWidthTicker() {
_widthTimer?.cancel();
_widthTimer = Timer.periodic(const Duration(milliseconds: 100), (_) {
if (!_widthTickerRunning || !mounted) return;
setState(() {
_widthNoise = (_widthNoise + 7) % 120;
});
});
}
void _startPaddingTopTicker() {
_paddingTopTimer?.cancel();
_paddingTopTimer = Timer.periodic(const Duration(milliseconds: 100), (_) {
if (!_paddingTopTickerRunning || !mounted) return;
setState(() {
_paddingTopNoise = (_paddingTopNoise + 5) % 48;
});
});
}
@override
void dispose() {
_widthTimer?.cancel();
_paddingTopTimer?.cancel();
super.dispose();
}
EdgeInsets get _padding => EdgeInsets.only(
top: _paddingTop + _paddingTopNoise,
bottom: _paddingBottom,
);
MediaQueryData _overrideMediaQueryData(MediaQueryData data) {
final width = _baseSize.width + _widthNoise;
final padding = _padding;
return data.copyWith(
size: Size(width, data.size.height),
padding: padding,
viewPadding: padding,
);
}
@override
Widget build(BuildContext context) {
return ReactiveMediaQueryOverride(
dataOverrides: _overrideMediaQueryData,
child: ReactiveMediaQuery(
child: Scaffold(
extendBodyBehindAppBar: true,
resizeToAvoidBottomInset: false,
body: Stack(
children: [
Positioned.fill(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
const _ExplanationCard(),
const SizedBox(height: 16),
_HostMetricsCard(
width: _baseSize.width + _widthNoise,
paddingTop: _padding.top,
paddingBottom: _paddingBottom,
widthTickerRunning: _widthTickerRunning,
paddingTopTickerRunning: _paddingTopTickerRunning,
),
const SizedBox(height: 16),
const _MediaQueryOfCard(),
const SizedBox(height: 12),
const _PaddingOfSignalCard(),
const SizedBox(height: 12),
const _PaddingOfMediaQueryCard(),
const SizedBox(height: 12),
RepaintBoundary(child: const _BottomPaddingComputedCard()),
const SizedBox(height: 12),
const _WidthListenerCard(),
const SizedBox(height: 24),
_Controls(
widthTickerRunning: _widthTickerRunning,
paddingTopTickerRunning: _paddingTopTickerRunning,
paddingBottom: _paddingBottom,
onToggleWidthTicker: () {
setState(() {
_widthTickerRunning = !_widthTickerRunning;
});
},
onTogglePaddingTopTicker: () {
setState(() {
_paddingTopTickerRunning = !_paddingTopTickerRunning;
});
},
onBumpPaddingBottom: () {
setState(() {
_paddingBottom = _paddingBottom == 34 ? 80 : 34;
});
},
onBumpPaddingTopBase: () {
setState(() {
_paddingTop = _paddingTop == 0 ? 24 : 0;
_paddingTopNoise = 0;
});
},
),
],
),
),
Align(
alignment: .bottomCenter,
child: RepaintBoundary(child: const _BottomPaddingTextField()),
),
],
),
),
),
);
}
}
class _ExplanationCard extends StatelessWidget {
const _ExplanationCard();
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Host metrics use [ReactiveMediaQueryOverride] above [ReactiveMediaQuery].\n\n'
'By default, padding.top wiggles ~10×/sec while padding.bottom stays 34.\n\n'
'• MediaQuery.of rebuilds on width noise too.\n'
'• MediaQuery.paddingOf (and the computed paddingOf) rebuild on any padding '
'edge change — not width, but top noise still hits them.\n'
'• computed(padding().bottom) only rebuilds when bottom changes.\n'
'• The bottom text field uses that computed for inset only.\n\n'
'Toggle padding.bottom to see all padding listeners react.',
style: Theme.of(context).textTheme.bodyMedium,
),
),
);
}
}
class _HostMetricsCard extends StatelessWidget {
const _HostMetricsCard({
required this.width,
required this.paddingTop,
required this.paddingBottom,
required this.widthTickerRunning,
required this.paddingTopTickerRunning,
});
final double width;
final double paddingTop;
final double paddingBottom;
final bool widthTickerRunning;
final bool paddingTopTickerRunning;
@override
Widget build(BuildContext context) {
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Host (setState)',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Text('width: ${width.toStringAsFixed(1)}'),
Text(
'padding.top: ${paddingTop.toStringAsFixed(0)} '
'${paddingTopTickerRunning ? '(noise ON)' : '(stable)'}',
),
Text(
'padding.bottom: ${paddingBottom.toStringAsFixed(0)} (stable)',
),
Text(
'width ticker: ${widthTickerRunning ? 'ON' : 'off'}',
style: Theme.of(context).textTheme.labelLarge,
),
],
),
),
);
}
}
/// [MediaQuery.of] — rebuilds when *any* [MediaQueryData] field changes (incl. width).
class _MediaQueryOfCard extends StatelessWidget {
const _MediaQueryOfCard();
@override
Widget build(BuildContext context) {
return _DemoPanel(
title: 'MediaQuery.of (broad)',
subtitle: 'MediaQuery.of(context).padding — width noise rebuilds this',
accent: Colors.red,
child: Builder(
builder: (context) {
final padding = MediaQuery.of(context).padding;
return _RebuildCounter(
child: Text(
'top = ${padding.top.toStringAsFixed(0)}, '
'bottom = ${padding.bottom.toStringAsFixed(0)}',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
);
},
),
);
}
}
/// [ReactiveMediaQuery.paddingOf] computed — same padding aspect as Flutter.
class _PaddingOfSignalCard extends StatelessWidget {
const _PaddingOfSignalCard();
@override
Widget build(BuildContext context) {
return _DemoPanel(
title: 'paddingOf() computed',
subtitle: 'ReactiveMediaQuery.paddingOf(context)()',
accent: Colors.deepOrange,
child: SignalBuilder(
forceRebuild: false,
builder: (context) {
final padding = ReactiveMediaQuery.paddingOf(context)();
return _RebuildCounter(
child: Text(
'top = ${padding.top.toStringAsFixed(0)}, '
'bottom = ${padding.bottom.toStringAsFixed(0)}',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
);
},
),
);
}
}
/// Flutter [MediaQuery.paddingOf] — padding *aspect* only; still rebuilds when top changes.
class _PaddingOfMediaQueryCard extends StatelessWidget {
const _PaddingOfMediaQueryCard();
@override
Widget build(BuildContext context) {
return _DemoPanel(
title: 'MediaQuery.paddingOf',
subtitle: 'Flutter aspect API — not width, but any padding edge changes',
accent: Colors.amber,
child: Builder(
builder: (context) {
final padding = MediaQuery.paddingOf(context);
return _RebuildCounter(
child: Text(
'top = ${padding.top.toStringAsFixed(0)}, '
'bottom = ${padding.bottom.toStringAsFixed(0)}',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
);
},
),
);
}
}
/// Listens only to [ReactiveMediaQuery.widthOf] — rebuilds with the ticker.
class _WidthListenerCard extends StatelessWidget {
const _WidthListenerCard();
@override
Widget build(BuildContext context) {
return _DemoPanel(
title: 'Width computed',
subtitle: 'ReactiveMediaQuery.widthOf(context)()',
accent: Colors.orange,
child: SignalBuilder(
forceRebuild: false,
builder: (context) {
final width = ReactiveMediaQuery.widthOf(context)();
return _RebuildCounter(
child: Text(
'width = ${width.toStringAsFixed(1)}',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
);
},
),
);
}
}
/// [ComputedNotifier] on padding bottom — ignores width-only host updates.
class _BottomPaddingComputedCard extends StatefulWidget {
const _BottomPaddingComputedCard();
@override
State<_BottomPaddingComputedCard> createState() =>
_BottomPaddingComputedCardState();
}
class _BottomPaddingComputedCardState extends State<_BottomPaddingComputedCard>
with ReactiveNotifierMixin<_BottomPaddingComputedCard> {
ComputedNotifier<double>? _viewInsetsBottom;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_viewInsetsBottom ??= createComputed(
(_) => ReactiveMediaQuery.viewInsetsOf(context)().bottom,
listen: false,
);
}
@override
Widget build(BuildContext context) {
final bottomComputed = _viewInsetsBottom!;
return _DemoPanel(
title: 'viewInsets.bottom only (computed)',
subtitle: 'computed((_) => padding().bottom) — ignores top-only changes',
accent: Colors.green,
child: SignalBuilder(
forceRebuild: false,
builder: (_) {
final bottom = bottomComputed();
return _RebuildCounter(
child: Text(
'padding.bottom = ${bottom.toStringAsFixed(0)}',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
);
},
),
);
}
}
class _DemoPanel extends StatelessWidget {
const _DemoPanel({
required this.title,
required this.subtitle,
required this.accent,
required this.child,
});
final String title;
final String subtitle;
final Color accent;
final Widget child;
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
color: accent.withValues(alpha: 0.12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleSmall),
Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
Padding(padding: const EdgeInsets.all(16), child: child),
],
),
);
}
}
class _RebuildCounter extends StatefulWidget {
const _RebuildCounter({required this.child});
final Widget child;
@override
State<_RebuildCounter> createState() => _RebuildCounterState();
}
class _RebuildCounterState extends State<_RebuildCounter> {
int _builds = 0;
@override
Widget build(BuildContext context) {
_builds++;
return Column(
mainAxisSize: .min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_builds > 15 ? Icons.warning_amber_rounded : Icons.autorenew,
size: 18,
color: _builds > 15
? Theme.of(context).colorScheme.error
: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Widget rebuilds: $_builds',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
const SizedBox(height: 8),
widget.child,
],
);
}
}
/// Bottom [TextField] whose inset tracks [ReactiveMediaQuery] padding bottom only.
class _BottomPaddingTextField extends StatefulWidget {
const _BottomPaddingTextField();
@override
State<_BottomPaddingTextField> createState() =>
_BottomPaddingTextFieldState();
}
class _BottomPaddingTextFieldState extends State<_BottomPaddingTextField>
with ReactiveNotifierMixin<_BottomPaddingTextField> {
final _controller = TextEditingController();
late final _viewInsetsBottom = createComputed(
(_) => ReactiveMediaQuery.viewInsetsOf(context)().bottom,
listen: false,
);
late final _viewPaddingBottom = createComputed(
(_) => ReactiveMediaQuery.paddingOf(context)().bottom,
listen: false,
);
late final _bottom = createComputed((prev) {
final viewInsetsBottom = _viewInsetsBottom();
final viewPaddingBottom = _viewPaddingBottom();
if (viewInsetsBottom < viewPaddingBottom) return viewPaddingBottom;
return viewInsetsBottom;
}, listen: false);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Material(
elevation: 8,
child: SignalBuilder(
forceRebuild: false,
builder: (_) {
final viewInsetsBottom = _viewInsetsBottom();
final viewPaddingBottom = _viewPaddingBottom();
final bottom = _bottom();
return _RebuildCounter(
child: Padding(
padding: EdgeInsets.fromLTRB(16, 12, 16, bottom),
child: TextField(
controller: _controller,
onTapOutside: (event) =>
FocusScope.of(context, createDependency: false).unfocus(),
decoration: InputDecoration(
labelText: 'Bottom inset from ReactiveMediaQuery',
hintText:
'Type here — top/width noise should not rebuild this',
helperText:
'viewInsetsBottom.bottom = ${viewInsetsBottom.toStringAsFixed(0)} (computed only)\n'
'viewPaddingBottom.bottom = ${viewPaddingBottom.toStringAsFixed(0)} (computed only)',
border: const OutlineInputBorder(),
),
),
),
);
},
),
);
}
}
class _Controls extends StatelessWidget {
const _Controls({
required this.widthTickerRunning,
required this.paddingTopTickerRunning,
required this.paddingBottom,
required this.onToggleWidthTicker,
required this.onTogglePaddingTopTicker,
required this.onBumpPaddingBottom,
required this.onBumpPaddingTopBase,
});
final bool widthTickerRunning;
final bool paddingTopTickerRunning;
final double paddingBottom;
final VoidCallback onToggleWidthTicker;
final VoidCallback onTogglePaddingTopTicker;
final VoidCallback onBumpPaddingBottom;
final VoidCallback onBumpPaddingTopBase;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
onPressed: onTogglePaddingTopTicker,
icon: Icon(paddingTopTickerRunning ? Icons.pause : Icons.play_arrow),
label: Text(
paddingTopTickerRunning
? 'Pause padding.top noise'
: 'Resume padding.top noise',
),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: onToggleWidthTicker,
icon: const Icon(Icons.swap_horiz),
label: Text(
widthTickerRunning ? 'Pause width noise' : 'Start width noise',
),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: onBumpPaddingBottom,
icon: const Icon(Icons.vertical_align_bottom),
label: Text(
'Toggle padding.bottom (34 ↔ 80, now ${paddingBottom.toStringAsFixed(0)})',
),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: onBumpPaddingTopBase,
icon: const Icon(Icons.vertical_align_top),
label: const Text('Step padding.top base (0 ↔ 24, clears noise)'),
),
],
);
}
}