watchable_redux 1.3.0
watchable_redux: ^1.3.0 copied to clipboard
Predictable state management for Flutter. Redux architecture with O(1) selector caching, memoized selectors, async middleware, and time-travel debugging. Built on Watchable.
example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:watchable_redux/watchable_redux.dart';
void main() {
Redux.init(store);
runApp(const MyApp());
}
// =============================================================================
// STATE
// =============================================================================
class AppState {
final int counter;
final String name;
final bool isLoading;
final List<String> items;
const AppState({
this.counter = 0,
this.name = 'Guest',
this.isLoading = false,
this.items = const [],
});
AppState copyWith(
{int? counter, String? name, bool? isLoading, List<String>? items}) {
return AppState(
counter: counter ?? this.counter,
name: name ?? this.name,
isLoading: isLoading ?? this.isLoading,
items: items ?? this.items,
);
}
}
// =============================================================================
// ACTIONS
// =============================================================================
class Increment extends ReduxAction {
const Increment();
}
class Decrement extends ReduxAction {
const Decrement();
}
class SetName extends ReduxAction {
final String name;
const SetName(this.name);
}
class SetLoading extends ReduxAction {
final bool loading;
const SetLoading(this.loading);
}
class AddItem extends ReduxAction {
final String item;
const AddItem(this.item);
}
class ClearItems extends ReduxAction {
const ClearItems();
}
// =============================================================================
// REDUCER
// =============================================================================
AppState appReducer(AppState state, ReduxAction action) {
return switch (action) {
Increment() => state.copyWith(counter: state.counter + 1),
Decrement() => state.copyWith(counter: state.counter - 1),
SetName(:final name) => state.copyWith(name: name),
SetLoading(:final loading) => state.copyWith(isLoading: loading),
AddItem(:final item) => state.copyWith(items: [...state.items, item]),
ClearItems() => state.copyWith(items: []),
_ => state,
};
}
// =============================================================================
// SELECTORS (Memoized)
// =============================================================================
int selectCounter(AppState s) => s.counter;
String selectName(AppState s) => s.name;
List<String> selectItems(AppState s) => s.items;
bool selectIsLoading(AppState s) => s.isLoading;
final selectItemCount = createSelector<AppState, List<String>, int>(
selectItems,
(items) => items.length,
);
final selectGreeting = createSelector2<AppState, String, int, String>(
selectName,
selectCounter,
(name, count) => '$name clicked $count times',
);
// =============================================================================
// STORE
// =============================================================================
final store = Store<AppState>(
initialState: const AppState(),
reducer: appReducer,
middlewares: [thunkMiddleware(), loggerMiddleware()],
enableDevTools: true,
);
// =============================================================================
// APP
// =============================================================================
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return StoreProvider<AppState>(
store: store,
child: MaterialApp(
title: 'Watchable Redux Demo',
theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true),
home: const HomePage(),
),
);
}
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Watchable Redux Demo')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildCounterSection(),
_buildNameSection(),
_buildSelectorsSection(),
_buildItemsSection(),
_buildThunkSection(),
_buildDevToolsSection(),
],
),
);
}
Widget _section(String title, List<Widget> children) {
return Card(
margin: const EdgeInsets.only(bottom: 16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style:
const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
...children,
],
),
),
);
}
// Counter with dispatch
Widget _buildCounterSection() {
return _section('Counter (dispatch)', [
store.build(
(count) => Text('Count: $count', style: const TextStyle(fontSize: 24)),
only: selectCounter,
),
const SizedBox(height: 8),
Row(children: [
ElevatedButton(
onPressed: () => store.dispatch(const Increment()),
child: const Text('+'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () => store.dispatch(const Decrement()),
child: const Text('-'),
),
]),
]);
}
// Name input
Widget _buildNameSection() {
return _section('Name Input', [
store.build(
(name) => Text('Hello, $name!', style: const TextStyle(fontSize: 20)),
only: selectName,
),
const SizedBox(height: 8),
TextField(
onChanged: (v) => store.dispatch(SetName(v.isEmpty ? 'Guest' : v)),
decoration: const InputDecoration(
labelText: 'Enter name', border: OutlineInputBorder()),
),
]);
}
// Memoized selectors
Widget _buildSelectorsSection() {
return _section('Memoized Selectors', [
const Text('createSelector2:',
style: TextStyle(fontWeight: FontWeight.bold)),
store.build(
(greeting) =>
Text(greeting, style: const TextStyle(color: Colors.blue)),
only: selectGreeting,
),
const SizedBox(height: 8),
const Text('createSelector (derived):',
style: TextStyle(fontWeight: FontWeight.bold)),
store.build(
(count) => Text('Item count: $count',
style: const TextStyle(color: Colors.green)),
only: selectItemCount,
),
]);
}
// Items list
Widget _buildItemsSection() {
return _section('Items List', [
store.build(
(items) => Text(items.isEmpty ? 'No items' : items.join(', ')),
only: selectItems,
),
const SizedBox(height: 8),
Row(children: [
ElevatedButton(
onPressed: () =>
store.dispatch(AddItem('Item ${DateTime.now().second}')),
child: const Text('Add Item'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () => store.dispatch(const ClearItems()),
child: const Text('Clear'),
),
]),
]);
}
// Thunk (async action)
Widget _buildThunkSection() {
return _section('Thunk (Async)', [
store.build(
(loading) => loading
? const Row(children: [
CircularProgressIndicator(),
SizedBox(width: 8),
Text('Loading...')
])
: const Text('Ready', style: TextStyle(color: Colors.green)),
only: selectIsLoading,
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () =>
store.dispatch(ThunkAction<AppState>((dispatch, getState) async {
dispatch(const SetLoading(true));
await Future.delayed(const Duration(seconds: 1));
dispatch(SetName('Loaded User'));
dispatch(const SetLoading(false));
})),
child: const Text('Load User (Thunk)'),
),
]);
}
// DevTools
Widget _buildDevToolsSection() {
return _section('DevTools (Time-Travel)', [
ValueListenableBuilder(
valueListenable: store.devTools!.watchableHistory.notifier,
builder: (_, history, __) => Text('History: ${history.length} states'),
),
const SizedBox(height: 8),
Row(children: [
ElevatedButton(
onPressed: store.devTools?.canUndo == true
? () => store.devTools?.undo()
: null,
child: const Text('Undo'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: store.devTools?.canRedo == true
? () => store.devTools?.redo()
: null,
child: const Text('Redo'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () => store.devTools?.reset(),
child: const Text('Reset'),
),
]),
]);
}
}