A reliable mobile app handles four states in every screen: loading, error, empty, and data. The HTTP client throws on non-2xx and on network failure — the rest is a UI concern. This page covers the small helpers that keep the four states consistent and adds an exponential-backoff retry for the operations where it makes sense.
The four states
Every list and detail screen passes through these:
| State | When | What the user sees |
|---|---|---|
| Loading | First fetch in flight | Spinner or shimmer placeholder |
| Error | Network failure or non-2xx | Error message + retry button |
| Empty | 200 OK, zero rows | Friendly empty state with a primary action |
| Data | 200 OK, at least one row | The actual list/detail |
A reusable widget keeps the four states consistent:
// lib/widgets/async_view.dart
import 'package:flutter/material.dart';
class AsyncView<T> extends StatelessWidget {
final AsyncSnapshot<List<T>> snapshot;
final Widget Function(List<T>) builder;
final Widget? emptyView;
final VoidCallback? onRetry;
const AsyncView({
super.key,
required this.snapshot,
required this.builder,
this.emptyView,
this.onRetry,
});
@override
Widget build(BuildContext context) {
if (snapshot.connectionState == ConnectionState.waiting && !snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && !snapshot.hasData) {
return _ErrorView(
message: _humanize(snapshot.error),
onRetry: onRetry,
);
}
final list = snapshot.data ?? const [];
if (list.isEmpty) {
return emptyView ?? const Center(child: Text('Nothing here yet.'));
}
return builder(list);
}
String _humanize(Object? error) {
final s = error.toString();
if (s.contains('SocketException')) return 'No connection. Check your network.';
if (s.contains('TimeoutException')) return 'The server took too long. Try again.';
if (s.contains('Session expired')) return 'Your session expired. Please sign in.';
return 'Something went wrong.';
}
}
class _ErrorView extends StatelessWidget {
final String message;
final VoidCallback? onRetry;
const _ErrorView({required this.message, this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message),
if (onRetry != null) ...[
const SizedBox(height: 12),
FilledButton(onPressed: onRetry, child: const Text('Retry')),
],
],
),
);
}
}
BaseModel unwrap helpers
The HTTP client returns the raw decoded JSON. Records come back wrapped:
{ "pk": "...", "sk": "...", "data": { "name": "Acme" } }
Lists wrap each item the same way. A pair of helpers keeps services concise:
// lib/services/base_model.dart
class BaseModel {
/// Unwrap a single record. Returns the inner `data` map, augmented with
/// the BaseModel envelope fields under `__pk`, `__sk`, etc.
static Map<String, dynamic> unwrap(Map<String, dynamic> json) {
final data = json['data'];
if (data is Map<String, dynamic>) {
return {
...data,
'__pk': json['pk'],
'__sk': json['sk'],
'__version': json['version'],
'__state': json['state'],
'__createdate': json['createdate'],
'__modifydate': json['modifydate'],
};
}
return Map<String, dynamic>.from(json);
}
/// Unwrap a paginated list response. Returns the inner array.
/// Falls back across the variants the server has shipped historically.
static List<dynamic> unwrapList(dynamic response) {
if (response is List) return response;
if (response is Map) {
return (response['data'] ?? response['results'] ?? response['items'] ?? [])
as List;
}
return const [];
}
/// Total count for pagination.
static int totalOf(dynamic response, {int defaultValue = 0}) {
if (response is Map) {
final t = response['total'] ?? response['count'];
if (t is int) return t;
if (t is String) return int.tryParse(t) ?? defaultValue;
}
return defaultValue;
}
}
Use them in services:
Future<List<LeadModel>> list({int page = 1}) async {
final response = await _http.get('/crm/leads/detail', queryParams: {
'page': page.toString(),
});
return BaseModel.unwrapList(response)
.map((j) => LeadModel.fromJson(j as Map<String, dynamic>))
.toList();
}
Exception types
The client throws plain Exception with a string. For operations where you want to branch on the error type, wrap it once in your service and rethrow typed exceptions:
// lib/services/errors.dart
sealed class AppmintError implements Exception {
final String message;
const AppmintError(this.message);
@override
String toString() => message;
}
class NetworkError extends AppmintError {
const NetworkError(super.message);
}
class AuthError extends AppmintError {
const AuthError(super.message);
}
class NotFoundError extends AppmintError {
const NotFoundError(super.message);
}
class ServerError extends AppmintError {
const ServerError(super.message);
}
AppmintError translate(Object error) {
final s = error.toString();
if (s.contains('Session expired')) return AuthError(s);
if (s.contains('Not found')) return NotFoundError(s);
if (s.contains('Server error')) return ServerError(s);
if (s.contains('SocketException') || s.contains('TimeoutException')) {
return NetworkError('No connection');
}
return ServerError(s);
}
Then in services:
Future<LeadModel> getById(String id) async {
try {
final response = await _http.get('/crm/leads/detail/$id');
return LeadModel.fromJson(response);
} catch (e) {
throw translate(e);
}
}
Exponential-backoff retry
The HTTP client does not retry by default — the only auto-retry is the single token-refresh on 401. For idempotent reads that are worth retrying transparently (KPIs, dashboards, list refreshes), wrap the call:
// lib/services/retry.dart
import 'dart:async';
import 'dart:math';
Future<T> retry<T>(
Future<T> Function() fn, {
int maxAttempts = 3,
Duration baseDelay = const Duration(milliseconds: 500),
bool Function(Object error)? retryIf,
}) async {
Object lastError;
for (var attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (e) {
lastError = e;
final shouldRetry = retryIf?.call(e) ?? _isTransient(e);
if (!shouldRetry || attempt == maxAttempts - 1) rethrow;
final jitter = Random().nextInt(200);
final delay = baseDelay * pow(2, attempt) + Duration(milliseconds: jitter);
await Future.delayed(delay);
}
}
throw lastError!;
}
bool _isTransient(Object error) {
final s = error.toString();
return s.contains('SocketException') ||
s.contains('TimeoutException') ||
s.contains('Server error') ||
s.contains('502') ||
s.contains('503') ||
s.contains('504');
}
Usage:
final leads = await retry(
() => LeadsService().list(),
maxAttempts: 3,
);
Don't retry mutations (POST, PUT, DELETE) blindly — the server may have committed the first call and the second creates a duplicate. If you must retry a write, send an idempotency key in the body and let the server deduplicate:
await _http.post('/storefront/order/process/$orderNumber', body: {
'idempotencyKey': '$orderNumber-${DateTime.now().millisecondsSinceEpoch}',
});
Connectivity awareness
connectivity_plus lets you fast-fail when the device has no network:
import 'package:connectivity_plus/connectivity_plus.dart';
Future<bool> hasNetwork() async {
final result = await Connectivity().checkConnectivity();
return !result.contains(ConnectivityResult.none);
}
Skip the HTTP call entirely when offline — the request will time out 30 seconds later anyway, and the user has already lost confidence by then.
if (!await hasNetwork()) {
throw const NetworkError('You are offline');
}
Logging in production
Debug logs are on in dev (controlled by EnvironmentConfig.enableDebugLogs) and off in release. For production observability, plug a crash reporter into FlutterError.onError:
FlutterError.onError = (FlutterErrorDetails details) {
// Sentry, Crashlytics, your own pipeline.
reportToBackend(details.exception, details.stack);
FlutterError.presentError(details);
};
Don't ship verbose request/response logs to production — at minimum they leak orgIds and at worst they leak tokens and PII from response bodies.
What good error UX looks like
- Always offer a way out — a Retry button, a "Sign in again" link, a "Go home" affordance.
- Distinguish "the network failed" from "the server said no" — users can fix the first, not the second.
- Don't surface stack traces. The user doesn't care, and they can't act on them.
- Keep error copy short and human — "Couldn't load orders. Check your connection and try again." beats "Exception: SocketException: Failed host lookup."