dartvel 0.1.1
dartvel: ^0.1.1 copied to clipboard
Laravel-native API client for Flutter: contract envelope parsing, typed validation errors, Paginator, Sanctum token lifecycle, device registration.

A Laravel-native API client for Flutter. It speaks the gaitco/dartvel
contract: envelope parsing ({"data": ..., "message": ..., "meta": ...}),
typed exceptions built from the backend's error_code, Paginator<T> built
from Laravel's own pagination meta, a Sanctum-shaped token lifecycle, and
device registration for push notifications.
Is this the right package for you? #
This package parses one specific wire contract, not arbitrary JSON APIs.
It is the right choice if your backend is a Laravel app using the
gaitco/dartvel composer package — or one that hand-rolls
responses matching that same envelope and error_code shape.
It is not the right choice if:
- Your API has its own response envelope (a different pagination shape,
different error format, no
error_code) — this package cannot parse it, and bending your backend to match it just to use this client is backwards. Reach fordiodirectly and write your own decoding. - You need offline caching, broadcasting/realtime, file uploads, or
ready-made UI widgets. None of that exists here in v0.1 — this is a thin
HTTP + parsing layer, nothing more. (Realtime is a separate concern —
see
laravel_reverbif you need it.) - You want codegen from an OpenAPI/Scramble document. That's a planned v0.2
(
php artisan dart:generate), not shipped yet.
If your backend already speaks (or is willing to speak) the Dartvel contract, this package removes the boilerplate of envelope unwrapping, error mapping, and pagination parsing that every Laravel + Flutter app otherwise reimplements by hand.
Install #
flutter pub add dartvel
Quickstart #
final client = DartvelClient(
baseUrl: 'https://api.example.com/api',
tokenStore: SecureTokenStore(),
localeResolver: () => 'ar',
);
final auth = AuthApi(client: client, decodeUser: User.fromJson);
final user = await auth.login({'email': email, 'password': password});
final posts = await client.getPaginated(
'/posts',
page: const PageRequest(page: 1, perPage: 20),
decodeItem: Post.fromJson,
);
try {
await client.post('/posts', body: draft.toJson(), decode: Post.fromJson);
} on ValidationException catch (e) {
formErrors.value = e.errors;
}
A full, dart analyze-clean version of this snippet (with stub User/Post
models) lives in example/dartvel_example.dart.
How it fits together #

DartvelClient wraps a Dio instance. Every request:
- attaches
Accept: application/json - attaches
X-localization: <locale>iflocaleResolverreturns non-null - attaches
Authorization: Bearer <token>iftokenStoreholds one (MemoryTokenStoreby default — passSecureTokenStore()forflutter_secure_storage-backed persistence) - checks the response's
X-Contract-Versionheader: if the backend's major version doesn't match the client's, every call throwsContractViolationExceptioninstead of silently misparsing a future wire format - unwraps the envelope into
Envelope<T>(data,message,meta) viadecode, or throws a typedDartvelException
get/post/put/patch/delete all take a required decode callback and
return Future<Envelope<T>>; post/put/patch also take body.
getPaginated decodes each list item with decodeItem and returns a
Paginator<T> built from Laravel's pagination meta
(current_page/last_page/per_page/total/has_more).
Error handling #
Every non-2xx contract response ({"message": ..., "error_code": ...}) maps
to one of these typed exceptions, all subclasses of the sealed
DartvelException:
| Exception | error_code |
Typical HTTP status |
|---|---|---|
ValidationException (carries errors: Map<String, List<String>> and firstError) |
validation_failed |
422 |
UnauthenticatedException |
unauthenticated |
401 |
ForbiddenException |
forbidden |
403 |
NotFoundException |
not_found |
404 |
RateLimitedException |
rate_limited |
429 |
ServerException |
server_error, or any error_code this client doesn't recognize |
500 |
ConnectionException |
— no response reached the client (timeout, no network, DNS, etc.) | — |
ContractViolationException (carries expected) |
— response body isn't valid contract JSON, or the backend's X-Contract-Version major doesn't match this client's |
any |
DartvelException is sealed, so an exhaustive switch on it is a
compile-time guarantee — the analyzer flags a missing case the moment a new
subtype ships.
Auth: AuthApi #
final auth = AuthApi<User>(client: client, decodeUser: User.fromJson);
auth.authState.listen((status) {
// AuthStatus.unknown | .authenticated | .unauthenticated
});
await auth.restore(); // reads the token store, emits the current status
final user = await auth.login({'email': email, 'password': password});
await auth.requestOtp({'phone': phone});
final otpUser = await auth.verifyOtp({'phone': phone, 'otp': code});
final me = await auth.me();
await auth.logout(); // always clears the token store, even if the server call fails
login/verifyOtp expect the backend to return {"data": {"token": "...", "user": {...}}} — anything else throws ContractViolationException. Both
write the token to client.tokenStore and emit AuthStatus.authenticated on
authState before returning the decoded user. All auth routes default to the
/auth prefix (login, otp/verify, otp/request, me, logout); pass
prefix: to AuthApi to change it.
Device registration #
DeviceRegistrar posts to /devices (or a custom path) and retries
connection/server failures with a linear backoff (retryDelay * attempt, up
to maxAttempts). It does not depend on firebase_messaging — feed it
whatever push token your app already has:
path is appended to client's baseUrl as-is — it is not
authority-relative. DartvelClient's baseUrl already ends in /api (see
Quickstart above), and the default path: '/devices' reaches the backend
out of the box because gaitco/dartvel's own route_prefix defaults to
'api/devices' — see
laravel/README.md's Devices section
for the matching Laravel-side config.
final registrar = DeviceRegistrar(client: client);
final fcmToken = await FirebaseMessaging.instance.getToken();
await registrar.register(
uniqueId: deviceId,
fcmToken: fcmToken,
type: 'android',
locale: 'ar',
timezone: 'Africa/Cairo',
);
Only ValidationException and non-retryable DartvelExceptions propagate
immediately; ConnectionException/ServerException are retried up to
maxAttempts times before propagating.
The contract #
The wire format this package parses is defined by golden JSON fixtures in
contract/ at the repo root (envelope.json,
pagination.json, auth.json) and implemented on the Laravel side by
gaitco/dartvel. Both packages' test suites read the same
fixture files, so the two sides can't silently drift apart.
contract/VERSION holds the contract's major version. A bump there is a
breaking wire change and ships as a major version bump of both packages
together — see the root README for the
policy.