mastro 1.5.0
mastro: ^1.5.0 copied to clipboard
A robust state management solution for Flutter that seamlessly integrates reactive programming with event handling and persistence capabilities.
Mastro #
A pragmatic, fast, and ergonomic Flutter state toolkit that blends reactive state, event orchestration, persistence, and view/scope glue into a clean, testable, feature‑based architecture.
Zero boilerplate for simple state — strong patterns for complex flows.
Table of Contents #
- Features
- Installation
- Project Structure (Feature-based)
- Overall Flow (How it links up)
- Quick Start
- Reactive State
- Persistence (Persistro)
- Boxes & Events
- Widget Building
- MastroScope (back-blocking UX)
- MastroView (view glue & lifecycle)
- FAQ
- Examples
- Contributions
- License
Features #
- Feature‑based structure: each feature owns its presentation, logic (boxes & events), and optional states.
- Reactive state:
Lightro<T>,Mastro<T>both support.modify,.late(), and builders. - Events engine:
parallel, per‑typesequential(FIFO with awaitable queued calls), and per‑typesolo(suppress duplicates of the same type). - Gesture‑friendly builders:
MastroBuilder/TagBuilderrebuild immediately when safe. - Persistence:
PersistroLightro/PersistroMastroon top ofSharedPreferences. - Scopes:
MastroScopeintegrates back‑blocking UX for long tasks. - Views:
MastroView<T>pairs a screen with its box (local or scoped) and exposes lifecycle hooks.
Installation #
dependencies:
mastro: ^<latest>
flutter pub get
If you use persistence, initialize it once before runApp:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Persistro.initialize();
runApp(const MyApp());
}
Project Structure (Feature‑based) #
Each feature owns its UI and logic (and optional custom states). Keep shared bits in core/.
lib/
core/ # theming, routing, env, DI helpers, shared states
features/
auth/
presentation/ # widgets, screens, MastroView subclasses
auth_view.dart
logic/ # boxes + events for this feature
auth_box.dart
auth_event.dart
states/ # custom sealed/union states (optional)
todos/
presentation/
components/
todo_view.dart
logic/
todos_box.dart
todos_event.dart
app.dart
main.dart
Naming convention (logic):
*_box.dartfor boxes*_event.dartfor events*_view.dartfor views
Overall Flow (How it links up) #
- Provide or create a box
- Scoped (Global): provide with
BoxProvider/MultiBoxProviderhigh in the tree. - Local: pass a factory directly to your
MastroViewsuper constructor.
- Render a view (
MastroView<T>)
- If you passed a local factory → the view uses that instance.
- Otherwise → it resolves the box from
BoxProvider.of<T>(context).
-
Build widgets with
MastroBuilder/TagBuilderlistening to reactive state. -
Run events via
box.execute(event)(orexecuteBlockPop(context, event)to block back viaMastroScope). -
Persist state over sessions with
Persistro-PersistroLightro-PersistroMastrowhen needed.
Quick Start #
import 'package:flutter/material.dart';
import 'package:mastro/mastro.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MultiBoxProvider(
providers: [
// Scoped (Global) box
BoxProvider(create: (_) => CounterBox()),
],
child: const MaterialApp(
home: CounterView(),
),
);
}
}
// features/counter/logic/counter_box.dart
class CounterBox extends MastroBox<CounterEvent> {
final count = 0.lightro;
}
// features/counter/logic/counter_event.dart
sealed class CounterEvent extends MastroEvent<CounterBox> {
const CounterEvent();
const factory CounterEvent.increment() = _Increment;
}
class _Increment extends CounterEvent {
const _Increment();
@override
Future<void> implement(CounterBox box, Callbacks callbacks) async {
box.count.value++;
}
}
// features/counter/presentation/counter_view.dart
class CounterView extends MastroView<CounterBox> {
// Local: pass a box directly to the super constructor:
// const CounterView({super.key}) : super(box: () => CounterBox());
const CounterView({super.key});
@override
Widget build(BuildContext context, CounterBox box) {
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(
child: MastroBuilder(
state: box.count,
builder: (state, context) => Text('Count: ${state.value}', style: const TextStyle(fontSize: 36)),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => box.execute(const CounterEvent.increment()),
child: const Icon(Icons.add),
),
);
}
}
Local vs Scoped (Global): in a
MastroView, passsuper(box: () => YourBox())to use a local instance.
If you omit it, the view will resolve a scoped box fromBoxProvider.
Reactive State #
Lightro vs Mastro (Comparison) #
| Capability | Lightro<T> | Mastro<T> |
|---|---|---|
Reactive .value |
✅ (state.value = x) |
✅ |
modify in‑place mutation |
✅ state.modify((state) { state.value.field = ...; }) |
✅ |
late() (uninitialized start) |
✅ | ✅ |
| Computed/derived values | ❌ | ✅ compute((self) => ...) + .dependsOn(other) |
| Validation | ❌ | ✅ setValidator(...) + onValidationError |
| Observers (side effects) | ❌ | ✅ .observe('key', (v) => ...) |
Tip: Start with Lightro; choose Mastro when you need compute/validation/dependencies/observers.
Lightro #
final isEnabled = false.lightro;
MastroBuilder(
state: isEnabled,
builder: (state, context) => Switch(
value: state.value,
onChanged: (value) => state.value = value,
),
);
Mastro #
class Profile { String name; int age; Profile(this.name, this.age); }
final profile = Profile('Alice', 30).mastro;
// In-place updates
profile.modify((s) {
s.value.name = 'Bob';
s.value.age++;
});
// Computed with dependency
final factor = 2.mastro;
final scaledAge = profile
.compute((p) => p.age * factor.value);
// Validation & observers
profile
..setValidator((p) => p.name.isNotEmpty && p.age >= 0)
..observe('log', (p) => debugPrint('Profile → ${p.name}(${p.age})'));
Mastro Functions #
-
dependsOn: Establish dependencies between states. When any dependency state changes, the dependent state will be notified.
dependentState.dependsOn(anotherState); // dependentState gets notified when anotherState changes -
compute: Define computed values based on other states. Automatically updates when the source states change.
final someState = 10.mastro; final computedState = someState.compute((value) => value * 5); // computedState is automatically updated when someState changes -
setValidator: Set validation logic for a state. Ensures that the state value meets certain criteria.
final validatedState = 2.mastro; validatedState.setValidator((value) => value > 0); validatedState.value = 1; // accepted validatedState.value = -1; // rejected (see next section to handle errors) -
observe: Observe changes in the state and execute a callback when the state changes.
observedState.observe('observedState', (value) { print('State changed to $value'); });
Validation & Error Handling #
Mastro supports validation and error callbacks out of the box:
final age = 25.mastro;
// Validation rule + error handler
age.setValidator(
(v) => v >= 0 && v <= 120,
onValidationError: (invalid) {
// Show a toast/snack, log, or recover
debugPrint('Invalid age: $invalid');
},
);
// Setting an invalid value will NOT update the state;
// instead, onValidationError is invoked.
age.value = -5; // ❌ rejected, handler runs
age.value = 26; // ✅ accepted
Validation can also be added to computed states:
final price = 10.mastro;
final qty = 2.mastro;
final total = price.compute((p) => p * qty.value)
// ensure positive totals only
..setValidator((t) => t > 0, onValidationError: (t) {
debugPrint('Non-positive total: $t');
});
AsyncState #
Wrap AsyncState<T> in a reactive container so the UI can listen:
// Best practice: wrap in Lightro (or Mastro)
final userState = const AsyncState<User>.initial().lightro;
// or: final userState = const AsyncState<User>.initial().mastro;
Future<void> loadUser() async {
userState.value = const AsyncState.loading();
try {
final u = await repo.fetchUser();
userState.value = AsyncState.data(u);
} catch (e) {
userState.value = AsyncState.error('Failed: $e');
}
}
MastroBuilder(
state: userState,
builder: (state, _) => state.value.when(
initial: (_) => const Text('Tap to load'),
loading: () => const CircularProgressIndicator(),
data: (u) => Text('Hello ${u.name}'),
error: (msg, _) => Text(msg ?? 'Error'),
),
);
late() state and helpers #
Both Lightro & Mastro support uninitialized state via .late():
final token = Lightro<String>.late(); // or: final profile = Mastro<User>.late();
// Accessing before setting throws UninitializedLate...Exception:
token.value; // ❌ throws
// Initialize first:
token.value = 'abc'; // ✅
// Safe branching:
final label = token.when(
uninitialized: () => 'No token',
initialized: (v) => 'Token: $v',
);
Key helpers & properties (both kinds):
isInitializedensureInitialized()when({uninitialized, initialized})resetToUninitialized()notify()
Persistence (Persistro) #
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Persistro.initialize();
runApp(const MyApp());
}
Low‑level API + reactive wrappers:
// Low-level key/value
await Persistro.putString('token', 'abc');
final token = await Persistro.getString('token');
// Reactive persisted state (Lightro flavor)
final cart = PersistroLightro.list<CartItem>(
'cart',
initial: [],
fromJson: (j) => CartItem.fromJson(j),
);
// Reactive persisted state (Mastro flavor)
final isDark = PersistroMastro.boolean('isDarkMode', initial: false);
// Manual control if needed
await isDark.persist();
await isDark.restore();
await isDark.clear();
Boxes & Events #
Local vs Scoped (Global) Boxes #
-
Local: Pass a box directly to your view via the
MastroViewsuper constructor.class SearchView extends MastroView<SearchBox> { const SearchView({super.key}) : super(box: () => SearchBox()); // local ... } -
Scoped (Global): Provide a box using
BoxProvider/MultiBoxProviderhigh in the tree, then let views resolve it automatically.MultiBoxProvider( providers: [ BoxProvider<AuthBox>(create: (_) => AuthBox()), ], child: const App(), );
Access anywhere via:
final box = BoxProvider.of<AuthBox>(context);
MastroBox lifecycle & options #
Each box exposes small lifecycle hooks and options:
init()→ override to set up (callsuper.init()).cleanup()→ override to tear down (callsuper.cleanup()); idempotent.
Auto cleanup knobs
autoCleanupWhenAllViewsDetached(bool; box property, provider option)
Whentrue, the box callscleanup()automatically after the last attachedMastroViewdetaches.autoCleanupWhenUnmountedFromWidgetTree(bool; provider option)
Whentrue, the provider callscleanup()when the provider subtree unmounts (e.g., navigating away).
TL;DR
- Short‑lived screens → Local box (pass via view).
- Long‑lived features → Scoped box (provider), and fine‑tune cleanup with the two flags above.
Creating a Box #
// features/notes/logic/notes_box.dart
class NotesBox extends MastroBox<NotesEvent> {
final notes = <Note>[].mastro;
@override
void init() { super.init(); }
@override
void cleanup() { super.cleanup(); }
}
Creating Events #
// features/notes/logic/notes_event.dart
sealed class NotesEvent extends MastroEvent<NotesBox> {
const NotesEvent();
const factory NotesEvent.add(String title) = _AddNote;
const factory NotesEvent.load() = _Load;
}
class _AddNote extends NotesEvent {
final String title;
const _AddNote(this.title);
@override
Future<void> implement(NotesBox box, Callbacks callbacks) async {
box.notes.modify((s) => s.value.add(Note(title)));
callbacks.invoke('toast', data: {'msg': 'Note added'}); // optional loose callback
}
}
class _Load extends NotesEvent {
const _Load();
@override
EventRunningMode get mode => EventRunningMode.sequential; // serialize
@override
Future<void> implement(NotesBox box, Callbacks callbacks) async {
// fetch and assign
}
}
Running Events #
// With callbacks:
await box.execute(
const NotesEvent.add('New Note'),
callbacks: Callbacks.on('toast', (data) {
// receive callback from event
}),
);
// Override mode at call-site & block back while running:
await box.executeBlockPop(
context,
const NotesEvent.load(),
mode: EventRunningMode.solo,
);
EventRunningMode #
parallel(default): Multiple instances can run simultaneously, run freely.sequential: Events of same type are queued, per‑type FIFO — one event of a given runtime type at a time; others queue behind it.
Queued calls return a Future that completes when that specific queued item finishes.solo: per‑type exclusivity — another SOLO of the same type is ignored while one runs (different SOLO types may run concurrently).
Box Tagging & Loose Callbacks #
-
Tagging: fire a targeted UI refresh without wiring explicit state:
// in box tag(tag: 'refresh-notes'); // in view/widget TagBuilder(tag: 'refresh-notes', box: box, builder: (_) => NotesList()); -
Loose callbacks: decouple UI actions from events:
// register once (e.g., in view initState) box.registerCallback(key: 'toast', callback: (data) { final msg = data?['msg'] as String? ?? 'Done'; showToast(msg); }); // from event callbacks.invoke('toast', data: {'msg': 'Saved ✅'}); // cleanup (e.g., view dispose) box.unregisterCallback(key: 'toast');
Widget Building #
MastroBuilder #
Listens to one primary Basetro (plus optional listeners). It rebuilds immediately when safe
MastroBuilder(
state: box.profile,
listeners: [box.settings], // optional
shouldRebuild: (prev, next) => prev.id != next.id, // optional
builder: (state, context) => Text('Hello ${state.value.name}'),
);
state: The state object that the widget depends on.builder: A function that builds the widget based on the current state.listeners(optional): Additional states to listen to.shouldRebuild(optional): Predicate(prev, next) => boolto skip redundant rebuilds.
TagBuilder #
Rebuild only when a specific tag is fired by the box:
TagBuilder(
tag: 'refresh-notes',
box: box,
builder: (_) => NotesList(notes: box.notes.value),
);
// later
box.tag(tag: 'refresh-notes');
RebuildBoundary #
Force a subtree to rebuild via a key swap (handy for resetting animations/forms):
final boundary = RebuildBoundary();
Widget build(BuildContext context) {
return boundary.build((context, key) => AnimatedSwitcher(
key: key,
duration: kThemeChangeDuration,
child: MyForm(),
));
}
// later
boundary.trigger();
MastroScope (back‑blocking UX) #
Wrap your app (or subtree) with a MastroScope to enable back‑blocking for long‑running tasks via executeBlockPop:
MaterialApp(
home: MastroScope(
onPopScope: OnPopScope(
onPopWaitMessage: (context) {
// e.g., show a SnackBar/overlay while back is blocked
},
),
child: HomeView(),
),
);
How it works:
executeBlockPop(context, event)wraps the event in a scope that blocks the system back (viaPopScope) until the event finishes.- While blocked,
OnPopScope.onPopWaitMessageis invoked to let you show feedback (e.g., “Please wait…”).
MastroView (view glue & lifecycle) #
MastroView<T extends MastroBox> pairs a screen/page with its box and wires lifecycle/UX:
- Constructor
const MyView({super.key}) : super(box: () => MyBox());→ Local boxconst MyView({super.key});+ provider → Scoped box
- Box resolution (internal)
- Local factory provided → use that instance.
- Otherwise → resolve via
BoxProvider.of<T>(context).
- Lifecycle hooks you can override (all receive
contextandbox):initState: called when the view is initializeddispose: called when the view is disposedonResume: called when the app is resumed from backgroundonInactive: called when the app becomes inactiveonPaused: called when the app is pausedonHide: called when the app is hiddenonDetached: called when the app is detachedrebuild(context): rebuilds the view
- MastroScope integration
- If a
MastroScopewith anOnPopScopeis present above,executeBlockPopwill guard back navigation until the event completes.
- If a
FAQ #
Do sequential events of type A block type B?
No — queues are per event type.
Does SOLO block everything?
No — SOLO is per‑type: duplicates of the same SOLO type are suppressed while one runs; different SOLO types may run concurrently.
Can I await a queued sequential call?
Yes — queued sequential calls return a Future that completes when that specific queued item finishes.
Is modify only for Mastro?
No — modify is available on all Basetro containers (Lightro & Mastro).
How do I use a local box in a view?
Pass it via the super constructor: const MyView() : super(box: () => MyBox());
Examples #
Check the example folder for more detailed examples of how to use Mastro in your Flutter app.
Contributions #
Contributions are welcome! If you have any ideas, suggestions, or bug reports, please open an issue or submit a pull request on GitHub.
License #
MIT © Yousef Shaiban