JIntent

Pub Version License Pub Points Likes Build Coverage

Lightweight, explicit Intent + State + Side Effect architecture for Flutter (MVI-inspired)

Table of Contents

  • Overview
  • Why JIntent?
  • Quick Start
  • Core Concepts
  • Architecture
  • Minimal Example
  • Side Effects
  • Concurrency & Ordering
  • Observability
  • Testing
  • Comparison
  • Migration (if any)
  • Roadmap (Short Term)
  • Contributing
  • License

Overview

JIntent provides a simple, explicit way to:

  1. Represent immutable UI state (JState).
  2. Trigger domain actions via Intents (JIntent subclasses).
  3. Update state in a single, predictable point (JController).
  4. Emit one-off side effects (navigation, dialogs, toasts) without polluting state (JEffect / side effect channels).

Goal: Clarity and testability with minimal boilerplate.

Why JIntent?

Problem in typical apps How JIntent helps
Mixed UI + logic Controller centralizes state transitions
Side effects duplicated Dedicated side effect stream/channel
Hard to test flows Intents are discrete, testable units
Race conditions (Document your chosen intent handling policy)
State mutation Immutability via copyWith patterns

Quick Start

Add dependency:

dependencies:
  jintent: ^X.Y.Z

Import:

import 'package:jintent/jintent.dart';

Create State:


@immutable
class CounterState extends JState {
  final int counter;

  const CounterState({required this.counter});

  @override
  CounterState copyWith({int? newStateCounter}) =>
      CounterState(counter: newStateCounter ?? counter);

  @override
  List<Object?> get props => [counter];

  factory CounterState.initialState() => const CounterState(counter: 0);
}

Declare Intents:

class DecrementUseCase extends JSyncUseCase<int, int> {
  @override
  Either<Exception, int> run(int currentValue) {
    final newValue = currentValue - 1;

    if (newValue < -10) {
      return Left(Exception('Value cannot be less than -10'));
    }
    return Right(newValue);
  }
}

Controller:

class CounterController extends JController<CounterState> {
  // With injection dependence
  final _getCurrentCounterValueIntent = Di.sl<GetCurrentCounterValueIntent>();
  final _incrementIntent = Di.sl<IncrementIntent>();
  final _decrementIntent = Di.sl<DecrementIntent>();

  // other common to Creation
  // final _getCurrentCounterValueIntent = GetCurrentCounterValueIntent()
  // final _incrementIntent = IncrementIntent();
  // final _decrementIntent = DecrementIntent();

  CounterController(super.initialState);

  void loadCounter() {
    intent(_getCurrentCounterValueIntent);
  }

  void increment() => intent(_incrementIntent);

  void decrement() => intent(_decrementIntent);

  @override
  void onInit() {}
}

UI:


import 'package:counter/src/presentation/counter/controllers/controller.dart';
import 'package:counter/src/presentation/counter/presentation/counter_effect_handler.dart';
import 'package:counter/src/presentation/counter/states/state.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:jintent/jintent.dart';

class CounterView extends ConsumerStatefulWidget {
  const CounterView({super.key});

  @override
  ConsumerState<ConsumerStatefulWidget> createState() => _CounterViewState();
}

class _CounterViewState extends ConsumerState<CounterView> {
  final throttler = JThrottler(const Duration(milliseconds: 200));

  late final CounterController _counterController;

  JSideEffectHandler<CounterState> get _sideEffectHandler =>
      CounterEffectHandler(_counterController);

  @override
  void initState() {
    super.initState();

    _counterController = ref.read(couterControllerProvider.notifier);
  }

  @override
  Widget build(BuildContext context) {
    final counter = ref.watch(
      couterControllerProvider.select((value) => value.counter),
    );

    return JEffectListener(
      controller: _counterController,
      handler: _sideEffectHandler,
      child: Scaffold(
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              const Text('You have pushed the button this many times:'),
              Text(
                '$counter',
                style: Theme.of(context).textTheme.headlineMedium,
              ),
            ],
          ),
        ),
        floatingActionButton: Column(
          mainAxisSize: MainAxisSize.max,
          crossAxisAlignment: CrossAxisAlignment.end,
          mainAxisAlignment: MainAxisAlignment.end,
          children: [
            FloatingActionButton.extended(
              label: const Text('Increment'),
              heroTag: 'Increment',
              onPressed: () => throttler.call(_counterController.increment),
              tooltip: 'Increment',
              icon: const Icon(Icons.exposure_plus_1_outlined),
            ),
            const SizedBox(height: 10),
            FloatingActionButton.extended(
              label: const Text('Decrement'),
              heroTag: 'Decrement',
              onPressed: () => throttler.call(_counterController.decrement),
              tooltip: 'Decrement',
              icon: const Icon(Icons.exposure_minus_1_sharp),
            ),
          ],
        ),
      ),
    );
  }
}

Core Concepts

  • JState: Immutable snapshot of UI data.
  • JIntent: User or system intention (e.g., SubmitLogin, LoadItems).
  • JController<S, I>: Receives intents, updates state, emits side effects.
  • Side Effect: One-shot event (navigation, toast, analytics).
  • Effect Channel / Stream: Decoupled delivery of ephemeral events.

