bm_flutter_networking 0.2.0 copy "bm_flutter_networking: ^0.2.0" to clipboard
bm_flutter_networking: ^0.2.0 copied to clipboard

A Flutter networking package providing a type-safe network layer with SSL pinning, token refresh, interceptors, and connectivity monitoring.

BMFlutterNetworking #

A structured Flutter networking layer providing type-safe API requests, automatic token refresh, custom error mapping, and SSL pinning.


Table of Contents #


Installation #

dependencies:
  bm_flutter_networking: ^0.2.0

Platform Support #

Platform HTTP Requests File Upload/Download SSL Pinning
Android
iOS
macOS
Windows
Linux
Web ⚠️ throws UnsupportedError ⚠️ throws UnsupportedError

Web note: HTTP requests, cookies, and JSON decoding work fully on web. File upload via path (RequestTask.uploadFile) and performDownload() throw UnsupportedError on web — use RequestTask.uploadMultipart with Uint8List bytes for file uploads instead. SSLPinningHelper.createSecureHttpClient() is also unavailable on web (browsers enforce TLS natively).


Architecture Overview #

main.dart
  └─ registers TokenRefreshRegistry & APIErrorResponseRegistry
       │
       ▼
Target (base URL + environment config)
       │
       ▼
ModelTargetType / SuccessTargetType  (one class per endpoint)
       │
       ▼
Client  (thin wrapper that calls performResult / performAsync)
       │
       ▼
Repository / ViewModel / BLoC

Every API call flows through a request class that knows its own URL, method, headers, and parameters. The client layer is just a façade that groups related requests together and returns Result<T, APIError> to callers.


Step 1 — Define Your Targets #

A Target holds your base URL configuration. Create one subclass per distinct API origin (main API, auth API, CDN, etc.).

import 'package:bm_flutter_networking/bm_flutter_networking.dart';

// A global app environment value — set this before runApp().
late AppEnvironment appEnv;

// ── Main API target ───────────────────────────────────────────────────────────
class AppApiTarget extends Target {
  @override
  AppEnvironment get appEnvironment => appEnv;

  @override
  String get kAppScheme => 'https';

  @override
  String get kAppHost {
    switch (appEnvironment) {
      case AppEnvironment.production:
        return 'api.example.com';
      case AppEnvironment.staging:
        return 'staging.api.example.com';
      case AppEnvironment.development:
        return 'dev.api.example.com';
    }
  }

  @override
  String? get kMainAPIPath => 'api';   // https://api.example.com/api/
  
  @override
  String? get kAppApiPath => 'v1';     // https://api.example.com/api/v1/
}

// ── Auth API target (different subdomain / path) ──────────────────────────────
class AuthApiTarget extends Target {
  @override
  AppEnvironment get appEnvironment => appEnv;

  @override
  String get kAppScheme => 'https';

  @override
  String get kAppHost => 'auth.example.com';

  @override
  String? get kMainAPIPath => 'oauth';
}

How the base URL is composed:

Property Example value Result
kAppScheme https https://
kAppHost api.example.com https://api.example.com
kMainAPIPath api https://api.example.com/api/
kAppApiPath v1 https://api.example.com/api/v1/

Access the final URL via AppApiTarget().kBaseURL.


Step 2 — Create Request Classes #

Each endpoint gets its own class. Use ModelTargetType<T> when the response deserializes into a model, and SuccessTargetType when you only care whether the call succeeded.

GET with query parameters #

// Response model
class ProductList {
  final List<Product> items;
  const ProductList({required this.items});

  factory ProductList.fromJson(Map<String, dynamic> json) => ProductList(
    items: (json['items'] as List).map((e) => Product.fromJson(e)).toList(),
  );
}

// Request class
final class GetProducts extends ModelTargetType<ProductList> {
  final String category;
  final int page;

  GetProducts({required this.category, this.page = 1})
      : super(decoder: ProductList.fromJson);

  @override
  String get baseURL => AppApiTarget().kBaseURL;

  @override
  HTTPMethod get requestMethod => HTTPMethod.get;

  @override
  String get requestPath => 'products';

  @override
  RequestTask get requestTask => RequestTask.parameters({
    'category': category,
    'page': page,
  });
}

POST with JSON body #

class AuthToken {
  final String accessToken;
  final String refreshToken;

  const AuthToken({required this.accessToken, required this.refreshToken});

  factory AuthToken.fromJson(Map<String, dynamic> json) => AuthToken(
    accessToken: json['access_token'],
    refreshToken: json['refresh_token'],
  );
}

