deo_emerges 0.3.0
deo_emerges: ^0.3.0 copied to clipboard
Flutter networking on top of Dio. Request deduplication, typed responses, uploads and downloads with progress, caching, retry and clear errors.
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.
Upload progress is always hand-rolled. Every app writes the same FormData plus onSendProgress wiring for a profile photo, and gets the -1 content-length case wrong. upload and download take bytes and hand you progress.
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);
Uploads and downloads with progress #
await client.upload(
'/avatar',
bytes: imageBytes,
filename: 'avatar.png',
onProgress: (sent, total) => setState(() => progress = sent / total),
);
final pdf = await client.download(
'/invoices/2026-08.pdf',
onProgress: (received, total) {
if (total != -1) setState(() => progress = received / total);
},
);
Both take and return bytes, not file paths. A path means dart:io, which does not exist on web and would cost the package web and WebAssembly support. Read and write files however your platform prefers.
total is -1 until the server sends a Content-Length, so check it before dividing.
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.
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