netanchor 1.2.0 copy "netanchor: ^1.2.0" to clipboard
netanchor: ^1.2.0 copied to clipboard

unlisted

A swappable, SOLID-compliant networking layer for Flutter & Dart. Adapter pattern over Dio with refresh-token, retry, cache, pagination, and DataKey unwrapping. Zero UI coupling.

netanchor

pub version pub points license: MIT

netanchor #

A typed, swappable HTTP client facade for Dart and Flutter.

Apps depend on the facade. The facade depends on a transport adapter. The day you decide to swap Dio for package:http, you write a new adapter and change one line — every repository, interceptor, and test keeps working.


At a glance #

import 'package:netanchor/netanchor.dart';

final netAnchor = NetAnchor.builder()
  .baseUrl('https://api.example.com')
  .build();

final result = await netAnchor.get<User>('/users/me', parser: User.fromJson);

result.when(
  success: (user) => print('Hi, ${user.name}'),
  failure: (f)    => print('Failed: ${f.localizedKey}'),
);

Installation #

dependencies:
  netanchor: ^1.2.0
import 'package:netanchor/netanchor.dart';

Requires Dart >=3.3.0 and Flutter >=3.19.0. Uses Dart 3 sealed classes, switch expressions, and pattern matching — no codegen, no mirrors.

Why netanchor? #

Every Flutter team I've seen ships the same NetworkHandler over and over: five copies of try { dio.get/post/put/delete } catch (DioException), showToast and Navigator.push calls smuggled into the network layer, a static _isLoggingOut flag because 401s race each other, Dio types leaking into every repository, no retry, no cache, no proper refresh-token queue.

netanchor consolidates those into one principled architecture so you can stop rewriting them every project.

What you get #

  • Adapter pattern — Dio default, MockAdapter shipped, anything else pluggable.
  • Sealed Result<T, NetworkFailure> with eleven failure cases — no nullable bodies, no magic casts, exhaustive switch enforced by the compiler.
  • Single-flight refresh-token queue — concurrent 401s share one refresh call, then transparently retry. No _isLoggingOut flag.
  • Listener pattern — toasts, navigation, and analytics never touch the library. The library never imports package:flutter.
  • Retry with exponential backoff + jitter — and RateLimitFailure honors the Retry-After header (RFC 7231).
  • Pluggable cache with six strategies: cacheFirst, staleWhileRevalidate, cacheElseNetwork, networkOnly, networkFirst (online → fresh, offline → last known), noCache.
  • Pagination — typed Paginated<T> with offset/cursor/custom parsers.
  • DataKey — auto-unwrap { "data": {...} } envelopes; per-request override.
  • First-class progress callbacks for upload and download.
  • CancellationToken, silent: true per request, dev/prod base URLs, head() and download() helpers, ValidationFailure.fromMap, failure.isRetriable, failure.localizedKey.
  • Pure-Dart RFC 7231 HTTP-date parser — works on Flutter Web (no dart:io leakage in the core).

Quick start #

import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:netanchor/netanchor.dart';

final builder = NetAnchor.builder()
  .baseUrls(
    prod: 'https://api.example.com',
    dev:  'https://dev.example.com',
  )
  .useDev(kDebugMode)
  .dataKey(const DataKey('data'))                 // auto-unwrap envelopes
  .listener(MyAppListener())                      // toasts/navigation here
  .retryPolicy(ExponentialBackoffPolicy(maxAttempts: 3))
  .cacheStore(MemoryCacheStore());

// Per-request traffic log (`→ request`, `← response`, `✗ failure`).
// `.logger(...)` sets the *sink*; `.enableTrafficLog()` is the *switch*
// that produces the traffic — keep it debug-only so you don't leak
// request bodies into release logs.
if (kDebugMode) builder.enableTrafficLog();

final netAnchor = builder.build();

final result = await netAnchor.get<User>(
  '/users/me',
  parser: User.fromJson,
);

Handling failures #

Every verb returns Future<NetworkResult<T>>, a sealed type alias for Result<T, NetworkFailure>. There is no try / catch, no nullable body, no boolean success flag.

