scopo 0.2.2
scopo: ^0.2.2 copied to clipboard
A Flutter package for managing scopes and dependency injection within the widget tree
scopo #
A Flutter package for managing scopes, dependency injection, and asynchronous initialization in your widget tree.
scopo simplifies the process of creating hierarchical scopes where
dependencies can be initialized, accessed, and disposed of automatically.
It handles loading states, errors, and provides a clean API for accessing
dependencies and UI state.
Features #
- Scoped Dependency Injection: easily provide and access dependencies down the widget tree.
- Asynchronous Initialization: built-in support for async dependency initialization with progress reporting.
- Lifecycle Management: automatically disposes of dependencies when the scope is closed.
- Error Handling: graceful handling of initialization errors.
- Separation of Concerns: clearly separates Dependencies (
ScopeDeps), UI Logic (ScopeContent), and Wiring (Scope).
Table of Contents #
Installation #
Add scopo to your pubspec.yaml:
dependencies:
scopo: ^0.2.2
Or run:
flutter pub add scopo
Usage #
Using scopo involves three main steps: defining your dependencies, defining
your content (UI/Logic), and creating the Scope widget.
1. Define Dependencies #
Create a class that implements ScopeDeps. This class holds your services,
repositories, etc. Implement the static init method to initialize them.
import 'package:scopo/scopo.dart';
class MyFeatureDeps implements ScopeDeps {
final ApiService apiService;
final Database db;
MyFeatureDeps({required this.apiService, required this.db});
// The initialization logic.
static Stream<ScopeInitState<double, MyFeatureDeps>> init() async* {
ApiService? apiService;
Database? db;
var _isInitialized = false;
try {
// 1. Initialize API Service.
apiService = ApiService();
await apiService.init();
yield ScopeProgress(0.5); // Report progress 50%
// 2. Initialize Database.
final db = Database();
await db.open();
yield ScopeProgress(1.0); // Report progress 100%
// 3. Ready!
yield ScopeReady(
MyFeatureDeps(
apiService: apiService,
db: db,
),
);
_isInitialized = true;
} finally {
// Clean up in case of error or cancellation.
if (!_isInitialized) {
await db?.close();
await apiService?.dispose();
}
}
}
@override
Future<void> dispose() async {
// Clean up.
await db.close();
await apiService.dispose();
}
}
2. Define Content #
Create a class that extends ScopeContent. This is where your UI logic and
state reside. It has access to the dependencies.
ScopeContent implements Listenable, so you can use it to rebuild widgets
when state changes (e.g. using ListenableBuilder or ListenableSelector).
class MyFeatureContent extends ScopeContent<MyFeatureScope, MyFeatureDeps, MyFeatureContent> {
@override
void initState() {
super.initState();
...
}
// Access dependencies via `deps`.
void fetchData() {
deps.apiService.fetch();
}
// Notify listeners to rebuild widgets that listen to this content.
void updateState() {
notifyListeners();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('My Feature')),
body: Center(child: Text('Feature Content')),
);
}
}
3. Define the Scope #
Create a widget that extends Scope. This connects the dependencies and the
content.
class MyFeatureScope extends Scope<MyFeatureScope, MyFeatureDeps, MyFeatureContent> {
const MyFeatureScope({super.key}) : super(init: MyFeatureDeps.init);
// Boilerplate for easy access.
static MyFeatureContent of(BuildContext context) =>
Scope.of<MyFeatureScope, MyFeatureDeps, MyFeatureContent>(context);
@override
MyFeatureContent createContent() => MyFeatureContent();
// Loading screen.
@override
Widget onInit(Object? progress) {
return Scaffold(
body: Center(
child: CircularProgressIndicator(
value: progress as double?,
),
),
);
}
// Error screen.
@override
Widget onError(Object error, StackTrace stackTrace) {
return Scaffold(
body: Center(
child: Text('Error: $error'),
),
);
}
@override
bool updateParamsShouldNotify(MyFeatureScope oldWidget) => false;
}
4. Use it #
Simply wrap your widget tree (or part of it) with your Scope.
void main() {
runApp(
const MaterialApp(
home: MyFeatureScope(),
),
);
}
Accessing Dependencies #
You can access the scope and its dependencies from any child widget:
// Get the content (and dependencies via content.deps)
final content = Scope.of<MyFeatureScope, MyFeatureDeps, MyFeatureContent>(context);
// Or if you added the helper method in your `Scope` class:
final deps = MyFeatureScope.of(context).deps;
Logging #
You can enable and customize logging using ScopeConfig.
void main() {
// Enable debug logs
ScopeConfig.debug.isEnabled = true;
// Enable error logs
ScopeConfig.debugError.isEnabled = true;
// Customize logger
ScopeConfig.debug.log = (source, message, error, stackTrace) {
print('[$source] $message');
};
runApp(const MyApp());
}
Utilities #
ScopeConsumer #
A mixin for State classes that provides easy access to the scope content.
class MyWidget extends StatefulWidget {
const MyWidget({super.key});
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget>
with ScopeConsumer<MyFeatureScope, MyFeatureDeps, MyFeatureContent> {
@override
Widget build(BuildContext context) {
// Access scope content via `scope`
return Text(scope.someValue);
}
}
Or:
typedef MyFeatureConsumer =
ScopeConsumer<MyFeatureScope, MyFeatureDeps, MyFeatureContent>;
class MyWidget extends StatefulWidget {
const MyWidget({super.key});
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> with MyFeatureConsumer {
@override
Widget build(BuildContext context) {
// Access scope content via `scope`
return Text(scope.someValue);
}
}
NavigationNode #
A widget that creates a nested Navigator. It allows you to include bottom
sheets, dialogs, and other screens in the current scope, ensuring they have
access to the dependencies.
final class MyFeature extends Scope<MyFeature, MyFeatureDeps, MyFeatureContent> {
const MyFeature({super.key});
...
@override
Widget wrapContent(MyFeatureDeps deps, Widget child) => NavigationNode(
onPop: (context, result) async {
await MyFeature.of(context).close();
return true;
},
child: child,
);
}
final class MyFeatureContent
extends ScopeContent<MyFeature, MyFeatureDeps, MyFeatureContent> {
String get nestedScreenTitle = 'My Nested Screen';
@override
Widget build(BuildContext context) {
return MyNestedScreen();
}
}
// Because of `NavigationNode`, this screen will be pushed to the nested
// navigator and will have access to `MyFeatureContent` and `MyFeatureDeps`.
class MyNestedScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () {
// This dialog will be pushed to the nested navigator too and will
// have access to `MyFeatureContent` and `MyFeatureDeps`.
showDialog(
context: context,
// But you must specify not to use the root navigator!
useRootNavigator: false,
builder: (context) {
// Access dependencies!
final deps = MyFeature.of(context).deps;
return AlertDialog(title: Text('Deps: $deps'));
},
);
},
child: Text(MyFeature.of(context).nestedScreenTitle),
),
),
);
}
}
DoubleProgressIterator #
A helper class to track initialization progress as a double value between 0.0 and 1.0.
static Stream<ScopeInitState<double, MyFeatureDeps>> init() async* {
final progressIterator = DoubleProgressIterator(count: 3);
// step 1
yield ScopeProgress(progressIterator.nextProgress()); // 0.33
// step 2
yield ScopeProgress(progressIterator.nextProgress()); // 0.66
// step 3
yield ScopeProgress(progressIterator.nextProgress()); // 1.0
...
}
IntProgressIterator #
A helper class to track initialization progress as an int value.
static Stream<ScopeInitState<int, MyFeatureDeps>> init() async* {
final progressIterator = IntProgressIterator(count: 3);
// step 1
yield ScopeProgress(progressIterator.nextStep()); // 1
// step 2
yield ScopeProgress(progressIterator.nextStep()); // 2
// step 3
yield ScopeProgress(progressIterator.nextStep()); // 3
...
}
ListenableListenExtension #
A helper extension that adds a listen method to Listenable, similar to
Stream.listen. It returns a ListenableSubscription that can be easily
canceled.
final scrollController = ScrollController();
final subscription = scrollController.listen(() {
// ...
});
// ...
subscription.cancel();
It also supports CompositeListenableSubscription for managing multiple
subscriptions:
final composite = CompositeListenableSubscription();
composite.add(listenable1.listen(listener1));
listenable2.listen(listener2).addTo(composite);
// ...
composite.cancel();
ListenableSelectExtension #
A helper extension that adds a select method to Listenable. It allows
listening to a specific value of the Listenable (selector), triggering the
listener only when that value changes.
final subscription = listenable.select(
(listenable) => listenable.value,
(listenable, value) => print(value),
);
By default, the previous value is compared to the new value using the
operator ==, but this behavior can be changed using compare.
final subscription = listenable.select(
(listenable) => listenable.value,
(listenable, value) => print(value),
compare: (previous, current) => identical(previous, current),
);
ListenableSelector #
A widget that rebuilds when a value selected from a Listenable changes.
ListenableSelector<MyScopeContent, int>(
listenable: scope,
selector: (scope) => scope.counter,
builder: (context, scope, counter, child) {
return Text('$counter');
},
);
Examples #
Check out the example directory for more comprehensive examples:
- minimal: A simple counter app demonstrating basic usage.
- scopo_demo: A more complex app with nested scopes, error simulation, and custom progress indicators.