final class LoginRequest extends ModelTargetType<AuthToken> {
  final String email;
  final String password;

  LoginRequest({required this.email, required this.password})
      : super(decoder: AuthToken.fromJson);

  @override
  String get baseURL => AuthApiTarget().kBaseURL;

  @override
  HTTPMethod get requestMethod => HTTPMethod.post;

  @override
  String get requestPath => 'token';

  @override
  RequestTask get requestTask => RequestTask.encodedBody({
    'email': email,
    'password': password,
    'grant_type': 'password',
  });
}

POST with both query params and body #

final class SearchRequest extends ModelTargetType<SearchResult> {
  final String query;
  final Map<String, dynamic> filters;

  SearchRequest({required this.query, required this.filters})
      : super(decoder: SearchResult.fromJson);

  @override
  String get baseURL => AppApiTarget().kBaseURL;

  @override
  HTTPMethod get requestMethod => HTTPMethod.post;

  @override
  String get requestPath => 'search';

  @override
  RequestTask get requestTask => RequestTask.parametersAndBody(
    {'q': query},   // query string
    filters,        // JSON body
  );
}

Void / success-only request #

final class DeleteAccountRequest extends SuccessTargetType {
  @override
  String get baseURL => AppApiTarget().kBaseURL;

  @override
  HTTPMethod get requestMethod => HTTPMethod.delete;

  @override
  String get requestPath => 'account';
}

Override fromJson instead of using a decoder #

final class GetUserProfile extends ModelTargetType<UserProfile> {
  @override
  String get baseURL => AppApiTarget().kBaseURL;

  @override
  HTTPMethod get requestMethod => HTTPMethod.get;

  @override
  String get requestPath => 'profile';

  @override
  UserProfile fromJson(Map<String, dynamic> json) => UserProfile(
    id: json['id'],
    displayName: json['display_name'],
    avatarUrl: json['avatar_url'],
  );
}

Step 3 — The Authorized Mixin #

Rather than repeating isAuthorized, auth headers, and API-key headers in every request class, define a mixin once and apply it where needed.

import 'package:bm_flutter_networking/bm_flutter_networking.dart';

mixin Authorized on TargetRequest {
  // Inject or access your token storage here (GetIt, Riverpod, etc.)
  static TokenStorage get _tokens => getIt<TokenStorage>();

  @override
  bool get isAuthorized => true;  // triggers automatic 401 → refresh → retry

  @override
  Map<String, String> get headers => {
    'x-api-key': Env.apiKey,
  };

  @override
  Map<String, String> get authHeaders {
    final token = _tokens.accessToken;
    if (token == null || token.isEmpty) return {};
    return {'Authorization': 'Bearer $token'};
  }
}

Apply it to any request class:

final class GetUserProfile extends ModelTargetType<UserProfile> with Authorized {
  GetUserProfile() : super(decoder: UserProfile.fromJson);

  @override
  String get baseURL => AppApiTarget().kBaseURL;

  @override
  HTTPMethod get requestMethod => HTTPMethod.get;

  @override
  String get requestPath => 'profile';
}

final class UpdateNotificationSettings extends SuccessTargetType with Authorized {
  final bool enabled;

  UpdateNotificationSettings({required this.enabled});

  @override
  String get baseURL => AppApiTarget().kBaseURL;

  @override
  HTTPMethod get requestMethod => HTTPMethod.patch;

  @override
  String get requestPath => 'settings/notifications';

  @override
  RequestTask get requestTask => RequestTask.encodedBody({'enabled': enabled});
}

When an authorized request receives a 401 Unauthorized response, the layer automatically:

  1. Calls TokenRefreshRegistry.refreshToken()
  2. Rebuilds the request (picking up fresh tokens from authHeaders)
  3. Retries once

Step 4 — Build a Client Layer #

Clients are thin wrappers that group related requests. They translate request objects into Result<T, APIError> values that repositories and view models consume.

import 'package:injectable/injectable.dart';
import 'package:bm_flutter_networking/bm_flutter_networking.dart';

@lazySingleton
class ProductClient {
  Future<Result<ProductList, APIError>> getProducts({
    required String category,
    int page = 1,
  }) => GetProducts(category: category, page: page).performResult();

  Future<Result<Product, APIError>> getProductDetail(String id) =>
      GetProductDetail(id: id).performResult();

  Future<Result<void, APIError>> addToCart(String productId, int qty) =>
      AddToCartRequest(productId: productId, qty: qty).performResult();
}