switch (result) {
  case Success(:final data):
    showProfile(data);

  case Failure(failure: NoInternetFailure()):       showOfflineBanner();
  case Failure(failure: TimeoutFailure()):          showRetryButton();
  case Failure(failure: UnauthorizedFailure()):     /* listener navigates */;
  case Failure(failure: ForbiddenFailure()):        showLockedFeatureUI();
  case Failure(failure: NotFoundFailure()):         showEmptyState();
  case Failure(failure: ValidationFailure(:final fieldErrors)):
    for (final entry in fieldErrors.entries) {
      formKey.currentState?.invalidateField(entry.key, entry.value.first);
    }
  case Failure(failure: RateLimitFailure(:final retryAfter)):
    showRateLimitedBanner(retryAfter);
  case Failure(failure: ServerFailure()):           showServerDownBanner();
  case Failure(failure: ParseFailure()):            crashlytics.report(failure);
  case Failure(failure: CancelledFailure()):        /* user cancelled */;
  case Failure(failure: UnknownFailure()):          showGenericError();
}

Two convenience helpers on every failure:

if (failure.isRetriable) showRetryButton();
final i18nKey = failure.localizedKey;   // "no_internet", "validation_failed", ...

Constructing a result yourself #

The verbs hand you a NetworkResult<T> — but you'll also build one yourself inside repositories (wrapping the DTO into an entity), in fake data sources, and in tests. Two equivalent ways:

// 1. The variant classes directly — explicit, spells out both type args.
return Success<User, NetworkFailure>(user);
return Failure<User, NetworkFailure>(const UnauthorizedFailure());

// 2. The factory constructors — no need to repeat `NetworkFailure`,
//    and the type name matches the method's return type.
NetworkResult<User> loadUser() {
  return NetworkResult<User>.success(user);
  // ...or:
  return NetworkResult<User>.failure(const UnauthorizedFailure());
}

Both compile to the exact same object. Pick whichever reads better to you — the factories just save you from writing NetworkFailure on every line, since in netanchor the failure type is always NetworkFailure.

NoteResult.success / Result.failure are constructors, so they only help when building a result. To inspect one, you still pattern-match on the Success / Failure classes (switch, is Success<...>, result.when(...)) — a type alias can't discriminate the variant at a check site.


Authentication & refresh tokens #

AuthConfig bundles every refresh-related setting in one place:

final netAnchor = NetAnchor.builder()
  .baseUrl('https://api.example.com')
  .auth(AuthConfig(
    tokenStorage: MyKeychainTokenStorage(),
    refreshPath: '/auth/refresh',
    accessTokenPrefix: 'Bearer',
    removeAccessTokenBeforeRefresh: true,

    // Customise the refresh request
    refreshBodyBuilder: (refreshToken) => {
      'refresh_token': refreshToken,
      'grant_type': 'refresh_token',
    },
    refreshHeaders: const {'X-Client-Version': '1.0.0'},

    // Override the JSON keys if your backend uses camelCase
    refreshAccessTokenJsonKey: 'accessToken',
    refreshTokenJsonKey: 'refreshToken',

    // Hooks
    onBeforeRefreshRequest: (req) => req.copyWith(
      headers: {...req.headers, 'X-Trace-Id': uuid.v4()},
    ),
    onTokenRefreshed: (tokens) async {
      analytics.track('token_refreshed');
    },
    onRefreshFailed: (failure) async {
      analytics.track('refresh_failed', failure.message);
    },
  ))
  .build();

What happens on a 401 #

  1. The library pauses the failing request.
  2. Concurrent 401s share a single in-flight refresh future — no thundering-herd.
  3. tokenStorage.save(...) persists the new tokens.
  4. onTokenRefreshed fires for side-effects (analytics, sync).
  5. The original request retries transparently with a fresh Authorization header.
  6. If refresh itself fails: tokens are cleared, onRefreshFailed fires, listeners get onUnauthorized, the call returns UnauthorizedFailure.
  7. If the server returns 401 again after a successful refresh, the library does not loop — the auth-retry cap is one. Subsequent 401s fall through to onUnauthorized.

Refresh path bypasses interceptors — by design #

Refresh requests go through the HttpAdapter directly, not the interceptor chain (avoids accidental auth-on-auth loops). For dynamic headers on refresh requests use onBeforeRefreshRequest, not a DynamicHeaderInterceptor.

Custom refresh logic #

