ctrl 0.1.1
ctrl: ^0.1.1 copied to clipboard
A package that allows you to control observables within scopes that are linked to the Widget Lifecycle.
Ctrl is a package that allows you to control observables within scopes that are linked to the Widget Lifecycle.
It provides a simple way to manage state in your Flutter applications using Flutter's built-in ChangeNotifier.
Disclaimer #
This package is still in a very early stage of development. While it is functional, there might be breaking changes in future releases. So please use it with caution in production applications.
Features #
- Observables: Reactive data holders that notify listeners when their value changes.
- Scopes: A mechanism to manage the lifecycle of your observables.
- Lifecycle Linkage: Automatically initializes and disposes of your scopes and observables when the Widget is created and destroyed.
- Pattern Agnostic: Does not force you to use a specific architecture.
Deep Dive: Observable #
Observable is the core of this package. It is an enhanced implementation of Flutter's ChangeNotifier that provides more power and flexibility.
Unlike a standard ChangeNotifier, an Observable:
- Holds a value and by default notifies listeners only when the value changes(You can use emitAll to notify listeners even if the value hasn't changed).
- Allows you to define custom equality logic via
changeDetector. - Can be granularly forced to notify listeners even if the value hasn't changed using
reload().
Advanced Features #
The package comes with a set of useful extensions to manipulate your data, like:
- update: Updates complex observable objects.
- transform: Creates a new observable that is derived from the original one. It updates automatically when the source changes.
- mirror: Creates a read-only view of a mutable observable.
- hotswappable: Allows you to dynamically switch the underlying source of the observable.
- filtered: (For Lists) Creates a new observable list that only contains elements that satisfy a condition.
- notNull: (For Lists) Creates a new observable list with all null values removed.
Deep Dive: Scopes #
Scopes are used to manage the lifecycle of your observables. They are automatically initialized and disposed of when the Widget is created and destroyed.
Observables have an internal scope. Dependent observables like transformed, inherit the scope of their source. So when the source is disposed, the dependent observables are disposed as well.
Installation #
Add ctrl to your pubspec.yaml:
dependencies:
ctrl: ^0.2.0
Usage #
1. Create a Ctrl class (Controller / ViewModel / Store) #
Use the Ctrl mixin to add scope management capabilities to your class. You can create observables using the mutable method.
import 'package:ctrl/ctrl.dart';
class CounterController with Ctrl {
// Create a mutable observable
late final count = mutable(0);
// Create a derived observable
late final doubleCount = count.transform((data) => data.value * 2);
void increment() {
count.value++;
}
}
You can also hide the mutable and expose only the observable:
import 'package:ctrl/ctrl.dart';
class CounterController with Ctrl {
// Create a mutable observable
late final _count = mutable(0);
Observable<int> get count => _count;
void increment() {
_count.value++;
}
}
2. Connect to a Widget #
You can use CtrlWidget or CtrlState to connect your Ctrl class to the widget lifecycle.
Choosing Between CtrlWidget and CtrlState
- Use
CtrlWidgetfor simple widgets that need exactly one controller - Use
CtrlStatewhen you need:- Multiple controllers in the same widget
- Full access to the State lifecycle (didUpdateWidget, didChangeDependencies, etc.)
- Complex logic with animations, text controllers, or other mixins
Using CtrlWidget
CtrlWidget is perfect for simple widgets with a single controller. It handles the creation and disposal of the Ctrl class for you.
The controller is passed as a parameter to the build method, making it easy to access.
Cascade State Composition: Each widget maintains its own isolated state (controller) while receiving data from parents via constructor injection. This creates a predictable, unidirectional data flow where children react to parent changes but manage their own local state independently. See example/lib/view/counter for a demo.
import 'package:flutter/material.dart';
import 'package:ctrl/ctrl.dart';
class CounterPage extends CtrlWidget<CounterController> {
const CounterPage({super.key});
@override
Widget build(BuildContext context, CounterController ctrl) {
// You can rename 'ctrl' to whatever you want, e.g., 'viewModel' or 'store'
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(
// Watch the observable for changes
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Watch(
ctrl.count,
builder: (context, value) => Text('Count: $value'),
),
Watch(
ctrl.doubleCount,
builder: (context, value) => Text('Double: $value'),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: ctrl.increment,
child: const Icon(Icons.add),
),
);
}
}
Using CtrlState
For more complex scenarios where you need full access to the State lifecycle or multiple controllers, use CtrlState.
Use the useCtrl<T>() method to register controllers. All registered controllers will have their lifecycle managed automatically.
Single Controller Example
import 'package:flutter/material.dart';
import 'package:ctrl/ctrl.dart';
class CounterPage extends StatefulWidget {
const CounterPage({super.key});
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends CtrlState<CounterPage> {
late final CounterController ctrl;
@override
void initState() {
ctrl = useCtrl();
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(
child: Watch(
ctrl.count,
builder: (context, value) => Text('Count: $value'),
),
),
floatingActionButton: FloatingActionButton(
onPressed: ctrl.increment,
child: const Icon(Icons.add),
),
);
}
}
Multiple Controllers Example
class _DashboardPageState extends CtrlState<DashboardPage> {
late final AuthController authCtrl;
late final UserController userCtrl;
late final NotificationController notificationCtrl;
late final CustomController customCtrl;
@override
void initState() {
// Register multiple controllers
authCtrl = useCtrl();
userCtrl = useCtrl();
notificationCtrl = useCtrl();
// You can also pass a controller instance directly or provide using service locator or other dependency injection strategy
customCtrl = useCtrl(CustomController(someParam: 'value'));
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
Watch(authCtrl.isAuthenticated, builder: (context, isAuth) => ...),
Watch(userCtrl.profile, builder: (context, profile) => ...),
Watch(notificationCtrl.count, builder: (context, count) => ...),
],
),
);
}
}
Update complex objects #
import 'package:ctrl/ctrl.dart';
class Product {
final int id;
final String name;
final double price;
Product({required this.id, required this.name, required this.price});
}
class CounterController with Ctrl {
// Create a mutable observable
late final product = mutable(Product(id: 1, name: 'Product 1', price: 10.0));
void updateProductPrice() {
product.update((product) {
product.price = 20.0;
});
}
}
Dependency Injection #
By default, Ctrl uses a simple built-in service locator. You can register your controllers before using them.
void main() {
// Register the controller factory
// i() is a shortcut for Locator().get(). It helps inject dependencies into the constructor.
Locator().registerFactory((i) => CounterController(repository: i()));
// You can also register other dependencies
Locator().registerSingleton((_) => CounterRepository());
runApp(const MyApp());
}
Custom Dependency Injection
You can override resolveCtrl in your CtrlWidget to use any other dependency injection solution (like GetIt, Provider, etc.).
// In CtrlWidget
class CounterPage extends CtrlWidget<CounterController> {
@override
CounterController? resolveCtrl(BuildContext context) => GetIt.I.get();
@override
Widget build(BuildContext context, CounterController ctrl) {
// ...
}
}
For CtrlState, you can pass the controller directly to useCtrl():
class _CounterPageState extends CtrlState<CounterPage> {
late final CounterController ctrl;
@override
void initState() {
ctrl = useCtrl(GetIt.I.get());
super.initState();
}
@override
Widget build(BuildContext context) {
// ...
}
}