@lazySingleton
class AuthClient {
  Future<Result<AuthToken, APIError>> login(String email, String password) =>
      LoginRequest(email: email, password: password).performResult();

  Future<Result<AuthToken, APIError>> refreshToken(String token) =>
      RefreshTokenRequest(token: token).performResult();

  Future<Result<void, APIError>> logout() =>
      LogoutRequest().performResult();
}

Consuming the result in a repository:

class ProductRepository {
  final ProductClient _client;

  ProductRepository(this._client);

  Future<void> loadProducts(String category) async {
    final result = await _client.getProducts(category: category);

    result.when(
      success: (productList) {
        // update state with productList.items
      },
      failure: (error) {
        // handle error.type, error.statusCode, error.errorModel
      },
    );
  }
}

Step 5 — Handle Errors (APIErrorResponseMapper) #

When an HTTP error response contains a JSON body with extra detail (error code, message, etc.), implement APIErrorResponseMapper to decode it into your own model. This decoded object is attached to APIError.errorModel.

// Your custom error model
class ApiErrorResponse {
  final int? code;
  final String? errorCode;
  final String? message;

  const ApiErrorResponse({this.code, this.errorCode, this.message});

  factory ApiErrorResponse.fromJson(Map<String, dynamic> json) =>
      ApiErrorResponse(
        code: json['code'],
        errorCode: json['error_code'],
        message: json['message'],
      );
}

// The mapper
class AppErrorResponseMapper implements APIErrorResponseMapper {
  @override
  dynamic decode(dynamic json) {
    if (json is Map<String, dynamic>) {
      return ApiErrorResponse.fromJson(json);
    }
    return null;
  }
}

Reading the error model after a failed request:

result.when(
  success: (_) { /* ... */ },
  failure: (error) {
    final detail = error.errorModel as ApiErrorResponse?;
    final userMessage = detail?.message ?? 'Something went wrong';
    // show userMessage
  },
);

Register it once at startup (see Step 7).


Step 6 — Token Refresh (TokenRefreshHandler) #

Implement TokenRefreshHandler to tell the layer how to obtain a new access token. The built-in mutex ensures this is called only once even if multiple authorized requests receive a 401 simultaneously.

import 'package:injectable/injectable.dart';
import 'package:bm_flutter_networking/bm_flutter_networking.dart';

@singleton
class AppTokenRefreshHandler implements TokenRefreshHandler {
  final TokenStorage _storage;
  final AuthClient _authClient;

  AppTokenRefreshHandler(this._storage, this._authClient);

  @override
  Future<bool> refreshToken() async {
    final storedRefreshToken = _storage.refreshToken;
    if (storedRefreshToken == null) return false;

    final result = await _authClient.refreshToken(storedRefreshToken);

    return result.when(
      success: (newTokens) {
        _storage.saveTokens(newTokens);   // persist fresh tokens
        return true;
      },
      failure: (_) => false,
    );
  }
}
  • Return true if the refresh succeeded — the original request will be retried with the new token from authHeaders.
  • Return false or throw if refresh failed — the original 401 error propagates to the caller.

Register it once at startup (see Step 7).


Step 7 — Initialize in main() #

Register the handlers before runApp(). Dependency injection must be set up first so the handlers can receive their injected dependencies.

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  appEnv = AppEnvironment.production; // or read from --dart-define

  // 1. Set up DI (GetIt/Injectable, Riverpod, etc.)
  await configureDependencies();

  // 2. Register the token refresh handler
  TokenRefreshRegistry.register(getIt<AppTokenRefreshHandler>());

  // 3. Register the error response mapper
  APIErrorResponseRegistry.register(AppErrorResponseMapper());

  runApp(const MyApp());
}

Order matters. TokenRefreshRegistry and APIErrorResponseRegistry must be populated before the first network call executes.


Request Types Reference #

Factory When to use
RequestTask.plain() GET with no parameters (default)
RequestTask.parameters(Map) Query string parameters
RequestTask.encodedBody(dynamic) JSON body (POST / PUT / PATCH)
RequestTask.parametersAndBody(Map, dynamic) Query params and JSON body
RequestTask.uploadFile(String path) Single binary file upload (native only)
RequestTask.uploadMultipart(Map<String, MultipartFormData>) Multipart form fields / files (all platforms)
RequestTask.download(String url) Download a file from a full URL (native only)
RequestTask.downloadResumable({int? offset}) Resumable download with Range header (native only)