Need mTLS, device attestation, or anything exotic? Implement TokenRefresher and pass it through AuthConfig.refresher:

class MyRefresher extends TokenRefresher {
  @override
  Future<RefreshedTokens> refresh(String refreshToken) async {
    final tokens = await myCustomFlow(refreshToken);
    return RefreshedTokens(
      accessToken: tokens.access,
      refreshToken: tokens.refresh,
    );
  }
}

NetAnchor.builder()
  .auth(AuthConfig(tokenStorage: storage, refresher: MyRefresher()))
  .build();

Caching #

final netAnchor = NetAnchor.builder()
  .cacheStore(MemoryCacheStore())
  .build();

final products = await netAnchor.get<List<Product>>(
  '/products',
  parser: Product.fromList,
  cache: const CacheConfig(
    policy: CachePolicy.staleWhileRevalidate,
    ttl: Duration(minutes: 5),
  ),
);
Policy Reads cache? Writes cache? Triggers network?
networkOnly (default) no yes (on 2xx) always
cacheFirst yes (always) yes (on 2xx) only on miss
cacheElseNetwork yes (if fresh) yes (on 2xx) on miss or expired
staleWhileRevalidate yes (always) yes (on 2xx, in background) yes (background)
networkFirst only on offline failure (even if expired) yes (on 2xx) always
noCache no no always

networkFirst is the "online → fresh, offline → last known" strategy: it always tries the network first and returns the fresh response when the server is reachable; only a connectivity/timeout failure makes it fall back to the last cached entry (even an expired one). Other failures (4xx/5xx, parse) pass through untouched — stale cache never masks a real error.

final feed = await netAnchor.get<List<Ad>>(
  '/ads',
  parser: Ad.fromList,
  cache: const CacheConfig(policy: CachePolicy.networkFirst, ttl: Duration(days: 7)),
);
// Online: always fresh. Offline: the last successful response, if any.

cache: works on every verb (get, post, put, patch, delete). Cache keys for non-GET requests include a stable jsonEncode of the body, so two POSTs with structurally-equal payloads share the same slot without colliding with a third that sends different data.

Custom cache backend #

CacheStore is a tiny interface — wire it up to Hive, SQLite, Drift, file system, anything:

class HiveCacheStore extends CacheStore {
  HiveCacheStore(this._box);
  final Box<Map> _box;

  @override
  Future<CacheEntry?> read(String key) async {
    final raw = _box.get(key);
    if (raw == null) return null;
    return CacheEntry(
      data: raw['data'],
      savedAt: DateTime.fromMillisecondsSinceEpoch(raw['savedAt'] as int),
    );
  }

  @override
  Future<void> write(String key, CacheEntry entry) =>
      _box.put(key, {
        'data': entry.data,
        'savedAt': entry.savedAt.millisecondsSinceEpoch,
      });

  @override
  Future<void> remove(String key) => _box.delete(key);

  @override
  Future<void> clear() => _box.clear();
}

Pagination #

final result = await netAnchor.getPaginated<User>(
  '/users',
  parser: User.fromJson,
  queryParameters: {'page': 1, 'per_page': 20},
);

result.when(
  success: (page) {
    print('${page.items.length} of ${page.total}; more=${page.hasMore}');
  },
  failure: (f) => print(f),
);

The default OffsetPaginationParser reads items/data/results/ records for the list and page/per_page/total/total_pages/ has_more for the metadata. Bare arrays work too — if your API returns [ {...}, {...} ] directly, the parser treats it as a one-page result with null metadata.

For Stripe-style cursor pagination:

NetAnchor.builder()
  .paginationParser(const CursorPaginationParser())
  .build();

For an exotic shape, implement PaginationParser yourself — three lines:

class JsonApiPaginationParser extends PaginationParser {
  const JsonApiPaginationParser();
  @override
  Paginated<T> parse<T>(Object? data, ResponseParser<T> itemParser) {
    final map = data! as Map;
    return Paginated<T>(
      items: (map['data'] as List).map(itemParser).toList(),
      nextCursor: (map['links'] as Map?)?['next'] as String?,
      raw: data,
    );
  }
}

File upload (with progress) #

