deo_emerges

Flutter networking on top of Dio, with the boring parts already solved.

pub package pub points license CI


The problems this solves

Every Flutter app rebuilds the same networking layer, and gets the same four things slightly wrong.

Two widgets ask for the same thing at once. A header shows the user's avatar, a settings tile shows their name, both mount together, and GET /users/me goes out twice. deo_emerges collapses identical in-flight GETs into one request automatically. No setup.

The token expires mid-screen. Six requests get a 401 at the same moment, six refresh calls race, and one of them wins while the others invalidate the token they just got. The auth interceptor refreshes once and replays the rest.

JSON to models is written by hand, badly. response.data['user']['name'] as String scattered through the codebase, and one null crashes the screen. getJson decodes into your model and tells you clearly when it was the decoding that failed rather than the network.

The user leaves the screen and the request keeps going. Then it completes, calls setState, and throws on a disposed widget. Named cancel tokens make cancelling on dispose one line.


Install

dependencies:
  deo_emerges: ^0.2.0

Three dependencies: dio, logger, crypto. Nothing else. It does not pull in a state management library, and it never will.


Quick start

import 'package:deo_emerges/deo_emerges.dart';

final client = DeoClient(
  config: DeoConfig(baseUrl: 'https://api.example.com'),
);

final response = await client.get('/users/me');
print(response.data);

Typed responses

class User {
  User({required this.id, required this.name});
  final String id;
  final String name;

  factory User.fromJson(Map<String, dynamic> json) => User(
        id: (json['id'] ?? '') as String,
        name: (json['name'] ?? '') as String,
      );
}

final user = await client.getJson('/users/me', (json) => User.fromJson(json));
print(user.name);

If your model throws while parsing, you get a DeoError that says decoding failed. You will not spend an hour looking at the wrong layer.

Deduplication

// Two widgets, same moment, one network call.
final a = client.get('/users/me');
final b = client.get('/users/me');
await Future.wait([a, b]);

Identical path plus identical query parameters, already in flight, returns the same future. Query order does not matter: ?a=1&b=2 and ?b=2&a=1 are the same request.

GET only. POST, PUT, PATCH and DELETE change state on the server, so they are never shared.

Opt out per call when you need a genuinely fresh read:

await client.get('/feed', dedupe: false);

Concurrent requests

final results = await client.concurrent([
  () => client.get('/users/me'),
  () => client.get('/notifications'),
  () => client.get('/settings'),
]);

Cancelling

await client.get('/search', queryParameters: {'q': term}, cancelToken: 'search');

@override
void dispose() {
  client.cancelRequest('search');
  super.dispose();
}

Auth with refresh

client.addInterceptor(
  AuthInterceptor(
    getToken: () => storage.read('access_token'),
    refreshToken: () => authRepository.refresh(),
  ),
);

Caching

client.addInterceptor(CacheInterceptor(maxAge: const Duration(minutes: 5)));

Wiring it to your state tool

Version 0.2.0 removed the built-in Riverpod, Provider and Bloc adapters. They forced three competing state libraries on every install, and wiring your own takes five lines.

Riverpod

final clientProvider = Provider((ref) => DeoClient(config: DeoConfig(baseUrl: baseUrl)));

final userProvider = FutureProvider.autoDispose((ref) async {
  return ref.watch(clientProvider).getJson('/users/me', User.fromJson);
});

Bloc / Cubit

class UserCubit extends Cubit<AsyncValue<User>> {
  UserCubit(this._client) : super(const AsyncLoading());
  final DeoClient _client;

  Future<void> load() async {
    try {
      emit(AsyncData(await _client.getJson('/users/me', User.fromJson)));
    } on DeoError catch (e) {
      emit(AsyncError(e));
    }
  }
}

Provider

class UserModel extends ChangeNotifier {
  UserModel(this._client);
  final DeoClient _client;
  User? user;

  Future<void> load() async {
    user = await _client.getJson('/users/me', User.fromJson);
    notifyListeners();
  }
}

Same client, any state tool, no dependency on any of them.

Custom interceptors

client.addInterceptor(MyCustomInterceptor());

SSL pinning

final client = DeoClient(
  config: DeoConfig(
    baseUrl: 'https://api.example.com',
    validateCertificate: true,
    certificates: ['certificate1', 'certificate2'],
  ),
);

Errors

Everything throws DeoError, so you catch one type:

try {
  await client.get('/users/me');
} on DeoError catch (e) {
  print(e.message);
  print(e.statusCode);
}

Platforms

Android, iOS, web, macOS, Windows and Linux, and it is WebAssembly compatible. Pure Dart over Dio, no platform channels, no dart:io.

pub points 160 out of 160 on pub.dev.


Contributing

See CONTRIBUTING.md. Issues and pull requests are welcome.

Security

See SECURITY.md. Please report vulnerabilities privately rather than in a public issue.

License

MIT © Tisankan Jeyakumar

Libraries

deo_emerges