Multipart upload example #

final class UploadAvatarRequest extends SuccessTargetType with Authorized {
  final Uint8List imageBytes;
  final String filename;

  UploadAvatarRequest({required this.imageBytes, required this.filename});

  @override
  String get baseURL => AppApiTarget().kBaseURL;

  @override
  HTTPMethod get requestMethod => HTTPMethod.post;

  @override
  String get requestPath => 'profile/avatar';

  @override
  RequestTask get requestTask => RequestTask.uploadMultipart({
    'avatar': MultipartFormDataData(
      data: imageBytes,
      fileName: filename,
      mimeType: 'image/jpeg',
    ),
    'description': const MultipartFormDataText('Profile picture'),
  });
}

Response Methods Reference #

All response methods are available on both ModelTargetType and SuccessTargetType.

Method Returns Throws on error
performAsync<T>() T Yes — throws APIError
performResult<T>() Result<T, APIError> No — wraps in Failure
performAsyncWithCookies<T>() NetworkResponse<T> Yes — throws APIError
performResultWithCookies<T>() Result<NetworkResponse<T>, APIError> No — wraps in Failure
performDownload() DownloadedFile? Yes — throws APIError (native only)
performDownloadResult() Result<DownloadedFile?, APIError> No — wraps in Failure (native only)

Prefer performResult / performDownloadResult in production code — they never throw, and the Result type forces you to handle both outcomes at compile time.

// ✅ Recommended — compile-safe, no try/catch needed
final result = await GetProducts(category: 'electronics').performResult<ProductList>();
result.when(
  success: (list) => print(list.items.length),
  failure: (err) => print(err.type),
);

// ⚠️ Only use performAsync when you're intentionally letting errors propagate
try {
  final list = await GetProducts(category: 'electronics').performAsync<ProductList>();
} on APIError catch (e) {
  print(e.statusCode);
}

APIError fields #

Field Type Description
type APIErrorType .noNetwork, .httpError, .dataConversionFailed, .invalidURL, …
statusCode HTTPStatusCode? .success, .notAuthorize, .notFound, .clientError, .serverError
errorModel dynamic Decoded by APIErrorResponseMapper, cast to your error model

Working with Cookies #

Use the WithCookies variants when you need to read Set-Cookie headers from the response (e.g. session management).

Cookies are represented by the platform-agnostic BMCookie class (works on all platforms including web).

final response = await LoginRequest(email: email, password: password)
    .performAsyncWithCookies<AuthToken>();

final token = response.data;              // the decoded AuthToken
final List<BMCookie> cookies = response.cookies;
final sessionCookie = cookies.firstWhere(
  (c) => c.name == 'session',
  orElse: () => BMCookie(name: '', value: ''),
);
final rawHeader = response.rawSetCookieHeader;  // original Set-Cookie string

BMCookie fields:

Field Type Description
name String Cookie name
value String Cookie value
domain String? Domain attribute
path String? Path attribute
httpOnly bool HttpOnly flag
secure bool Secure flag

File Downloads #

Native platforms only. Calling performDownload() on web throws UnsupportedError.

final class DownloadInvoiceRequest extends ModelTargetType<void> with Authorized {
  final String invoiceId;

  DownloadInvoiceRequest(this.invoiceId);

  @override
  String get baseURL => AppApiTarget().kBaseURL;

  @override
  HTTPMethod get requestMethod => HTTPMethod.get;

  @override
  String get requestPath => 'invoices/$invoiceId/pdf';

  @override
  RequestTask get requestTask =>
      RequestTask.download('https://files.example.com/invoices/$invoiceId.pdf');

  @override
  bool get useUniqueFilename => true; // avoids overwriting existing files
}
final result = await DownloadInvoiceRequest(invoiceId).performDownloadResult();

result.when(
  success: (file) {
    if (file != null) {
      print('Saved to: ${file.downloadedUrl}');
    }
  },
  failure: (error) => print('Download failed: ${error.type}'),
);

File Uploads #

Single file (native only) #

RequestTask.uploadFile reads from a file path using dart:io and throws UnsupportedError on web. Use multipart with Uint8List bytes for cross-platform file uploads.

final class UploadDocumentRequest extends SuccessTargetType with Authorized {
  final String filePath;

  UploadDocumentRequest(this.filePath);

  @override
  String get baseURL => AppApiTarget().kBaseURL;

  @override
  HTTPMethod get requestMethod => HTTPMethod.post;

  @override
  String get requestPath => 'documents';