await netAnchor.post<UploadResult>(
  '/files',
  body: UploadFile.path(
    path: file.path,
    filename: file.name,
    contentType: 'image/jpeg',
  ),
  parser: UploadResult.fromJson,
  onSendProgress: (sent, total) {
    final pct = total > 0 ? (sent / total * 100).round() : 0;
    setState(() => _progress = pct);
  },
);

For a multipart form with mixed fields and files:

await netAnchor.post<Album>(
  '/albums',
  body: {
    'title': 'My album',
    'cover':  UploadFile.path(path: cover.path, filename: 'cover.jpg'),
    'photos': [
      for (final f in photos) UploadFile.path(path: f.path, filename: f.name),
    ],
  },
  parser: Album.fromJson,
);

Bytes (no file on disk):

await netAnchor.post<UploadResult>(
  '/files',
  body: UploadFile.bytes(
    bytes: pngBytes,
    filename: 'screenshot.png',
    contentType: 'image/png',
  ),
  parser: UploadResult.fromJson,
);

total may be -1 when the size is unknown (chunked encoding). Handle that case in your UI.


Binary downloads #

final result = await netAnchor.download(
  '/files/large.zip',
  onReceiveProgress: (received, total) {
    debugPrint('downloaded $received / $total bytes');
  },
);

result.when(
  success: (bytes) => File('./large.zip').writeAsBytes(bytes),
  failure: (f)     => print(f),
);

download() sets responseType: HttpResponseType.bytes so the body is delivered as raw List<int>, not parsed as JSON or coerced to String.


Cancellation #

final token = CancellationToken();

final future = netAnchor.get<List<Post>>(
  '/feed',
  parser: Post.fromList,
  cancellationToken: token,
);

@override
void dispose() {
  token.cancel('user_left_screen');
  super.dispose();
}

final result = await future;
// → Failure(CancelledFailure(reason: 'user_left_screen'))

