Mastro

Mastro is a state management solution for Flutter that combines reactive programming with event handling and persistence. It provides a structured way to manage state, handle events, and persist data across app sessions.

Table of Contents

Key Features

  • 🎯 Simple State Management - Lightweight and Mastro state objects
  • πŸ”„ Reactive Updates - Efficient widget rebuilding
  • πŸ’Ύ Persistent Storage - Built-in persistence capabilities
  • πŸ“¦ MastroBox Pattern - Organized business logic and state
  • 🎭 Event Handling - Structured event processing
  • πŸ” Debug Tools - Built-in debugging capabilities
  • πŸ—οΈ Builder Widgets - Flexible widget building
  • πŸ”’ State Validation - Input validation support
  • πŸ”„ Computed States - Derived values with automatic updates
  • 🎯 Event Modes - Parallel, Sequential, and Solo event processing
  • πŸ”Œ Lifecycle Management - Built-in lifecycle hooks
  • 🎨 UI Patterns - Structured view and widget patterns

Installation

Add the following to your pubspec.yaml file:

dependencies:
  mastro: <latest_version>

Then, run flutter pub get to install the package.

1. Initialization

To use Mastro, you need to initialize it in your main.dart file. This setup ensures that all necessary components are ready before your app starts.

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await MastroInit.initialize(); // Initialize Mastro
  ...
  runApp(MaterialApp(home: MastroScope(child: YourHomeWidget())));
}

2. State Management

Mastro offers two primary ways to manage state: Lightro and Mastro. Both can handle any state, but Mastro provides additional features.

Lightro - Simple State

  • Purpose: Manage states with a straightforward approach.
  • Usage: Ideal for basic state management where you need to track a single value.
  • Example:
    final counter = 0.lightro; // Create a simple state
    
    // Update the state
    counter.value++;
    
    // Reactive UI with MastroBuilder
    MastroBuilder(
      state: counter,
      builder: (state, context) => Text('Counter: ${state.value}'),
    );
    

Mastro - Advanced State

  • Purpose: Manage states with additional features like dependencies, computed values, and validation.
  • Usage: Suitable for scenarios where state changes depend on other states or require validation.
  • Example:
    class User {
      String name;
      int age;
    
      User({required this.name, required this.age});
    }
    
    final user = User(name: 'Alice', age: 30).mastro; // Create a complex state
    
    // Modify the state without replacing the object
    user.modify((state) {
      state.value.name = 'Bob';
      state.value.age = 31;
    });
    
    // Reactive UI with MastroBuilder
    MastroBuilder(
      state: user,
      builder: (state, context) => Column(
        children: [
          Text('Name: ${state.value.name}'),
          Text('Age: ${state.value.age}'),
        ],
      ),
    );
    

Mastro Functions

  • dependsOn: Establish dependencies between states. When the dependent state changes, the current state is notified.

    dependentState.dependsOn(anotherState);
    
  • 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);
    
  • 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; // this will be accepted
    validatedState.value = -1; // this will be ignored
    
  • observe: Observe changes in the state and execute a callback when the state changes.

    observedState.observe((value) {
      print('State changed to $value');
    });
    

Differences Between Lightro and Mastro

Feature Lightro Mastro
Modify method βœ… βœ…
Dependencies ❌ βœ…
Computed states ❌ βœ…
Validation ❌ βœ…
Observers ❌ βœ…

3. Persistent Storage

Persistro Class

  • Purpose: Provides a base for persistent storage using SharedPreferences.
  • Usage: Can be used directly for custom persistence logic.
  • Example:
    // Direct usage example
    Future<void> saveCustomData(String key, String value) async {
      await Persistro.putString(key, value);
    }
    
    Future<String?> loadCustomData(String key) async {
      return await Persistro.getString(key);
    }
    

PersistroMastro and PersistroLightro

These classes extend the functionality of Mastro and Lightro by adding persistence capabilities, allowing state data to be saved and restored across app sessions.

  • PersistroLightro:

    • Purpose: Manage simple, single-value states with persistence.
    • Usage: Ideal for persisting basic settings or preferences.
    • Example:
      final isDarkMode = PersistroLightro.boolean('isDarkMode', initial: false); // Persistent boolean state
      
      // Toggle dark mode
      isDarkMode.toggle();
      
      // Reactive UI with MastroBuilder
      MastroBuilder(
        state: isDarkMode,
        builder: (state, context) => Text('Dark Mode: ${state.value ? "On" : "Off"}'),
      );
      
  • PersistroMastro:

    • Purpose: Manage complex states with persistence, including lists and maps.
    • Usage: Suitable for persisting collections or objects with multiple properties.
    • Example:
      final notes = PersistroMastro.list<Note>(
        'notes',
        initial: [],
        fromJson: (json) => Note.fromJson(json),
      );
      
      // Add a new note
      notes.modify((state) {
        state.value.add(Note(
          id: '1',
          title: 'New Note',
          content: 'This is a new note.',
          createdAt: DateTime.now(),
        ));
      });
      
      // Reactive UI with MastroBuilder
      MastroBuilder(
        state: notes,
        builder: (state, context) => ListView.builder(
          itemCount: state.value.length,
          itemBuilder: (context, index) {
            final note = state.value[index];
            return ListTile(title: Text(note.title));
          },
        ),
      );
      

