flutter_ioc_container

ioc_container

Manage your dependencies in the widget tree, access them from the BuildContext, and replace them with test doubles for testing.

ioc_container is a dependency injection and service location library for Dart. You can use it in Flutter as a service locator like the GetIt package. flutter_ioc_container is an extension for ioc_container that exposes the library throughout the widget tree so you can use it like Provider. It provides extension methods on BuildContext to allow you to get instances of your dependencies anywhere in the widget tree.

This accesses the CounterController to increment and grab the current value:

FloatingActionButton.extended(
    icon: const Icon(Icons.add),
    // Increment the value
    onPressed: context<CounterController>().increment,
    label: Text(
    // Display the value
    context<CounterController>().value.toString(),
    style: Theme.of(context).textTheme.headlineMedium,
    ),
),

See the ioc_container documentation for a more comprehensive guide.

Getting Started

Installing the Package

Add the following line to your pubspec.yaml file under the dependencies section:

dependencies:
  flutter_ioc_container: <latest version>

Run flutter pub get to download the dependencies.

Or, you can install the package from the command line:

flutter pub add flutter_ioc_container

Basic Usage

  • Put a CompositionRoot widget at the base of your widget tree. This propagates the container throughout the widget tree as an inherited widget.
  • Specify the configureBuild parameter to configure the container.
  • Access the dependencies throughout the widget tree via the BuildContext.
import 'package:flutter/material.dart';
import 'package:flutter_ioc_container/flutter_ioc_container.dart';

void main() {
  runApp(
    CompositionRoot.configureBuild(
      MaterialApp(
        home: Scaffold(
          body: Builder(builder: (context) => const BasicWidget()),
        ),
      ),
      (builder) => builder.addSingleton((container) => 'test'),
    ),
  );
}

class BasicWidget extends StatelessWidget {
  const BasicWidget({super.key});

  @override
  Widget build(BuildContext context) => Text(context<String>());
}

Scoping

If you need a set of dependencies that have a short life and you need to dispose of them afterward, something in the widget tree needs to hold onto a scoped container. Get a scoped container by calling context.scoped(). One approach is to put the scoped container in the State of a StatefulWidget and dispose of the contents in the dispose() method of the State.

This example creates a scoped container on didChangeDependencies. It exists for the lifespan of the state, and the resources get disposed when the widget tree disposes of this widget.

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_ioc_container/flutter_ioc_container.dart';
import 'package:ioc_container/ioc_container.dart';

class DisposableResources {
  String display = 'hello world';

  void dispose() {
    // ignore: avoid_print
    print('Disposed');
  }
}

void main() {
  runApp(
    CompositionRoot.configureBuild(
      MaterialApp(
        home: Scaffold(
          body: Builder(builder: (context) => const BasicWidget()),
        ),
      ),
      (builder) => builder.add(
        (container) => DisposableResources(),
        dispose: (service) => service.dispose(),
      ),
    ),
  );
}

class BasicWidget extends StatefulWidget {
  const BasicWidget({super.key});

  @override
  State<BasicWidget> createState() => _BasicWidgetState();
}

class _BasicWidgetState extends State<BasicWidget> {
  late final IocContainer scopedContainer;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    scopedContainer = context.scoped();
  }

  @override
  void dispose() {
    super.dispose();
    unawaited(scopedContainer.dispose());
  }

  @override
  Widget build(BuildContext context) =>
      Text(scopedContainer<DisposableResources>().display);
}

See more on scoping here.

Async Injection

If your dependency requires async initialization, you can do this using addAsync. You can use the FutureBuilder widget to render the object when it is available. Async singletons come with a guarantee of no reentrancy(async locking), so it is safe to grab the singleton anywhere in the app and the future will resolve once.

Here's an example:

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_ioc_container/flutter_ioc_container.dart';

void main() {
  runApp(
    CompositionRoot(
      compose: BuildCompose(
        (builder) => builder.addSingletonAsync(
          (container) async => Future<String>.delayed(
            const Duration(seconds: 5),
            () => 'Hello world!',
          ),
        ),
      ),
      child: MaterialApp(
        home: Scaffold(
          body: Builder(builder: (context) => const BasicAsyncWidget()),
        ),
      ),
    ),
  );
}

class BasicAsyncWidget extends StatefulWidget {
  const BasicAsyncWidget({super.key});

  @override
  State<BasicAsyncWidget> createState() => _BasicAsyncWidgetState();
}

class _BasicAsyncWidgetState extends State<BasicAsyncWidget> {
  late final Future<String> future;

  @override
  void didChangeDependencies() {
    // ignore: discarded_futures
    future = context.getAsync<String>();
    super.didChangeDependencies();
  }

  @override
  Widget build(BuildContext context) => FutureBuilder(
        // ignore: discarded_futures
        future: future,
        builder: (ctx, ss) => ss.connectionState == ConnectionState.done
            ? Text(ss.data!)
            : const CircularProgressIndicator(),
      );
}

See more on async injection here.

Replace Dependencies with Test Doubles for Testing

Pass a configureOverrides function into your root widget. This allows you to replace dependencies with test doubles for testing. See the example widget tests for a full example.

class MyApp extends StatelessWidget {
  const MyApp({
    super.key,
    this.configureOverrides,
  });

  // This allows us to override the dependencies for testing. Take a look at
  // the widget tests
  final ConfigureBuild? configureOverrides;

  @override
  Widget build(BuildContext context) => CompositionRoot(
        compose: BuildCompose(
          configureOverrides: configureOverrides,
          (builder) => builder
            // Adds a singleton CounterController to the container
            ..addSingleton(
              (container) => CounterController(),
            ),
          // [...] See the example folder of this package for a full example
        ),
      );
}

This example overrides the dependency with a MockValueNotifier:

testWidgets('Basic Smoke Test', (tester) async {
    final mockValueNotifier = MockValueNotifier();

    await tester.pumpWidget(
      MyApp(
        // This is how you substitute dependencies with test doubles
        configureOverrides: (builder) => builder
            .addSingleton<CounterController>((container) => mockValueNotifier),
      ),
    );

    // Initial value
    expect(find.text('0'), findsOneWidget);

    // Tap the button
    await tester.tap(find.byIcon(Icons.add));
    await tester.pumpAndSettle();

    // Verify value
    expect(find.text('1'), findsOneWidget);
    expect(find.text('0'), findsNothing);

    // Ensure we're using the mock dependency
    expect(mockValueNotifier.hasCalls, isTrue);
  });

See more on testing here.