jintent 2.1.0
jintent: ^2.1.0 copied to clipboard
jintent is a Flutter package that provides an architecture for managing state changes in your application using the concept of intents.
JIntent #
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
- Testing
- Comparison
- Migration (if any)
- Roadmap (Short Term)
- Contributing
- License
Overview #
JIntent provides a simple, explicit way to:
- Represent immutable UI state (JState).
- Trigger domain actions via Intents (JIntent subclasses).
- Update state in a single, predictable point (JController).
- 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.
Testing #
Recommended strategy:
- Given initial state
- When: dispatch intent
- Await completion
- 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 (Short Term) #
- [✓] Formal concurrency policy documentation
- [✓] Logging observer utility
- ❌ Advanced examples (debounce, pagination, streaming)
- ❌ DevTool overlay (visualize intents/states)
- ❌ Undo/Redo experiment
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 featurefix: Bug fixdocs: Documentationrefactor: Internal refactor, no functional changetest: 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.
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.)