The token is library-neutral. Adapters translate it into their native equivalent (Dio's CancelToken for DioAdapter).


Logging #

netanchor ships three loggers behind a NetAnchorLogger interface, so you can pick the one that fits your build flavor. Plug whichever you want into the builder with .logger(...); the same sink also feeds .enableTrafficLog(), which is the per-request → … ← … ✗ … switch.

PrettyNetAnchorLogger renders every request, response, and failure as a bordered, color-coded box. Visually equivalent to package:logger's PrettyPrinter, wired into the netanchor pipeline.

final netAnchor = NetAnchor.builder()
    .baseUrl('https://api.example.com')
    .logger(const PrettyNetAnchorLogger(printTime: true))
    .enableTrafficLog()                  // debug builds only
    .build();

Output (cyan for 2xx, yellow for 4xx, red for 5xx and transport failures):

┌─ REQUEST ───────────────────────────────────────────────
│ 12:30:40.550
├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
│ at AuthRepository.login (package:myapp/auth/repo.dart:42:5)
│ at AuthCubit.signIn (package:myapp/auth/cubit.dart:18:7)
│ at LoginPage._submit (package:myapp/ui/login_page.dart:55:9)
├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
│ 💡 → POST /api/auth/login
│ body: {
│   "email": "you@example.com"
│ }
└─────────────────────────────────────────────────────────

The caller-stack section filters out framework frames (package:netanchor/, package:dio/, package:flutter/, dart:async, …) and renders the last user-code frames in Dart's standard (package:foo/bar.dart:42:5) format — Android Studio, VS Code, and flutter logs render those as clickable links.

Knobs:

Parameter Default Purpose
colors true Wrap each line with ANSI color codes. Set false for plain CI logs or sinks that show codes as garbage.
printEmojis true Prefix the message line with 💡 / ⚠️ / ⛔.
printTime false Render an HH:mm:ss.SSS row at the top of the box.
lineLength 120 Total visible width of the box.
callerMethodCount 3 User-code frames in the caller section. 0 hides it.
excludeFromCallerTrace netanchor / dio / flutter / dart Path prefixes to drop from caller frames.
errorMethodCount 8 Frames included for error() stack traces.

Android note. Per-line print() means Android's logcat may interleave system rows (D/BufferQueueProducer, SurfaceView, …) between box rows. Open Android Studio's Logcat tab and filter by tag:flutter — the noise disappears and the box stays intact. (We tried batching the whole box into one print(); Android's ~1 KB log cap silently truncated the trailing border.)

Stdout — DevTools-friendly #

StdoutNetAnchorLogger routes through dart:developer.log, which is what Flutter DevTools' Logging panel reads and what crash reporters (Crashlytics, Sentry) pick up automatically:

.logger(const StdoutNetAnchorLogger(name: 'netanchor'))

Silent — production #

SilentNetAnchorLogger drops everything on the floor. Wire it for release builds so request bodies and tokens never end up in production logs:

.logger(cfg.isDebug
    ? const PrettyNetAnchorLogger()
    : const SilentNetAnchorLogger())

Bringing your own #

Implement NetAnchorLogger. The three core methods are required; override the optional log method too if you want titled, leveled output (otherwise the base class routes it through info / warn / error with the title inlined):

class MyLogger extends NetAnchorLogger {
  @override
  void info(String message) => myLogger.i(message);

  @override
  void warn(String message) => myLogger.w(message);

  @override
  void error(String message, [Object? error, StackTrace? stack]) =>
      myLogger.e(message, error, stack);

  // Optional — only override if you want REQUEST / RESPONSE / FAILURE
  // titles on whatever surface you're rendering to.
  @override
  void log({
    required LogSeverity level,
    required String title,
    required String message,
    Object? error,
    StackTrace? stackTrace,
  }) {
    // …
  }
}

Decoupling UI: the listener pattern #

Networking never imports flutter. UI reactions go through NetAnchorListener:

class MyAppListener extends NetAnchorListener {
  @override
  void onUnauthorized(NetworkEvent event) {
    Session.clear();
    AppRouter.go('/auth/login');
  }

  @override
  void onError(NetworkFailure failure, NetworkEvent event) {
    if (event.isSilent) return;
    Toasts.error(I18n.of(failure.localizedKey));
  }

  @override
  void onNoInternet(NetworkEvent event) {
    if (event.isSilent) return;
    Toasts.warn('You are offline'.tr());
  }

  @override
  void onRetry(int attempt, NetworkEvent event) {
    Analytics.track('http_retry', {'attempt': attempt, 'path': event.request.path});
  }
}

Multiple listeners are allowed; events fan out to all of them.

Two events for two failure modes #

onError and onNoInternet are orthogonal:

Failure source onNoInternet onError
ConnectivityChecker returned false (request never sent) ✅ fires ❌ skipped
Adapter threw noInternet mid-flight ❌ skipped ✅ fires with NoInternetFailure

If you want one hook for both, override both methods and dispatch the same UI.

Per-request silence #

For background polling, prefetching, or analytics calls, suppress UI feedback without changing the listener:

await netAnchor.get('/analytics/event', body: payload, silent: true);

In your listener, check event.isSilent before showing UI.


Testing your repositories #

MockAdapter ships in the public API. No mocktail required:

import 'package:netanchor/netanchor.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  test('login persists tokens', () async {
    final adapter = MockAdapter()
      ..when(
        method: HttpMethod.post,
        path: '/auth/login',
        bodyMatcher: (body) =>
            body is Map && body['email'] == 'a@b.c',
      ).thenReturn(HttpResponse(
        statusCode: 200,
        data: {'accessToken': 'TOKEN', 'user': {'id': 1, 'name': 'Ada'}},
        headers: const {},
      ));

    final netAnchor = NetAnchor.builder()
      .httpAdapter(adapter)
      .baseUrl('https://api.test')
      .build();

    final result = await AuthRepo(netAnchor).login('a@b.c', 'pw');

    expect(result.isSuccess, isTrue);
    expect(adapter.recordedRequests, hasLength(1));
    expect(adapter.lastRequest!.body, {'email': 'a@b.c', 'password': 'pw'});
  });
}

when(...) accepts:

  • method, path, pathContains — basic verb + URL filters.
  • bodyMatcher: (body) => bool — assert on the payload before answering.
  • headerMatcher: (headers) => bool — assert on headers.
  • queryMatcher: (query) => bool — assert on query parameters.

Then chain one of:

  • .thenReturn(response) — same response every time.
  • .thenThrow(exception)HttpAdapterException for transport failures.
  • .thenReturnInOrder([r1, r2, r3]) — cycles each response, sticks on the last. Great for retry tests.
  • .thenRespond([MockReply.success, MockReply.failure, ...]) — mix successes and exceptions.
  • .withDelay(duration) — pre-delay before answering.

