DEV Community

Cover image for Offline Development with Flutter: A Guide to Setup a scalable Mock Environment
Jozek Hajduk
Jozek Hajduk

Posted on

Offline Development with Flutter: A Guide to Setup a scalable Mock Environment

Have you ever been developing an application when the backend service stops working? Or perhaps you needed to work offline while traveling? These scenarios can completely halt your development process unless you have a robust simulation strategy.

Introduction

In this article, I’ll show you how to implement simulated remote data sources in Flutter using the get_it and injectable packages. This approach will allow you to seamlessly switch between real and simulated data sources according to your environment configuration.

The Problem

Managing simulated implementations manually can be complicated, especially in large projects with multiple developers. Not everyone on your team may be familiar with the manual configuration process, and maintaining these configurations individually by data source becomes increasingly difficult as your application grows.

This is where dependency injection comes to the rescue, specifically using the get_it and injectable packages to automate the configuration of data sources based on different environments.

Initial Setup

Before diving into the implementation, make sure you have the following packages in your pubspec.yaml:

dependencies:
  get_it:
  injectable:
  http:
dev_dependencies:
  build_runner:
  injectable_generator:

Enter fullscreen mode Exit fullscreen mode

The Solution: Environment-Based Switching

Our approach will revolve around defining different environments and injecting the appropriate implementation according to the current environment. Let’s start by setting up our environments.

Defining Custom Environments

First, let’s extend the base Environment class from the injectable package to define our custom environments:

📖 injectable comes with 3 predefined environments: qa, dev, prod. In this example we’ll create 2 custom environments, for example purposes: mock, stg

import 'package:injectable/injectable.dart';

/// 'mock' Environment
const mock = Environment(EnvironmentExtension.newMock);

/// 'stg' Environment
const stg = Environment(EnvironmentExtension.newStg);

/// List of mocked environments (environments used to get simulated data)
const mockedEnv = [EnvironmentExtension.newMock];

/// List of non-mocked environments (environments used to get remote data)
const noMockedEnv = [
  Environment.dev,
  Environment.prod,
  EnvironmentExtension.newStg,
];

extension EnvironmentExtension on Environment {
  /// preset of common environment name 'stg'
  static const newStg = 'stg';

  /// preset of common environment name 'mock'
  static const newMock = 'mock';

  /// Using the Flavor value, gets the appropriate environment used to configure
  /// get_it's 'di' instances
  static Environment getEnvByFlavor(Flavor flavor) => switch (flavor) {
    Flavor.development => dev,
    Flavor.staging => stg,
    Flavor.production => prod,
    Flavor.mock => mock,
  };
}

Enter fullscreen mode Exit fullscreen mode

📖 Flavor is a created enum that is used within the application to standardize environments

📖 The variables mock and stg are defined to be able to make individual use of the environments across layers, when we want to do it individually, through @mock and @stg decorators

Creating a Base Abstraction for the Data Source

Now, let’s create a base class that will handle common functionality for all data sources, including both HTTP requests and mock data handling:

📖 In this example we directly use the utilities from the http package, but through dependency injection, we can create and pass a separate client for better modularization

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:injectable/injectable.dart';

@lazySingleton
final class DataSourceBase {
  DataSourceBase(this._baseEndpoints);

  /// Class with the appropriate endpoints used throughout the app
  final BaseEndpoints _baseEndpoints;

  /// Map with decoded data from a JSON string
  Future<K> _decodeData<K extends Object>(String data) async =>
      json.decode(data) as K;

  /// Simulates a network request by reading data from a mock JSON string.
  ///
  /// Args:
  /// [endpoint]: An enum value representing the endpoint to get data from
  /// [mapper]: A function that converts the JSON data into an appropriate object
  /// [fakeLoadingTime]: Simulated network delay
  Future<T> handleMockRequest<T extends Object, K extends Object>({
    required EndpointsEnum endpoint,
    required T Function(K response) mapper,
    Duration fakeLoadingTime = Durations.medium3,
  }) async {
    final endPoint = _baseEndpoints.getEndpointValue(endpoint);
    try {
      // Create fake loading time to simulate network latency
      await Future<void>.delayed(fakeLoadingTime);
      // Get data from the endpoint (which contains mock JSON)
      final fakeBEResponse = await _decodeData<K>(endPoint);
      return mapper(fakeBEResponse);
    } catch (e) {
      // Your error code...
    }
  }