Architecture

Flow (simplified): User Action -> Intent -> Controller.handle -> (New State) + (Optional Side Effect)

State updates propagate to UI via a ValueListenable/Stream. Side effects consumed once by a listener (e.g. using a StreamBuilder or dedicated hook).

⚑ Side Effects (Modern Overview)

Side effects (JEffect) model transient events (navigation, dialogs, toasts) outside of state.
Key types:

  • JEffect
  • JFireAndForgetEffect (no return value)
  • JResultEffect

Quick example:

final confirmed = await controller.emitAndWaitSideEffect(
  DeleteDialogEffect(itemName: 'File.txt'),
);
if (confirmed) {
  controller.intent(DeleteItemIntent(...));
}

If an awaited effect has no handler, the default strategy completes it silently (warnAndAutoComplete).
Configure a different strategy with:

JEffectsConfig().unhandledStrategy = UnhandledEffectStrategy.throwError;

Concurrency & Ordering

JIntent processes intents in a predictable, sequential order by default.

  • Queue (FIFO): Intents are enqueued and handled one at a time, ensuring state transitions are applied in the order they were received.
  • No Parallel Mutations: This avoids race conditions and makes state changes easy to reason about.
  • Custom Policies: Advanced users can implement custom intent handling strategies (e.g., debouncing, throttling, dropping, or merging intents) by extending the controller or using middleware.

Best practice:
Document your chosen concurrency policy in your controller, especially if you change the default behavior. This helps maintain clarity and prevents subtle bugs in complex flows.

Observability

JIntent provides production-ready observability features for monitoring, debugging, and understanding your application's behavior.

Structured JSON Logging

import 'package:jintent/jintent.dart';

void main() {
  final logger = JStructuredLogger(
    serviceName: 'my-app',
    version: '1.0.0',
    minLevel: LogLevel.info,
  );
  
  logger.info('User logged in', context: {
    'userId': '12345',
    'action': 'login',
  });
  
  runApp(MyApp());
}

All logs are output as JSON for easy parsing:

{
  "timestamp": "2025-10-15T12:34:56.789Z",
  "level": "INFO",
  "message": "User logged in",
  "service": "my-app",
  "version": "1.0.0",
  "context": {
    "userId": "12345",
    "action": "login"
  }
}

Correlation IDs

Track user actions across multiple operations:

// Wrap user actions in a correlation context
await CorrelationContext.runWithCorrelation(() async {
  await controller.dispatch(LoginIntent());
  
  // Access the correlation ID anywhere
  final id = CorrelationContext.current;
  logger.info('Processing', context: {'correlationId': id});
});

Metrics Collection

Collect operational and performance metrics:

void main() {
  // Enable metrics
  JMetrics.enable();
  JMetrics.attachToObserver();
  
  runApp(MyApp());
}

// Metrics are automatically tracked for:
// - Intent dispatches
// - State changes
// - Effect emissions
// - Execution timings

Manual metric recording:

// Counter
JMetrics.incrementCounter('user.login');

// Gauge
JMetrics.recordGauge('active.users', userCount);

// Timer
final timerId = JMetrics.startTimer('api.request');
await performOperation();
JMetrics.stopTimer(timerId);

Complete Observability Example

class LoginIntent extends JIntent<AuthState> {
  final JStructuredLogger logger;

  @override
  Future<void> onInvoke() async {
    await CorrelationContext.runWithCorrelation(() async {
      final correlatedLogger = logger.withContext(
        CorrelationContext.asContext ?? {},
      );
      
      correlatedLogger.info('Login attempt started');
      final timerId = JMetrics.startTimer('login.duration');
      
      try {
        final user = await loginUseCase.execute();
        JMetrics.incrementCounter('login.success');
        JMetrics.stopTimer(timerId, tags: {'status': 'success'});
        correlatedLogger.info('Login successful');
        
        update((state) => state.copyWith(user: user));
      } catch (e) {
        JMetrics.incrementCounter('login.failed');
        JMetrics.stopTimer(timerId, tags: {'status': 'failed'});
        correlatedLogger.error('Login failed', error: e);
        
        emitSideEffect(ShowErrorEffect(e.toString()));
      }
    });
  }
}

πŸ“– For comprehensive observability documentation, see docs/OBSERVABILITY_GUIDE.md

Advanced Features (Phase 4)

DevTools Overlay

Real-time monitoring of JIntent operations directly in your app:

MaterialApp(
  builder: (context, child) {
    return JDevToolsOverlay(
      enabled: kDebugMode,
      child: child!,
    );
  },
)

Features:

  • Real-time intent/state/effect visualization
  • Event timeline with metadata
  • Metrics dashboard
  • Toggle on/off with FAB
  • Zero performance impact when hidden

πŸ“– See docs/DEVTOOLS_POC.md

Undo/Redo (Experimental)

Add undo/redo capabilities to your controllers:

class MyController extends JController<MyState>
    with UndoRedoMixin<MyState> {
  
  MyController() : super(MyState.initial());
  
  @override
  void onInit() {
    enableUndoRedo(maxHistorySize: 50);
  }
}