4. MastroBox Pattern

MastroBox is the core container for your application's state and logic.

  • Purpose: Organize state and business logic in a structured way.
  • Usage: Extend MastroBox to create a container for your app's state and logic.
  • Example:
    class NotesBox extends MastroBox<NotesEvent> {
      final notes = PersistroMastro.list<Note>(
        'notes',
        initial: [],
        fromJson: (json) => Note.fromJson(json),
      );
    
      @override
      void init() {
        notes.debugLog();
      }
    }
    

5. BoxProviders

BoxProvider and MultiBoxProvider are used to manage the lifecycle of MastroBox instances and provide them to the widget tree.

BoxProvider

  • Purpose: Provides a single MastroBox instance to the widget tree.
  • Usage: Use BoxProvider when you need to provide a single box to a subtree.
  • Example:
    BoxProvider<NotesBox>(
      create: (_) => NotesBox(),
      child: NotesView(),
    );
    

MultiBoxProvider

  • Purpose: Provides multiple MastroBox instances to the widget tree.
  • Usage: Use MultiBoxProvider when you need to provide multiple boxes to a subtree.
  • Example:
    MultiBoxProvider(
      providers: [
        BoxProvider(create: (_) => NotesBox()),
        BoxProvider(create: (_) => AnotherBox()),
      ],
      child: MyApp(),
    );
    

6. Event Handling

Events in Mastro provide a structured way to handle actions and state changes.

  • Purpose: Define and handle events that modify the state.
  • Usage: Create event classes that extend MastroEvent and implement the implement method.
  • Example:
    sealed class NotesEvent extends MastroEvent<NotesBox> {
      const NotesEvent();
      factory NotesEvent.add(String title, String content) = _AddNoteEvent;
    }
    
    class _AddNoteEvent extends NotesEvent {
      final String title;
      final String content;
    
      _AddNoteEvent(this.title, this.content);
    
      @override
      Future<void> implement(NotesBox box, Callbacks callbacks) async {
        final note = Note(
          id: DateTime.now().millisecondsSinceEpoch.toString(),
          title: title,
          content: content,
          createdAt: DateTime.now(),
        );
        box.notes.modify((notes) => notes.value.add(note));
          
        // Notify listeners that note was added successfully
        callbacks.invoke('onNoteAdded', data: {'noteId': note.id});
      }
    }
    
    // Using callbacks when adding event
    await box.addEvent(
      NotesEvent.add('Title', 'Content'),
      callbacks: Callbacks({
        'onNoteAdded': ({data}) {
          print('Note added with ID: ${data?['noteId']}');
        },
      }),
    );
    

Event Modes

class ComplexEvent extends MastroEvent<AppBox> {
  @override
  EventRunningMode get mode => EventRunningMode.sequential;
  // Available modes:
  // - parallel (default): Multiple instances can run simultaneously
  // - sequential: Events of same type are queued
  // - solo: Only one instance can run at a time
}

7. Widget Building

Mastro provides builder widgets to create reactive UIs.

MastroBuilder

  • Purpose: Build widgets that automatically update when the state changes.
  • Usage: Use MastroBuilder to wrap widgets that depend on a state.
  • Parameters:
    • state: The state object that the widget depends on.
    • builder: A function that builds the widget based on the current state.
    • listeners (optional): A list of additional state objects to listen to. If any of these states change, the widget will rebuild.
    • shouldRebuild (optional): A function that determines whether the widget should rebuild when the state changes. It takes the previous and current state values as arguments and returns a boolean. If not provided, the widget will rebuild on every state change.
  • Example:
    MastroBuilder(
      state: counter,
      builder: (state, context) => Text('Counter: ${state.value}'),
    );
    

TagBuilder

  • Purpose: Rebuild parts of the UI by calling box.tag to trigger updates for specific tags.
  • Usage: Use TagBuilder to create widgets that needs to be rebuild when a specific tag is triggered.
  • Example:
    TagBuilder(
      tag: 'important',
      box: box,
      builder: (context) => Text('This is an important update!'),
    );
    
    // Trigger a rebuild for the 'important' tag
    box.tag('important');
    

8. MastroView Pattern