For deterministic retry timing use package:fake_async. For deterministic jitter, pass a seeded Random to ExponentialBackoffPolicy.


Swapping the HTTP client #

The HttpAdapter interface is small:

abstract class HttpAdapter {
  Future<HttpResponse> send(HttpRequest request);
  void close({bool force = false});
}

A bare package:http adapter looks like:

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:netanchor/netanchor.dart';

class HttpPackageAdapter implements HttpAdapter {
  final _client = http.Client();

  @override
  Future<HttpResponse> send(HttpRequest request) async {
    final uri = Uri.parse(request.path).replace(
      queryParameters: request.queryParameters
          .map((k, v) => MapEntry(k, '$v')),
    );

    final req = http.Request(request.method.value, uri)
      ..headers.addAll(request.headers);
    if (request.body != null) req.body = jsonEncode(request.body);

    final streamed = await _client.send(req);
    final body = await streamed.stream.bytesToString();

    return HttpResponse(
      statusCode: streamed.statusCode,
      data: body.isEmpty ? null : jsonDecode(body),
      headers: streamed.headers.map((k, v) => MapEntry(k.toLowerCase(), [v])),
    );
  }

  @override
  void close({bool force = false}) => _client.close();
}

NetAnchor.builder()
  .httpAdapter(HttpPackageAdapter())
  .build();

Every interceptor, repository, listener, and test — untouched.


Architecture in one picture #

            ┌──────────────────┐
            │  Your App / VM   │
            └────────┬─────────┘
                     ▼
            ┌──────────────────┐         ┌─────────────────┐
            │     NetAnchor    │ ──────► │  listeners[]    │  ← UI hooks
            │     (facade)     │         └─────────────────┘
            └────────┬─────────┘
   ┌─────────────────┼─────────────────┬─────────────┐
   ▼                 ▼                 ▼             ▼
┌────────┐     ┌────────────┐    ┌──────────┐ ┌────────────────┐
│Inter-  │     │RetryPolicy │    │CacheStore│ │PaginationParser│
│ceptors │     └────────────┘    └──────────┘ └────────────────┘
└───┬────┘
    ▼
┌──────────────┐         ┌──────────────────┐
│ HttpAdapter  │ ──────► │ DioAdapter       │
│  (interface) │         │ MockAdapter      │
└──────────────┘         │ HttpPackageAdapter (yours)
                         └──────────────────┘

A request enters NetAnchor, walks the interceptor chain on the way out, crosses the HttpAdapter boundary, and the response walks the chain in reverse. The pipeline emits side-effects to listeners at every meaningful step. Failures are mapped into one of eleven typed cases; successes are unwrapped via the optional DataKey and parsed by your parser.

See PLAN.md for the architectural deep-dive and roadmap.


Migrating from a hand-rolled NetworkHandler #

Old New
try { dio.get(...) } catch (DioException) await netAnchor.get<T>(...) returns Result<T, NetworkFailure>
showToast inside the handler NetAnchorListener.onError
Modular.to.navigate('/login') NetAnchorListener.onUnauthorized
_isLoggingOut static flag Built-in single-flight refresh queue
Dio types in repository signatures NetAnchor (no Dio types leak)
responseType is ListMappable ? ... cast parser: T Function(Object?)

Contributing #

netanchor is not accepting external contributions at this time. The API is still settling and there's planned work in flight that would conflict with most PRs. Bug reports filed as issues are appreciated; please don't open pull requests yet.

This notice will be removed once the project opens for external contributors — watch the repo for the announcement.

License #

MIT


Author #

Mahmoud Basuony Mahmoud Basuony
Software Engineer

If netanchor saved you a few hours of boilerplate, a ⭐ on the repo is appreciated.
4
likes
0
points
51
downloads

Publisher

verified publisherbasuony.com

Weekly Downloads

A swappable, SOLID-compliant networking layer for Flutter & Dart. Adapter pattern over Dio with refresh-token, retry, cache, pagination, and DataKey unwrapping. Zero UI coupling.

Repository (GitHub)
View/report issues

Topics

#networking #http #dio #rest #api

License

unknown (license)

Dependencies

dio, flutter, meta

More

Packages that depend on netanchor