  /// Real network request handling.
  ///
  /// Args:
  /// [endpoint]: An enum value representing the endpoint to get data from
  /// [mapper]: A function that converts the JSON data into an appropriate object
  Future<T> handleHttpRequest<T extends Object, K extends Object>({
    required EndpointsEnum endpoint,
    required T Function(K response) mapper,
  }) async {
    Response? response;
    try {
      final url = Uri.parse(_baseEndpoints.getEndpointValue(endpoint));
      response = await get(url);
      final data = await _decodeData<K>(response.body);
      return mapper(data);
    } catch (e) {
      // Your error code...
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

Creating Endpoint Abstractions

Next, we’ll define an abstract class for endpoints and create concrete implementations for both real and simulated environments:

import 'package:freezed_annotation/freezed_annotation.dart';

abstract base class BaseEndpoints {
  /// Endpoint to get user posts
  @mustBeOverridden
  late String getPosts;

  /// Get the appropriate endpoint value based on the enum value
  String getEndpointValue(EndpointsEnum endpoint) => switch (endpoint) {
    EndpointsEnum.getPosts => getPosts,
  };
}

Enter fullscreen mode Exit fullscreen mode

For simulated endpoints, we can add the possible BE response directly in the string:

import 'package:injectable/injectable.dart';

@LazySingleton(as: BaseEndpoints, env: mockedEnv)
final class MockLocalEndpoints extends BaseEndpoints {
  @override
  String getPosts = '''
  [
    {
      "userId": 1,
      "id": 1,
      "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
      "body": "quia et suscipit suscipit recusandae consequuntur expedita et cum reprehenderit..."
    },
    {
      "userId": 1,
      "id": 2,
      "title": "qui est esse",
      "body": "est rerum tempore vitae sequi sint nihil reprehenderit dolor beatae ea..."
    },
    {
      "userId": 1,
      "id": 3,
      "title": "ea molestias quasi exercitationem repellat qui ipsa sit aut",
      "body": "et iusto sed quo iure voluptatem occaecati omnis eligendi aut ad..."
    }
  ]
  ''';
}

Enter fullscreen mode Exit fullscreen mode

📖 Note that the injectable decorator we use allows us to define that the class is an instance of BaseEndpoints and that we are going to use it in simulated environments, through the array definition we stipulated in EnvironmentExtension

For real HTTP endpoints, we can only define the endpoint we are going to use:

import 'package:injectable/injectable.dart';

@LazySingleton(as: BaseEndpoints, env: noMockedEnv)
final class HttpEndpointsV1 extends BaseEndpoints {
  @override
  String getPosts = 'https://jsonplaceholder.typicode.com/posts';
}

Enter fullscreen mode Exit fullscreen mode

Implementing Specific Data Sources

Now, let’s create specific implementations for our posts, one for each environment.

📖 Let’s assume we have a PostsDataSource interface that only has a getPosts() method defined. Also we have a model class call PostDTO and an entity called Post with the proper value from the BE response

For the simulated environment, we again define in the LazySingleton decorator the environments for which this class will be injected, and use the handleMockRequest method previously defined in the DataSourceBase class:

import 'package:injectable/injectable.dart';

@LazySingleton(as: PostsDataSource, env: mockedEnv)
final class MockPostsDataSourceImpl implements PostsDataSource {
  MockPostsDataSourceImpl(this._dataSourceBase);

  /// Base data source that handles actions with Http clients
  final DataSourceBase _dataSourceBase;

  @override
  Future<List<PostDTO>> getPosts() =>
    _dataSourceBase.handleMockRequest<List<PostDTO>, List<dynamic>>(
      endpoint: EndpointsEnum.getPosts,
      mapper: (data) {
        return data
          .map((e) => PostDTO.fromJson(e as Map<String, dynamic>))
          .toList();
      },
    );
}

Enter fullscreen mode Exit fullscreen mode

For real environments, we again define the LazySingleton decorator, but this time with non-simulated environments (for which this class will be injected), and use the handleHttpRequest method previously defined in the DataSourceBase class:

import 'package:injectable/injectable.dart';

@LazySingleton(as: PostsDataSource, env: noMockedEnv)
final class PostsDataSourceImpl implements PostsDataSource {
  PostsDataSourceImpl(this._dataSourceBase);

  /// Base data source that handles actions with Http clients
  final DataSourceBase _dataSourceBase;

  @override
  Future<List<PostDTO>> getPosts() =>
    _dataSourceBase.handleHttpRequest<List<PostDTO>, List<dynamic>>(
      endpoint: EndpointsEnum.getPosts,
      mapper: (data) {
        return data
          .map((e) => PostDTO.fromJson(e as Map<String, dynamic>))
          .toList();
      },
    );
}
Enter fullscreen mode Exit fullscreen mode

📖 A big advantage of endpoint definitions based on enum is to give the responsibility to BaseEndpoints to return the data defined in the concrete implementation of BaseEndpoints(Take in count that get_it inject one or another based on the env) without major modifications when increasing the number of endpoints in our project

Implementing Repositories and Notifiers for Data Flow

Once we have resolved the logic of our data source, we will create our repository and notifier, which will be responsible for completing a correct data flow from the UI layer to the Data layer.

For our repository, we will create a connection with the abstract PostsDataSource class. Later Injectable and get_it will do the necessary magic to be able to add the correct dependency:

import 'package:advance_app/features/home/home.dart';
import 'package:injectable/injectable.dart';

/// Proper implementation of the PostsRepository
@named
@LazySingleton(as: PostsRepository)
final class PostsRepositoryImpl implements PostsRepository {
  PostsRepositoryImpl(this._postsDataSource);

  /// Data source to interact with user post
  final PostsDataSource _postsDataSource;

  @override
  Future<List<Post>> getPosts() async {
    final response = await _postsDataSource.getPosts();
    return response.toDomain;
  }
}

Enter fullscreen mode Exit fullscreen mode

📖 The repository decorator changes a bit with respect to the decorators of PostsDataSourceImpl and MockPostsDataSourceImpl. Since we don’t need classes according to environments, we can do the injection through the name of the instance. Later in the notifier, we will inject this instance according to the name of this implementation.

For our notifier, we will also create a connection with the abstract PostsRepository class, just as we did previously with the data source:

import 'package:advance_app/core/core.dart';
import 'package:advance_app/features/home/home.dart';
import 'package:flutter/material.dart';
import 'package:injectable/injectable.dart';

/// Notifier to handle the posts information
@injectable
final class PostsNotifier extends ChangeNotifier {
  PostsNotifier(@Named.from(PostsRepositoryImpl) this._postsRepository);

  /// Repository to interact with posts
  final PostsRepository _postsRepository;

  /// Get the posts of all users
  Future<void> getUsersPosts() async {
    // Your logic to get the data and notify
  }
}

Enter fullscreen mode Exit fullscreen mode

📖 By defining the repository implementation as @named, we can do a specific injection, using the name of the class with @Named.from()

Configuring Dependency Injection

Finally, let’s configure dependency injection with the appropriate environment:

First we define the initial point of our configuration passing the proper environment

@InjectableInit(
  initializerName: 'init',
  preferRelativeImports: false,
  asExtension: true,
)
Future<void> configureDependencies({required String environment}) async =>
  getIt.init(environment: environment);
Enter fullscreen mode Exit fullscreen mode

And also call the appropriate environment in our initial app file main_stg.dart

import 'package:flutter/material.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  const flavor = Flavor.staging;
  const appEnv = AppEnvironment(flavor: flavor);
  final diEnv = EnvironmentExtension.getEnvByFlavor(flavor);

  await configureDependencies(environment: diEnv.name);
  // More code ...
  runApp(const App(environment: appEnv));
}
Enter fullscreen mode Exit fullscreen mode

Don’t forget to run the command to build your defined dependency

dart run build_runner build --delete-conflicting-outputs
Enter fullscreen mode Exit fullscreen mode

And the output of the generated injections will look like this:

// ... Imports

// The defined environments
const String _mock = 'mock';
const String _dev = 'dev';
const String _prod = 'prod';
const String _stg = 'stg';

extension GetItInjectableX on _i174.GetIt {
  // initializes the registration of main-scope dependencies inside of GetIt
  Future<_i174.GetIt> init({
    String? environment,
    _i526.EnvironmentFilter? environmentFilter,
  }) async {
    // ... Generated code
    // ... Instance of BaseEndpoints register for _mock env
    gh.lazySingleton<_i403.BaseEndpoints>(
      () => _i998.MockLocalEndpoints(),
      registerFor: {_mock},
    );
    // ... Instance of BaseEndpoints register for _dev, _prod and _stg env
    gh.lazySingleton<_i403.BaseEndpoints>(
      () => _i643.HttpEndpointsV1(),
      registerFor: {_dev, _prod, _stg},
    );
    gh.lazySingleton<_i952.DataSourceBase>(
      () => _i952.DataSourceBase(gh<_i403.BaseEndpoints>()),
    );
    gh.lazySingleton<_i403.RemoteConfigRepository>(
      () =>
          _i140.RemoteConfigRepositoryImpl(gh<_i403.RemoteConfigDatasource>()),
      instanceName: 'RemoteConfigRepositoryImpl',
    );
    // ... Instance of PostsDataSource register for _mock env
    gh.lazySingleton<_i96.PostsDataSource>(
      () => _i165.MockPostsDataSourceImpl(gh<_i403.DataSourceBase>()),
      registerFor: {_mock},
    );
    // ... Instance of PostsDataSource register for _dev, _prod and _stg env
    gh.lazySingleton<_i96.PostsDataSource>(
      () => _i435.PostsDataSourceImpl(gh<_i403.DataSourceBase>()),
      registerFor: {_dev, _prod, _stg},
    );
    gh.lazySingleton<_i96.PostsRepository>(
      () => _i732.PostsRepositoryImpl(gh<_i96.PostsDataSource>()),
      instanceName: 'PostsRepositoryImpl',
    );
    gh.factory<_i415.PostsNotifier>(
      () => _i415.PostsNotifier(
        gh<_i96.PostsRepository>(instanceName: 'PostsRepositoryImpl'),
      ),
    );
    // ... Generated code
    return this;
  }
}

Enter fullscreen mode Exit fullscreen mode

Example in a Real App

Here’s an example of how information loads when creating our app with a mock environment and stg environment:

Information in a mock environment, called main_mock.dart (mock flavor):

MOCK example

Information in a stg environment, called main_stg.dart (stg flavor):

STG example

Advantages of This Approach

  • Uninterrupted Development: Continue developing even when the backend is down.
  • Simplified Testing: You can use such a mocked data source to test your UI with controlled and predictable data.
  • Smooth Transition: Easily switch between real and simulated data with a simple environment configuration.
  • Increased Team Efficiency: All team members can easily switch between environments and implement mocked data sources, without crucially affecting development times, centralizing the logic of obtaining mocked data.

Important Point

When using @env and @named decorators simultaneously in a class that we want to alternate between implementations, errors may arise in the injectable package when reading the appropriate instance to inject. If we want to use the @env decorator to switch between injections, we should not use the @named decorator for implementations of the same class.

Resources

get_it | Dart package

injectable | Dart package

Conclusion

Implementing simulated data sources using the get_it and injectable packages significantly improves the development workflow and allows for greater flexibility in your development process, enabling parallel work while the BE team deploys new or adjusted services.


Have you used any other strategy to handle simulated data sources in your Flutter projects? I’d love to hear about your experiences in the comments!

Top comments (0)