  @override
  RequestTask get requestTask => RequestTask.uploadFile(filePath);
}

Multipart with metadata fields (all platforms) #

final class CreatePostRequest extends ModelTargetType<Post> with Authorized {
  final Uint8List imageBytes;
  final String title;
  final String body;

  CreatePostRequest({
    required this.imageBytes,
    required this.title,
    required this.body,
  }) : super(decoder: Post.fromJson);

  @override
  String get baseURL => AppApiTarget().kBaseURL;

  @override
  HTTPMethod get requestMethod => HTTPMethod.post;

  @override
  String get requestPath => 'posts';

  @override
  RequestTask get requestTask => RequestTask.uploadMultipart({
    'image': MultipartFormDataData(
      data: imageBytes,
      fileName: 'post.jpg',
      mimeType: 'image/jpeg',
    ),
    'title': MultipartFormDataText(title),
    'body': MultipartFormDataText(body),
  });
}

SSL Pinning #

Native platforms only. SSLPinningHelper.createSecureHttpClient() throws UnsupportedError on web — browsers enforce TLS certificate validation natively.

Enable SSL pinning per-request by overriding sslPinningConfiguration on your target request class.

final class GetSecureData extends ModelTargetType<SecurePayload> with Authorized {
  GetSecureData() : super(decoder: SecurePayload.fromJson);

  @override
  String get baseURL => AppApiTarget().kBaseURL;

  @override
  HTTPMethod get requestMethod => HTTPMethod.get;

  @override
  String get requestPath => 'secure/data';

  @override
  SSLPinningConfiguration get sslPinningConfiguration =>
      const SSLPinningConfiguration(
        isEnabled: true,
        allowFallback: false,
        pinnedHosts: {'api.example.com'},
        pinnedCertificatePaths: ['assets/certs/api.example.com.der'],
        pinnedPublicKeyHashes: {'sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='},
      );
}

Add your .der certificate to pubspec.yaml:

flutter:
  assets:
    - assets/certs/api.example.com.der

Logging #

The layer logs all requests and responses in debug mode automatically. You can control it manually:

Logger.isEnabled = true;   // force on (e.g. during QA)
Logger.isEnabled = false;  // force off (e.g. in tests)

In release builds, logging defaults to kDebugMode (off). Tie this to your own environment flag instead if kDebugMode doesn't line up with your build flavors (e.g. a release build against a staging environment):

Logger.isEnabled = !isProduction;

In-app Network Inspector #

Beyond console logging, the layer ships an in-app network log viewer — shake the device to open a bottom sheet listing every captured request, with search, a light/dark theme toggle, and a detail screen (real push/pop navigation, copy-to-clipboard on every section) for headers and bodies. It's fed by the same Logger.isEnabled flag, so it's on/off exactly where console logging is.

Wrap your root widget with NetworkInspectorOverlay, typically inside MaterialApp's builder:

MaterialApp(
  // ...
  builder: (context, child) => NetworkInspectorOverlay(
    enabled: !isProduction, // skip listening for shakes in production
    child: child!,
  ),
)
  • iOS — uses the native shake gesture (the same mechanism as "Shake to Undo"), so it works in the Simulator (Device > Shake Gesture) as well as on real devices.
  • Android / other platforms — falls back to accelerometer-magnitude thresholding (no OS-level shake gesture exists there); on an emulator, trigger it via Extended Controls > Virtual sensors > Accelerometer, or adb emu sensor set acceleration <x>:<y>:<z>.

The list and detail screens (NetworkLogListPage, NetworkLogDetailPage) are also exported standalone, in case you'd rather push them from your own debug menu route instead of via the shake gesture.


Network Connectivity #

Check connectivity or react to changes before making requests:

// One-shot check
final isOnline = await NetworkMonitor.isConnected;
if (!isOnline) {
  // show offline banner
}

// Stream — react as connectivity changes
NetworkMonitor.onConnectivityChanged.listen((isConnected) {
  if (isConnected) syncPendingRequests();
});

The layer checks connectivity automatically before every request and throws APIError(APIErrorType.noNetwork) when offline — your client code receives it as a Failure in the Result type without any extra connectivity checks needed.

3
likes
140
points
179
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A Flutter networking package providing a type-safe network layer with SSL pinning, token refresh, interceptors, and connectivity monitoring.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

connectivity_plus, crypto, flutter, http, http_parser, pointycastle, sensors_plus

More

Packages that depend on bm_flutter_networking

Packages that implement bm_flutter_networking