optireact 1.0.0
optireact: ^1.0.0 copied to clipboard
A lightweight, high-performance reactive state management library for Flutter. Zero external dependencies, auto-dispose, async handling, undo/redo, rate limiting, and more.
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:optireact/optireact.dart';
// -- Scope: groups all demo notifiers with auto-dispose ----------------------
class DemoScope extends ReactiveScope {
late final counter = reactive(0);
late final price = reactive(10.0);
late final quantity = reactive(2);
late final total = create(ReactiveComputedNotifier<double>(
dependencies: [price, quantity],
compute: () => price.value * quantity.value,
));
late final asyncUser = asyncReactive<String>();
late final undoable = create(UndoableNotifier<int>(0, maxHistory: 20));
late final search = create(
ReactiveDebouncedNotifier<String>('',
duration: const Duration(milliseconds: 400)),
);
late final searchResults = reactiveList<String>();
late final favorites = reactiveSet<String>();
}
// -- Entry point -------------------------------------------------------------
void main() => runApp(const OptiReactExampleApp());
class OptiReactExampleApp extends StatelessWidget {
const OptiReactExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'OptiReact Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.indigo,
useMaterial3: true,
),
home: ReactiveHost<DemoScope>(
create: DemoScope.new,
builder: (context, scope, child) => _Home(scope: scope),
),
);
}
}
// -- Home with tabs ----------------------------------------------------------
class _Home extends StatelessWidget {
const _Home({required this.scope});
final DemoScope scope;
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 5,
child: Scaffold(
appBar: AppBar(
title: const Text('OptiReact Demo'),
bottom: const TabBar(isScrollable: true, tabs: [
Tab(icon: Icon(Icons.add), text: 'Counter'),
Tab(icon: Icon(Icons.calculate), text: 'Computed'),
Tab(icon: Icon(Icons.cloud_download), text: 'Async'),
Tab(icon: Icon(Icons.undo), text: 'Undo'),
Tab(icon: Icon(Icons.search), text: 'Search'),
]),
),
body: TabBarView(children: [
_CounterTab(counter: scope.counter),
_ComputedTab(p: scope.price, q: scope.quantity, t: scope.total),
_AsyncTab(notifier: scope.asyncUser),
_UndoTab(notifier: scope.undoable),
_SearchTab(search: scope.search, results: scope.searchResults),
]),
),
);
}
}
// -- 1. Counter: Reactive + batch --------------------------------------
class _CounterTab extends StatelessWidget {
const _CounterTab({required this.counter});
final ReactiveNotifier<int> counter;
@override
Widget build(BuildContext context) {
return Center(
child: Reactive<int>(
notifier: counter,
autoDispose: false,
builder: (ctx, value, _) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('$value', style: ctx.textTheme.displayLarge),
const SizedBox(height: 24),
Row(mainAxisSize: MainAxisSize.min, children: [
FilledButton.tonalIcon(
onPressed: () => counter.value--,
icon: const Icon(Icons.remove),
label: const Text('Decrement'),
),
const SizedBox(width: 16),
FilledButton.icon(
onPressed: () => counter.value++,
icon: const Icon(Icons.add),
label: const Text('Increment'),
),
]),
const SizedBox(height: 16),
OutlinedButton(
onPressed: () => ReactiveNotifier.batch(() {
counter.value += 5;
counter.value += 5;
}),
child: const Text('Batch +10 (single rebuild)'),
),
const SizedBox(height: 8),
TextButton(
onPressed: counter.reset,
child: const Text('Reset'),
),
],
),
),
);
}
}
// -- 2. Computed: price * quantity = total ------------------------------------
class _ComputedTab extends StatelessWidget {
const _ComputedTab({required this.p, required this.q, required this.t});
final ReactiveNotifier<double> p;
final ReactiveNotifier<int> q;
final ReactiveComputedNotifier<double> t;
@override
Widget build(BuildContext context) {
final tt = context.textTheme;
return Padding(
padding: const EdgeInsets.all(24),
child: ReactiveMulti(
notifiers: [p, q, t],
autoDispose: false,
builder: (ctx, _) => Column(mainAxisSize: MainAxisSize.min, children: [
Text('Shopping Cart', style: tt.headlineSmall),
const SizedBox(height: 32),
_row(
ctx,
'Price',
'\$${p.value.toStringAsFixed(2)}',
Slider(
min: 1,
max: 100,
value: p.value,
onChanged: (v) =>
p.value = double.parse(v.toStringAsFixed(2)))),
_row(
ctx,
'Qty',
'${q.value}',
Slider(
min: 1,
max: 20,
divisions: 19,
value: q.value.toDouble(),
onChanged: (v) => q.value = v.round())),
const Divider(height: 32),
Text('Total: \$${t.value.toStringAsFixed(2)}',
style: tt.headlineMedium?.copyWith(
fontWeight: FontWeight.bold, color: ctx.colorScheme.primary)),
]),
),
);
}
Widget _row(BuildContext c, String label, String val, Widget slider) => Row(
children: [
SizedBox(
width: 60, child: Text(label, style: c.textTheme.titleMedium)),
Expanded(child: slider),
SizedBox(width: 70, child: Text(val, textAlign: TextAlign.end)),
],
);
}
// -- 3. Async: loading / error / data ----------------------------------------
Future<String> _fetchUser() async {
await Future.delayed(const Duration(seconds: 2));
if (Random().nextBool()) {
final n = DateTime.now();
return 'Jane Doe (${n.hour}:${n.minute.toString().padLeft(2, '0')})';
}
throw Exception('Network error - tap Retry');
}
class _AsyncTab extends StatelessWidget {
const _AsyncTab({required this.notifier});
final ReactiveAsyncNotifier<String> notifier;
@override
Widget build(BuildContext context) {
return Center(
child: ReactiveAsync<String>(
notifier: notifier,
autoDispose: false,
initial: (_) => Column(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.person_outline, size: 64),
const SizedBox(height: 16),
FilledButton(
onPressed: () => notifier.execute(_fetchUser),
child: const Text('Fetch User'),
),
]),
loading: (_) => const Column(mainAxisSize: MainAxisSize.min, children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading user...'),
]),
error: (ctx, error, _) =>
Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.error_outline, size: 64, color: ctx.colorScheme.error),
const SizedBox(height: 8),
Text('$error', style: TextStyle(color: ctx.colorScheme.error)),
const SizedBox(height: 16),
Row(mainAxisSize: MainAxisSize.min, children: [
FilledButton.icon(
onPressed: () => notifier.execute(_fetchUser, retryCount: 2),
icon: const Icon(Icons.refresh),
label: const Text('Retry (up to 2x)'),
),
const SizedBox(width: 12),
OutlinedButton(
onPressed: notifier.reset, child: const Text('Reset')),
]),
]),
data: (ctx, user) => Column(mainAxisSize: MainAxisSize.min, children: [
const CircleAvatar(radius: 40, child: Icon(Icons.person, size: 48)),
const SizedBox(height: 16),
Text(user, style: ctx.textTheme.titleLarge),
const SizedBox(height: 16),
OutlinedButton(
onPressed: () => notifier.execute(_fetchUser),
child: const Text('Fetch Again'),
),
]),
),
);
}
}
// -- 4. Undo / Redo ----------------------------------------------------------
class _UndoTab extends StatelessWidget {
const _UndoTab({required this.notifier});
final UndoableNotifier<int> notifier;
@override
Widget build(BuildContext context) {
return Center(
child: Reactive<int>(
notifier: notifier,
autoDispose: false,
builder: (ctx, value, _) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('$value', style: ctx.textTheme.displayLarge),
const SizedBox(height: 24),
Wrap(spacing: 8, children: [
for (final n in [1, 5, 10])
ActionChip(
label: Text('+$n'),
onPressed: () => notifier.value = value + n),
]),
const SizedBox(height: 24),
Row(mainAxisSize: MainAxisSize.min, children: [
IconButton.filled(
onPressed: notifier.canUndo ? notifier.undo : null,
icon: const Icon(Icons.undo),
tooltip: 'Undo',
),
const SizedBox(width: 16),
IconButton.filled(
onPressed: notifier.canRedo ? notifier.redo : null,
icon: const Icon(Icons.redo),
tooltip: 'Redo',
),
]),
const SizedBox(height: 8),
Text(
'History: ${notifier.canUndo ? "yes" : "empty"} | '
'Future: ${notifier.canRedo ? "yes" : "empty"}',
style: ctx.textTheme.bodySmall,
),
],
),
),
);
}
}
// -- 5. Search: ReactiveDebouncedNotifier --------------------------------------------
const _kItems = [
'Flutter',
'Dart',
'React',
'Angular',
'Vue',
'Svelte',
'SwiftUI',
'Kotlin',
'Compose',
'Jetpack',
'Riverpod',
'Provider',
'Bloc',
'OptiReact',
'Redux',
'MobX',
];
class _SearchTab extends StatefulWidget {
const _SearchTab({required this.search, required this.results});
final ReactiveDebouncedNotifier<String> search;
final ReactiveList<String> results;
@override
State<_SearchTab> createState() => _SearchTabState();
}
class _SearchTabState extends State<_SearchTab> {
void _onSearchChanged() {
final q = widget.search.value.toLowerCase();
final matches = q.isEmpty
? <String>[]
: _kItems.where((i) => i.toLowerCase().contains(q)).toList();
widget.results.clear();
if (matches.isNotEmpty) widget.results.addAll(matches);
}
@override
void initState() {
super.initState();
widget.search.addListener(_onSearchChanged);
}
@override
void dispose() {
widget.search.removeListener(_onSearchChanged);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(children: [
TextField(
decoration: const InputDecoration(
prefixIcon: Icon(Icons.search),
hintText: 'Type to search (400 ms debounce)...',
border: OutlineInputBorder(),
),
onChanged: (v) => widget.search.value = v,
),
const SizedBox(height: 16),
Expanded(
child: Reactive<List<String>>(
notifier: widget.results,
autoDispose: false,
builder: (ctx, items, _) {
if (items.isEmpty) {
return Center(
child: Text(
widget.search.value.isEmpty
? 'Start typing to search'
: 'No results',
style: ctx.textTheme.bodyLarge
?.copyWith(color: ctx.colorScheme.outline),
));
}
return ListView.separated(
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) => ListTile(
leading: CircleAvatar(child: Text(items[i][0])),
title: Text(items[i]),
),
);
},
),
),
]),
);
}
}
// -- Helpers -----------------------------------------------------------------
extension on BuildContext {
TextTheme get textTheme => Theme.of(this).textTheme;
ColorScheme get colorScheme => Theme.of(this).colorScheme;
}