// In your intent
updateWithUndo((state) => state.copyWith(value: newValue));

// Undo/redo
controller.undo();  // Returns bool
controller.redo();  // Returns bool

Alternative command pattern approach also available for fine-grained control.

Plugin Ecosystem

Extend JIntent with custom behavior:

class AnalyticsPlugin {
  void install() {
    JObserver.onIntentDispatched = (intent) {
      analytics.logEvent('intent_dispatched', 
        parameters: {'type': intent.runtimeType.toString()});
    };
  }
}

Extensibility points:

  • Observer hooks (intents, states, effects)
  • Custom dispatchers (priority, debouncing)
  • Custom effect handlers
  • Middleware pattern

πŸ“– See docs/PLUGIN_HOOKS.md

Performance

JIntent is designed for high performance:

  • Intent Processing: < 0.5ms (P50)
  • State Updates: > 10,000/sec
  • Memory Overhead: < 1KB per controller
  • Binary Size: +42KB to APK

πŸ“– See docs/PERFORMANCE.md

Testing

Recommended strategy:

  1. Given initial state
  2. When: dispatch intent
  3. Await completion
  4. Assert new state + captured side effects

Comparison (High Level)

Feature JIntent Bloc Redux Plain Riverpod
Side effects channel Yes Yes (via Bloc) Middleware needed Provider-dependent
Boilerplate Low Medium High Low
Immutable state Yes Yes Yes Depends
Intent semantics Explicit classes Events Actions Method calls
Concurrency control (Document) Per event loop Middleware Custom

Migration

If upgrading from 1.x to 2.x:

  • Update import paths.
  • Adjust side effect API rename.
  • See CHANGELOG for removed symbols. (Provide MIGRATION.md if many items.)

Roadmap

Completed βœ…

  • βœ“ Phase 0: Discovery and documentation baseline
  • βœ“ Phase 1: CI/CD, testing infrastructure, ADRs
  • βœ“ Phase 2: Security baseline, API patterns, data layer
  • βœ“ Phase 3: Structured logging, metrics, correlation IDs, integration tests
  • βœ“ Phase 4: DevTools overlay, undo/redo, performance docs, plugin hooks

Recent Additions (Phase 4)

  • βœ“ DevTools overlay for real-time monitoring
  • βœ“ Undo/redo experimental support
  • βœ“ Performance benchmarks and optimization guide
  • βœ“ Plugin hooks documentation
  • βœ“ 95%+ OWASP compliance documentation

Future Enhancements

  • Advanced examples (debounce, pagination, streaming)
  • Automated benchmark suite in CI
  • Community plugin ecosystem
  • Chrome DevTools integration

Documentation

Core Documentation

Advanced Features (Phase 4)

Observability (Phase 3)

Security & Best Practices

Code Examples

Governance & Architecture

Contributing Guidelines

Thank you for your interest in contributing to this project.
This document sets the official policies and guidelines for collaboration.

1. Communication

  • Before starting any development, please open an issue to discuss the proposal.
  • All contributions must align with the project's vision, objectives, and standards.

2. Git Workflow

  • Use branch names with the following format:

    • feature/<short-description>
    • fix/<short-description>
    • chore/<short-description>
  • Example: feature/offline-sync

  • Commit messages must follow the Conventional Commits standard:

    • feat: New feature
    • fix: Bug fix
    • docs: Documentation
    • refactor: Internal refactor, no functional change
    • test: Adding or updating tests

3. Code Quality

  • Every change must include:
    • Unit and/or integration tests.
    • An entry in CHANGELOG.md under [Unreleased].
    • Compliance with the project’s linting and formatting rules.

CI/CD Pipeline

  • All PRs trigger automated checks via GitHub Actions:
    • Code formatting (dart format)
    • Static analysis (flutter analyze)
    • Test suite with coverage report
    • Coverage threshold enforcement (β‰₯80%)
  • PRs cannot merge until all checks pass.
  • Coverage reports are available as artifacts in the workflow runs.

4. Pull Requests

  • PRs must be clear, concise, and focused only on related changes.
  • The PR description should include:
    • The problem it solves.
    • The changes introduced.
    • Instructions for testing.
  • At least one reviewer must approve the PR, and all automated checks must pass.

5. Versioning and Releases

  • We follow Semantic Versioning (SemVer):
    • MAJOR: Breaking changes.
    • MINOR: Backward-compatible new features.
    • PATCH: Bug fixes.
  • All releases must be documented in CHANGELOG.md.

6. Code of Conduct

All interactions must follow the Code of Conduct, fostering a professional, inclusive, and respectful environment.

License

MIT Β© 2025 TodoFlutter.com

FAQ

Q: How do I avoid duplicate side effects after Hot Reload?
A: Keep effects listener registration inside initState and cancel in dispose; do not re-emit past effects (channel is one-shot).

Q: Can I dispatch intents from inside another intent?
A: Prefer composing functions or scheduling a new intent after current finishes to maintain linear flow.

Q: Does JIntent support cancellation?
A: (Document if implemented; show API or mark as planned.)

Libraries

jintent