MastroView provides a structured way to create screens with lifecycle management.

  • Purpose: Manage the lifecycle of a screen and its associated state.
  • Usage: Extend MastroView to create a screen with lifecycle hooks.

Using Local Box

Create or pass a MastroBox instance directly to a MastroView super constructor.

  • Example:
    class LocalNotesView extends MastroView<NotesBox> {
      LocalNotesView({super.key}) : super(box: NotesBox());
    
      @override
      Widget build(BuildContext context, NotesBox box) {
        return Scaffold(
          appBar: AppBar(title: const Text('Local Notes')),
          body: MastroBuilder(
            state: box.notes,
            builder: (notes, context) => ListView.builder(
              itemCount: notes.value.length,
              itemBuilder: (context, index) {
                final note = notes.value[index];
                return ListTile(title: Text(note.title));
              },
            ),
          ),
        );
      }
    }
    

Using BoxProvider

Use MultiBoxProvider or BoxProvider to define MastroBox instances in the widget tree prior to the creation of the MastroView.

  • Example:
    class GlobalNotesView extends MastroView<NotesBox> {
      const GlobalNotesView({super.key});
    
      @override
      Widget build(BuildContext context, NotesBox box) {
        return Scaffold(
          appBar: AppBar(title: const Text('Global Notes')),
          body: MastroBuilder(
            state: box.notes,
            builder: (notes, context) => ListView.builder(
              itemCount: notes.value.length,
              itemBuilder: (context, index) {
                final note = notes.value[index];
                return ListTile(title: Text(note.title));
              },
            ),
          ),
        );
      }
    }
    

9. Scopes

Mastro provides a way to manage app-wide behaviors using scopes, particularly useful when handling events that block user interactions.

OnPopScope

  • Purpose: Manage user interactions during blocking events within MastroScope.
  • Usage: Use OnPopScope to define behavior when an event blocks user interactions.
  • Example:
    MaterialApp(
      home: MastroScope(
        onPopScope: OnPopScope(
          onPopWaitMessage: (context) {
            ScaffoldMessenger.of(context).showSnackBar(
              const SnackBar(content: Text('Please wait...')),
            );
          },
        ),
        child: YourHomeWidget(),
      ),
    );
    

addEventBlockPop

  • Purpose: Execute events that block user interactions until completion.
  • Usage: Use addEventBlockPop to run events that should prevent user actions until they finish.
  • Example:
    await box.addEventBlockPop(
      context,
      NotesEvent.add('New Note', 'This is a new note'),
    );
    

Global vs. Local Box Usage

  • Global Usage: Use MultiBoxProvider to define MastroBox instances that can be accessed from anywhere in the app. This is useful for app-wide settings or data that needs to be shared across multiple screens.

  • Local Usage: Pass a MastroBox instance directly to a MastroView for data that is only relevant to a particular screen or widget.

Project Structure

Mastro follows a feature-based architecture pattern that promotes organization and separation of concerns. Here's the recommended project structure:

lib/
β”œβ”€β”€ core/                     # Core functionality and configurations
β”œβ”€β”€ shared/                   # Shared resources (models, utilities, etc.)
β”‚   └── models/
└── features/                 # Feature modules
    └── notes/               # Example feature
        β”œβ”€β”€ logic/
        β”‚   β”œβ”€β”€ notes_box.dart
        β”‚   └── notes_events.dart
        └── presentation/
            β”œβ”€β”€ components/   # Feature-specific widgets
            └── notes_view.dart

Feature Structure Explanation

Each feature follows a consistent structure:

  1. Logic Layer (logic/)

    • *_box.dart: Contains the MastroBox implementation for the feature
    • *_events.dart: Defines feature-specific events
  2. Presentation Layer (presentation/)

    • *_view.dart: Main view implementation using MastroView
    • components/: Feature-specific widgets and UI components

Example Feature Implementation

// features/notes/logic/notes_box.dart
class NotesBox extends MastroBox<NotesEvent> {
  final notes = PersistroMastro.list<Note>('notes', initial: []);
}

// features/notes/logic/notes_events.dart
sealed class NotesEvent extends MastroEvent<NotesBox> {
  const NotesEvent();
  factory NotesEvent.add(Note note) = _AddNoteEvent;
}

// features/notes/presentation/notes_view.dart
class NotesView extends MastroView<NotesBox> {
  const NotesView({super.key});

  @override
  Widget build(BuildContext context, NotesBox box) {
    return Scaffold(
      appBar: AppBar(title: const Text('Notes')),
      body: MastroBuilder(
        state: box.notes,
        builder: (notes, context) => NotesListView(notes: notes.value),
      ),
    );
  }
}

This structure promotes:

  • Clear separation of concerns
  • Feature isolation
  • Easy navigation and maintenance
  • Scalable architecture
  • Reusable components

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

This project is licensed under the MIT License - see the LICENSE file for details.