flutter_network_guard 0.1.2
flutter_network_guard: ^0.1.2 copied to clipboard
Network reliability toolkit for Flutter — real internet reachability, server health checks, retry with backoff, offline queue, caching and network-aware widgets. State-management agnostic.
flutter_network_guard #
A complete network reliability toolkit for Flutter — connectivity detection,
real internet reachability, server health checks, network quality scoring,
retry with backoff, request deduplication/cancellation, an offline queue,
an offline cache, and a set of network-aware widgets. Every feature is
opt-in and state-management agnostic, so it drops into a Provider, Riverpod,
BLoC, GetX, Cubit, ValueNotifier or plain setState app without asking you
to change how you already manage state.
Table of contents #
- Why this package
- What's included
- Installation
- Getting started
- Platform notes & known issues
- Core concepts
- Module-by-module guide with examples
- Full configuration reference
- Recipes
- Security & privacy notes
- Troubleshooting / FAQ
- What this package does not do
- Contributing
- License
Screenshots #
| Live demo | Online | Offline |
|---|---|---|
![]() |
![]() |
![]() |
1. Why this package #
Every mid-sized Flutter app eventually reinvents the same handful of network-handling utilities, usually badly, usually late:
- "Is the device online?" gets answered by checking
connectivity_plusalone, which only tells you whether there's a Wi-Fi or cellular link — not whether that link actually reaches the internet. Captive portals, misconfigured routers, and airline Wi-Fi login pages all report a "connected" link with zero real connectivity. - "Is the internet up?" and "is our API up?" get treated as the same question, when they're not. The rest of the internet can be fine while your backend is deploying.
- A failed request gets retried with a hand-rolled
forloop and a fixedFuture.delayed, which hammers the server the moment it comes back up instead of backing off. - A user double-taps "Place order" and two orders get created because nothing was guarding against it.
- The app goes offline mid-write, the request just throws, and the data the user typed is gone.
flutter_network_guard bundles solutions to all of the above into one
consistent, well-tested API, and lets you turn on only the parts you need.
Nothing in this package does anything until you opt into it — with zero
configuration it only tracks connectivity and performs a real internet
reachability check.
2. What's included #
| Module | Purpose |
|---|---|
| Connectivity detection | Wraps connectivity_plus, exposes a normalized ConnectivityType. |
| Internet reachability | Confirms the internet is actually reachable, with multi-endpoint fallback. |
| Server health checks | Monitors your own API/backend independently of general internet health. |
| Network quality | Latency-based excellent / good / fair / poor classification. |
| Retry engine | Fixed, linear, or exponential backoff, with jitter. |
NetworkGuard.execute() |
One call that wires together offline checks, retry, dedup, cancellation, timeout, and caching, returning a typed result. |
| Request deduplication | Prevents duplicate in-flight calls sharing a key. |
| Cancellation | Cooperative CancelToken, usable with Future.any. |
| Offline queue | Persist-and-replay queue for deferred writes, with priority and retry limits. |
| Cache | In-memory TTL cache with five interaction policies, including stale-while-revalidate. |
| Network-aware widgets | NetworkGuardBuilder, NetworkAware, NetworkStatusBanner, OfflineBanner, NetworkGuardButton, NetworkDebugPanel. |
| Logging & metrics | Off by default; opt-in structured logging and in-memory counters. |
Dio / package:http adapters |
Optional glue so your existing HTTP client short-circuits while offline. |
3. Installation #
Add the package to pubspec.yaml:
dependencies:
flutter_network_guard: ^0.1.1
Then fetch it:
flutter pub get
flutter_network_guard pulls in four dependencies on your behalf:
connectivity_plus, http, shared_preferences, and dio. You do not
need to add any of these yourself, and you do not need Dio to use the
package — it's only required if you choose to use the optional
Dio interceptor.
Minimum SDKs: Dart ^3.13.0, Flutter >=1.17.0. If your project pins
an older Dart SDK, bump environment.sdk in your pubspec.yaml before
installing.
4. Getting started #
The absolute minimum to start tracking network state:
import 'package:flutter/material.dart';
import 'package:flutter_network_guard/flutter_network_guard.dart';
Future<void> main() async {
// Required because NetworkGuard.initialize() touches platform
// channels (connectivity_plus) before runApp().
WidgetsFlutterBinding.ensureInitialized();
await NetworkGuard.initialize();
runApp(const MyApp());
}
From anywhere in your app, after initialize() has completed:
if (NetworkGuard.instance.isOnline) {
// Safe to fire requests.
}
That's the whole "quick start." Everything past this point — retry, offline queueing, caching, server health, widgets — is opt-in and explained module by module below, with a runnable example for each.
If you only take one thing from this README, take this: read
section 7.6 next. execute() is the
single call most apps end up using the most, and it ties together retry,
dedup, cancellation and caching in one place.
5. Platform notes & known issues #
This package has no platform channel code of its own — all platform
behavior comes from connectivity_plus, http, and shared_preferences,
so platform support tracks whatever those three currently support. As of
this writing, that's Android, iOS, web, Windows, macOS, and Linux. A
few things to be aware of on each:
-
Android
- No extra setup needed for connectivity/internet checks in most apps
—
connectivity_plusrequires theACCESS_NETWORK_STATEpermission, which it merges into your manifest automatically via its own plugin manifest. You do not need to add it yourself. - If your app targets Android 9 (API 28) or newer and any of your
internetCheckUrlsorServerHealthConfig.urlvalues are plainhttp://(nothttps://), the request will fail due to Android's default cleartext traffic block. Either usehttps://endpoints (recommended) or add a network security config permitting cleartext for that specific host. - Emulators without Google Play services occasionally report a
connected Wi-Fi link with no real upstream internet. This is exactly
the scenario
InternetCheckerexists to catch —hasLocalConnectionwill betruewhilehasInternetisfalse. That's correct behavior, not a bug.
- No extra setup needed for connectivity/internet checks in most apps
—
-
iOS
- No
Info.plistchanges are required forhttps://endpoints — App Transport Security (ATS) allows HTTPS by default. - If you point
internetCheckUrlsor aServerHealthConfig.urlat a plainhttp://endpoint, ATS will block it unless you add an ATS exception inInfo.plistfor that domain. As with Android, usinghttps://avoids this entirely and is strongly recommended. - iOS Simulator's network state can lag behind the host Mac's actual connectivity by a second or two after toggling Wi-Fi in System Settings — this is a Simulator quirk, not something this package can work around. Test connectivity transitions on a physical device before relying on the timing.
- No
-
Web
connectivity_pluson web reports based on the browser'snavigator.onLine, which — like Android/iOS link state — tells you there's a connection, not that the internet is reachable. Real reachability still goes throughInternetChecker, exactly as on other platforms.- Browsers enforce CORS.
InternetCheckerandServerHealthCheckerissueHEAD/GETrequests directly from the browser, so the defaultinternetCheckUrls(https://one.one.one.one,https://8.8.8.8) or any custom server-health URL must either allow cross-origin requests or you'll see the check reported as unreachable even though the endpoint is fine. In practice, pick a server-health endpoint on your own domain for web builds so CORS is under your control, and don't assume the public DNS-style defaults work identically in a browser vs. a mobile app. SharedPreferencesQueueStorageworks on web (backed bylocalStoragevia theshared_preferencesweb implementation), but storage is scoped per-origin like any other browser storage — clearing site data clears the queue.
-
Windows / Linux (desktop)
connectivity_plusdesktop support reports connectivity type with less granularity than mobile (you'll frequently seeConnectivityType.ethernetorConnectivityType.wificorrectly, but don't rely onConnectivityType.vpnorConnectivityType.bluetoothbeing detected reliably on every desktop OS version — treat those as best-effort).- No extra manifest/entitlement changes are needed for outbound HTTP(S) requests on Windows.
-
macOS
- Sandboxed macOS apps (the default for anything distributed through
the Mac App Store) need the Outgoing Connections (Client)
network entitlement, or every request this package makes —
including the internet reachability check — will fail with a
permission error that looks identical to "offline." If you're
building a sandboxed macOS app, open
macos/Runner/*.entitlementsand add:
This is a standard Flutter/macOS requirement unrelated to this package specifically, but it's the single most common "why does<key>com.apple.security.network.client</key> <true/>NetworkGuardalways say I'm offline on macOS" report.
- Sandboxed macOS apps (the default for anything distributed through
the Mac App Store) need the Outgoing Connections (Client)
network entitlement, or every request this package makes —
including the internet reachability check — will fail with a
permission error that looks identical to "offline." If you're
building a sandboxed macOS app, open
-
All platforms — background/terminated app state
NetworkGuardConfig.checkOnResumere-checks network state when the app returns to the foreground. This package does not run checks while the app is fully backgrounded or terminated on any platform — there's no background isolate or platform-specific background task wired up. If you need connectivity awareness while backgrounded, that has to be built with platform-specific background execution (WorkManager,BGTaskScheduler, etc.) outside the scope of this package.
6. Core concepts #
Before diving into individual modules, three ideas make the rest of this README click into place:
- Local connection, internet, and server health are three different
signals, kept separate on purpose.
NetworkInfoexposeshasLocalConnection,hasInternet, andserverReachableas independent fields rather than collapsing them into one boolean. A device can have Wi-Fi (hasLocalConnection: true) with no upstream internet (hasInternet: false). It can also have perfectly good internet while your API is down (serverReachable: false). Treating these as the same "offline" state produces confusing UX ("why does it say I'm offline, my Wi-Fi bars are full?") and wastes debugging time. - Everything is opt-in. Calling
NetworkGuard.initialize()with no config does exactly two things: tracks connectivity, and periodically confirms real internet reachability. It does not retry anything, queue anything, cache anything, check any server, or log anything unless you explicitly configure it to. execute()never throws. Instead oftry/catch, calls made throughNetworkGuard.instance.execute()return a sealedNetworkResult<T>—NetworkSuccess,NetworkFailure,NetworkOffline,NetworkTimeout,NetworkCancelled, orNetworkQueued— so youswitchon the outcome instead of wrapping every call site in error handling.
7. Module-by-module guide with examples #
7.1 Connectivity detection #
Read the current link-layer connection type, or listen for changes.
// One-off check, without going through NetworkGuard at all:
final service = ConnectivityService();
final type = await service.check(); // ConnectivityType.wifi, .mobile, ...
// Or, once NetworkGuard is initialized, just read the latest snapshot:
final info = NetworkGuard.instance.info;
print(info.type); // ConnectivityType
ConnectivityType values: wifi, mobile, ethernet, vpn,
bluetooth, other, none, unknown. Each has a convenience getter:
info.type.hasLocalConnection; // false only for `none`
If the platform reports more than one simultaneous connection (e.g.
Wi-Fi + VPN), ConnectivityService resolves them to a single type using
this priority order: ethernet > wifi > vpn > mobile > bluetooth > other.
7.2 Real internet reachability #
Link-layer connectivity is not internet access. InternetChecker performs
an actual outbound request to confirm it:
final checker = InternetChecker(
urls: ['https://one.one.one.one', 'https://8.8.8.8'],
timeout: const Duration(seconds: 5),
);
final result = await checker.check();
print(result.isReachable); // true/false
print(result.latency); // Duration?, present when reachable
print(result.url); // which endpoint answered (or the last one tried)
URLs are tried in order; the first one that responds — with any
status code, since the goal is confirming the network path works, not
validating the endpoint's application logic — wins. If every URL fails,
the result from the last attempt is returned so you can inspect
result.error.
Once NetworkGuard is initialized, this runs automatically on the
interval set by NetworkGuardConfig.checkInterval (default: 30 seconds),
so you rarely need to call InternetChecker directly — it's exposed for
the cases where you want a one-off check outside the regular monitoring
loop.
7.3 Server health checks #
Internet being reachable doesn't mean your backend is up. Configure one or more servers to monitor independently:
await NetworkGuard.initialize(
config: NetworkGuardConfig(
serverHealthChecks: [
ServerHealthConfig(
id: 'main-api',
url: 'https://api.example.com/health',
expectedStatusCodes: {200, 204},
timeout: const Duration(seconds: 5),
),
ServerHealthConfig(
id: 'payments',
url: 'https://payments.example.com/health',
),
],
onServerRestored: (info) => debugPrint('A monitored server came back.'),
),
);
// Read the latest snapshot for every configured server:
final health = NetworkGuard.instance.serverHealth; // Map<String, ServerHealth>
print(health['main-api']?.isReachable);
// Or run a one-off check outside the regular monitoring loop:
final snapshot = await NetworkGuard.instance.checkServer(
const ServerHealthConfig(id: 'main-api', url: 'https://api.example.com/health'),
);
Each ServerHealth snapshot carries isReachable, latency,
statusCode, lastSuccessAt (null if it has never succeeded since
monitoring started), and error.
7.4 Network quality #
A latency-based estimate, not a bandwidth measurement — treat it as a
useful signal, not ground truth. A high-latency but high-bandwidth
satellite link and a genuinely poor connection can both report poor.
final info = NetworkGuard.instance.info;
print(info.quality); // NetworkQuality.excellent / .good / .fair / .poor / .unknown
Thresholds are configurable (each is an upper bound, inclusive):
await NetworkGuard.initialize(
config: NetworkGuardConfig(
qualityThresholds: NetworkQualityThresholds(
excellent: const Duration(milliseconds: 80),
good: const Duration(milliseconds: 250),
fair: const Duration(milliseconds: 700),
// anything slower than `fair` is classified as `poor`
),
),
);
If you want a smoothed reading instead of one noisy sample, use
LatencyMonitor directly:
final monitor = LatencyMonitor(historySize: 5);
monitor.record(const Duration(milliseconds: 120));
monitor.record(const Duration(milliseconds: 340));
print(monitor.averageLatency); // average of the last 5 samples
7.5 Retry with backoff #
RetryPolicy describes how retries are spaced; RetryEngine runs the
loop. Most apps only ever touch RetryPolicy directly — RetryEngine is
used internally by execute().
const policy = RetryPolicy(
maxAttempts: 3, // includes the first (non-retry) attempt
initialDelay: Duration(seconds: 1),
maxDelay: Duration(seconds: 10),
strategy: RetryStrategy.exponential, // .fixed, .linear, or .exponential
jitter: true, // ±25% randomization, avoids retry storms
retryableStatusCodes: {408, 425, 429, 500, 502, 503, 504},
retryIf: null, // optional custom predicate, see below
);
// Delay before the retry after a given (1-indexed) attempt number:
policy.delayFor(1); // delay before the 2nd call
policy.delayFor(2); // delay before the 3rd call
Notes:
RetryPolicy.noneis a built-in constant equal tomaxAttempts: 1— use it to explicitly opt anexecute()call out of retrying.4xxclient errors (400, 401, 403, 404, 422, ...) are not in the defaultretryableStatusCodeson purpose — retrying a bad request or an auth failure rarely helps and can duplicate side effects.- Supply
retryIfto fully override the retry decision with your own logic (e.g. inspecting a custom exception type from your HTTP client):RetryPolicy( retryIf: (error) => error is SocketException || error is TimeoutException, )
7.6 Protected calls with execute() #
This is the main entry point most call sites use. It wires together an
offline check, deduplication, cancellation, retry, timeout, and — if you
pass a cache policy — caching, and returns a typed NetworkResult<T>
instead of throwing.
final result = await NetworkGuard.instance.execute<List<Order>>(
key: 'get_orders',
retryPolicy: const RetryPolicy(maxAttempts: 3),
timeout: const Duration(seconds: 10),
request: () => api.getOrders(),
);
switch (result) {
case NetworkSuccess(:final value, :final attempts, :final fromCache):
// value is List<Order>; attempts tells you how many tries it took;
// fromCache tells you if this came from CacheManager instead of a live call.
break;
case NetworkFailure(:final error, :final attempts):
// Retries were exhausted (or maxAttempts: 1 and it failed once).
break;
case NetworkOffline():
// Device was offline; `request` was never even called.
break;
case NetworkTimeout(:final attempts):
// Exceeded the `timeout` duration.
break;
case NetworkCancelled():
// A CancelToken was cancelled before completion.
break;
case NetworkQueued(:final taskId):
// Only returned if you've wired execute() to your own queueing logic;
// execute() itself returns NetworkOffline when offline — see the note below.
break;
}
Every parameter:
| Parameter | Default | Meaning |
|---|---|---|
request |
— (required) | The async call to run. |
key |
null |
Enables deduplication and is required if you pass cachePolicy. |
retryPolicy |
RetryPolicy.none |
How to retry on failure. |
deduplicationPolicy |
DeduplicationPolicy.reuseInFlight |
How to handle a call whose key is already in flight. |
cancelToken |
null |
A CancelToken the caller can cancel from elsewhere. |
timeout |
null |
Per-attempt timeout; null means no timeout. |
cachePolicy |
null |
Enables the CacheManager integration for this call — see 7.10. |
cacheTtl |
null |
How long a value written by this call stays fresh. |
Important behavior to know up front:
- If the device is offline when
execute()is called and nocachePolicyis set, it returnsNetworkOfflineimmediately —requestis never invoked. This is deliberate: it avoids the confusing failure mode where a request half-starts, times out slowly, and only then reports "offline." If you want offline calls persisted for later delivery instead of immediately reported as offline, use the offline queue —execute()and the queue are separate, composable tools, not one feature. - Non-idempotent writes should either skip retries or opt in
carefully. Retrying a
POSTthat actually reached the server but whose response was lost can create a duplicate. Either passRetryPolicy.nonefor writes, or useretryIftogether with an idempotency key your backend understands. - A
StateErroris thrown synchronously (not wrapped inNetworkFailure) if you pass acachePolicyother thanCachePolicy.networkOnlywithout also passing akey— the cache needs a key to store under.
7.7 Request deduplication #
Prevents the classic "user double-tapped Submit" bug. Used automatically
inside execute() when you pass a key, or usable standalone:
final deduplicator = RequestDeduplicator();
Future<Order> submit() => deduplicator.run(
'create_order',
() => api.createOrder(),
policy: DeduplicationPolicy.reuseInFlight,
);
Three policies:
DeduplicationPolicy.reuseInFlight(default) — a second call with the same key while the first is still running gets the same result as the first, without starting a second network call.DeduplicationPolicy.ignoreNew— the second call is rejected immediately with aStateError; the first call is unaffected.DeduplicationPolicy.replacePrevious— the second call proceeds as a fresh request; the first is no longer tracked for dedup purposes (it still runs to completion in the background, it's just no longer considered "in flight").
Calls made with key: null are never deduplicated — dedup is
opt-in per call, not global.
7.8 Cancellation #
CancelToken is a cooperative cancellation primitive — it doesn't
forcibly abort a socket (your HTTP client would need to support that
itself), but it stops execute() from waiting on / reacting to a call
once cancelled, and lets your own code check it at safe points:
final token = CancelToken();
final future = NetworkGuard.instance.execute<String>(
cancelToken: token,
request: () => api.longRunningReport(),
);
// Elsewhere, e.g. when the user navigates away:
token.cancel();
// Inside a long-running operation, check cooperatively between steps:
Future<void> longRunningReport(CancelToken token) async {
for (final chunk in chunks) {
token.throwIfCancelled(); // throws RequestCancelledException
await processChunk(chunk);
}
}
7.9 Offline queue #
For writes that should be persisted and retried later instead of
failing immediately while offline — the queue is a separate tool from
execute(), and the two compose:
final queue = OfflineQueue(
storage: const SharedPreferencesQueueStorage(), // survives app restarts
maxQueueSize: 200,
executor: (task) async {
final response = await http.post(
Uri.parse('https://api.example.com${task.endpoint}'),
body: jsonEncode(task.body),
headers: task.headers,
);
return response.statusCode == 200; // true = success, false = will retry
},
);
await NetworkGuard.initialize(
config: NetworkGuardConfig(
offlineQueue: queue,
autoProcessQueueOnRestore: true, // auto-runs queue.processPending() when internet returns
),
);
// Adding a task (e.g. the user tapped "save" while offline):
await NetworkGuard.instance.enqueue(
QueuedRequest(
id: 'update_profile_${DateTime.now().millisecondsSinceEpoch}',
endpoint: '/profile',
method: HttpMethod.put,
body: {'displayName': 'Jane'},
priority: QueuePriority.high,
maxRetries: 5,
expiresAt: DateTime.now().add(const Duration(days: 1)),
),
);
// Inspecting the queue:
NetworkGuard.instance.queue.pending; // List<QueuedRequest>, priority-then-age ordered
NetworkGuard.instance.queue.failed; // exhausted retries or expired
NetworkGuard.instance.queue.completed; // succeeded, still retained until you clear them
// Manually retry everything that failed:
await NetworkGuard.instance.queue.retry();
// Remove a specific task, or wipe the queue entirely:
await NetworkGuard.instance.queue.remove('update_profile_123');
await NetworkGuard.instance.queue.clear();
Things worth knowing:
- Closures can't be serialized, so a
QueuedRequeststores plain data (endpoint,method,body,headers) instead of "the function to call." Yourexecutorturns that data back into a real request — this is intentional and is what makes persistence across app restarts possible in the first place. - Queueing successfully is not a delivery guarantee. The device
could stay offline indefinitely, a task can exceed
maxRetries, or your backend could reject it once delivered. Treat this as "best-effort deferred delivery," and surfacequeue.failedin your UI so users aren't left wondering where their data went. SharedPreferencesQueueStoragestores the whole queue as one JSON blob. It's fine for the modest queue sizes this package targets, but avoid putting tokens, passwords, or other sensitive values inheadersorbodyunless you provide your own encryptingQueueStorageimplementation (see section 10).- Two tasks added with the same
idare deduplicated — the secondadd()replaces the first rather than creating a duplicate entry. - Use
InMemoryQueueStorage()(the default if you don't passstorageat all) for a session-only queue, or in tests.
7.10 Cache #
An opt-in, in-memory cache with TTL and five interaction policies. Nothing
is cached until you create a CacheManager and either call it directly
or pass a cachePolicy to execute().
final cache = CacheManager(maxEntries: 100);
await NetworkGuard.initialize(config: NetworkGuardConfig(cacheManager: cache));
// Direct use:
cache.set('user_profile', profile, ttl: const Duration(minutes: 10));
final cached = cache.get<Profile>('user_profile'); // null if absent or expired
// Or through execute(), which requires `key` whenever cachePolicy is set:
final result = await NetworkGuard.instance.execute<Profile>(
key: 'user_profile',
cachePolicy: CachePolicy.staleWhileRevalidate,
cacheTtl: const Duration(minutes: 10),
request: () => api.getProfile(),
);
The five policies, in order of how "network-eager" they are:
CachePolicy.networkOnly— always fetch fresh; cache is never read or written. Equivalent to not using the cache at all.CachePolicy.cacheFirst— return a cached value if present and unexpired; only fetch if there's nothing usable cached.CachePolicy.staleWhileRevalidate— return the cached value immediately if one exists (even if stale), while kicking off a background fetch to refresh it for next time. Ideal for "show something instantly, then update" screens like a dashboard.CachePolicy.networkFirst— always try a fresh fetch first, falling back to a cached value only if the fetch throws.CachePolicy.cacheOnly— return a cached value only, never fetch; throws aStateErrorif nothing is cached under that key.
Other useful CacheManager methods: invalidate(key),
invalidateWhere((key) => key.startsWith('user_')), and clear(). Once
maxEntries is exceeded, the single oldest entry (by write time) is
evicted to make room — there's no LRU-by-access-time tracking, only
insertion order.
7.11 Network-aware widgets #
Five widgets, from lowest-level to highest-level:
NetworkGuardBuilder — rebuilds whenever network state changes. Every
other widget in this list is built on top of this one.
NetworkGuardBuilder(
builder: (context, info) => Text('Status: ${info.status.name}'),
)
NetworkAware — swaps between an online child and an offline
fallback, for replacing an entire screen:
NetworkAware(
child: const OrdersScreen(),
offlineBuilder: (context, info) => OfflineScreen(info: info),
)
NetworkStatusBanner — a dismissible-feeling banner that appears
while offline and briefly shows a "back online" message on reconnect,
then disappears on its own:
Column(
children: [
const NetworkStatusBanner(), // put near the top of your Scaffold
Expanded(child: MyScreenContent()),
],
)
// Fully custom appearance via `builder`:
NetworkStatusBanner(
backOnlineDuration: const Duration(seconds: 2),
builder: (context, info, isBackOnline) => MyCustomBanner(
offline: !info.status.isOnline,
justReconnected: isBackOnline,
),
)
OfflineBanner — a thin convenience wrapper around
NetworkStatusBanner for a plain "offline only" indicator with no "back
online" flash.
NetworkGuardButton — a tap target that disables itself while
offline or while its own action is still in flight, and deduplicates
rapid double-taps automatically:
NetworkGuardButton(
onPressed: () => api.submitForm(),
child: const Text('Submit'),
)
// Or fully custom, e.g. to show a spinner while `isLoading` is true:
NetworkGuardButton(
onPressed: () => api.submitForm(),
builder: (context, onPressed, isLoading) => ElevatedButton(
onPressed: onPressed, // null (disabled) automatically while offline/loading
child: isLoading ? const CircularProgressIndicator() : const Text('Submit'),
),
)
NetworkDebugPanel — a developer-only overlay of live network state,
server health, and last-check time. Automatically hides itself in release
builds unless you pass forceEnabled: true:
const NetworkDebugPanel() // shows in debug/profile builds only
const NetworkDebugPanel(forceEnabled: true) // shows everywhere, including release
7.12 Logging #
Off by default — this package never writes a log line unless you turn it on:
await NetworkGuard.initialize(
config: NetworkGuardConfig(
enableLogging: true,
logLevel: LogLevel.info, // none < error < warning < info < debug < verbose
),
);
Setting a level enables it and everything less verbose above it in the
list — e.g. LogLevel.warning also emits error-level messages.
LogLevel.none (the default) disables logging entirely regardless of
what else is configured.
7.13 Metrics #
Simple in-memory counters you drive yourself — nothing is sent anywhere, and nothing is recorded automatically unless you call these methods at the relevant points in your own code:
final metrics = NetworkMetrics();
metrics.recordRequest(success: true, attempts: 2, latency: const Duration(milliseconds: 340));
metrics.recordCache(hit: true);
metrics.recordQueued();
final snapshot = metrics.snapshot;
print(snapshot.totalRequests);
print(snapshot.successRate); // 0.0–1.0, or null if no requests yet
print(snapshot.retryCount);
print(snapshot.averageLatency);
metrics.reset(); // zero everything out, e.g. between test runs
7.14 Dio interceptor #
Optional — only relevant if your app uses Dio. Makes doomed requests fail fast instead of hitting the network and timing out on their own while offline:
final dio = Dio()..interceptors.add(NetworkGuardInterceptor());
NetworkGuardInterceptor({failFastWhenOffline = true}) rejects requests
immediately with a DioException wrapping a NetworkUnavailableException
while offline. Retry, deduplication, and offline queueing for Dio calls
are handled better by wrapping the call in NetworkGuard.instance.execute()
than inside the interceptor, since those features need to know per-call
semantics (idempotency, a dedup key) that an interceptor can't infer
generically:
final result = await NetworkGuard.instance.execute(
request: () => dio.get('/orders'),
retryPolicy: const RetryPolicy(maxAttempts: 3),
);
7.15 package:http client wrapper #
The equivalent adapter for package:http users — wrap your existing
client and every call through it will fail fast while offline:
final client = NetworkGuardHttpClient(http.Client());
final response = await client.get(Uri.parse('https://api.example.com/orders'));
Same failFastWhenOffline parameter and the same recommendation: use
NetworkGuard.instance.execute() around individual calls for retry,
dedup, and cancellation rather than trying to put that logic in the
wrapper itself.
8. Full configuration reference #
Every field of NetworkGuardConfig, passed to NetworkGuard.initialize():
| Field | Default | Notes |
|---|---|---|
enableMonitoring |
true |
Set false to disable the continuous background stream; you'd then call checks manually. |
enableLogging |
false |
See 7.12. |
logLevel |
LogLevel.error |
Minimum level emitted when enableLogging is true. |
checkInterval |
30s |
How often background monitoring re-checks internet/server health. |
checkDebounceDuration |
300ms |
Minimum gap between checks triggered by rapid OS connectivity events. |
internetCheckUrls |
Cloudflare + Google DNS | Fallback list for InternetChecker. Must not be empty. |
internetCheckTimeout |
5s |
Per-endpoint timeout for internet checks. |
qualityThresholds |
see 7.4 | Latency boundaries for NetworkQuality. |
checkOnResume |
true |
Re-check when the app returns to the foreground. |
serverHealthChecks |
[] |
List of ServerHealthConfig to monitor continuously. |
eventHistorySize |
50 |
How many entries NetworkGuard.instance.history retains. |
offlineQueue |
null |
See 7.9. |
autoProcessQueueOnRestore |
true |
Auto-calls queue.processPending() when internet returns; only matters if offlineQueue is set. |
cacheManager |
null |
See 7.10. |
onOnline |
null |
Fires on transition into NetworkStatus.online. |
onOffline |
null |
Fires on transition into NetworkStatus.offline. |
onInternetRestored |
null |
Fires when internet comes back after being down — a good place to flush a queue manually if autoProcessQueueOnRestore is off. |
onServerRestored |
null |
Fires when any monitored server comes back after being down. |
onNetworkChanged |
null |
Fires on every NetworkInfo change, regardless of type. |
9. Recipes #
1. Show a full-screen offline state instead of a broken list:
NetworkAware(
child: const OrdersListScreen(),
offlineBuilder: (context, info) => Center(
child: Text(
info.hasLocalConnection
? 'Connected, but no internet access.'
: 'No connection.',
),
),
)
2. Fetch-with-fallback for a settings screen that should still render something useful offline:
final result = await NetworkGuard.instance.execute<Settings>(
key: 'user_settings',
cachePolicy: CachePolicy.staleWhileRevalidate,
cacheTtl: const Duration(hours: 1),
request: () => api.getSettings(),
);
final settings = result.valueOrNull ?? Settings.defaults();
3. Guard a non-idempotent "place order" button against duplicate submissions and offline taps in one line:
NetworkGuardButton(
onPressed: () async {
final result = await NetworkGuard.instance.execute(
key: 'place_order_${cart.id}',
retryPolicy: RetryPolicy.none, // do not auto-retry a write
request: () => api.placeOrder(cart),
);
if (result is NetworkOffline) {
await NetworkGuard.instance.enqueue(
QueuedRequest(id: 'order_${cart.id}', endpoint: '/orders', body: cart.toJson()),
);
}
},
child: const Text('Place order'),
)
10. Security & privacy notes #
- This package makes no network calls of its own beyond what you
configure: internet reachability checks against
internetCheckUrls, server health checks against the endpoints you list, and whatever your ownrequestclosures do insideexecute(). It does not phone home, collect analytics, or transmit anything to Anthropic, Google, or any third party on your behalf. SharedPreferencesQueueStoragepersists queued task bodies and headers as plain, unencrypted JSON. Do not put access tokens, passwords, or other sensitive values into aQueuedRequest.headersor.bodyunless you implement your ownQueueStoragebacked by encrypted storage (e.g.flutter_secure_storage) — theQueueStorageinterface is deliberately small (loadAll/saveAll/clear) so swapping it out is straightforward.- Logging (when explicitly enabled) prints endpoint URLs and task ids at
infolevel and above — it does not log request bodies or headers. Still, avoid enablingLogLevel.debug/.verbosein production builds that ship to end users.
11. Troubleshooting / FAQ #
"NetworkGuard.instance throws a StateError."
You called it before await NetworkGuard.initialize() finished. Make
sure initialize() is awaited in main() before runApp(), and that
nothing reads NetworkGuard.instance from a top-level variable
initializer that runs earlier than that.
"It says I'm offline but my Wi-Fi is clearly connected."
Check NetworkGuard.instance.info.hasLocalConnection vs .hasInternet
separately — a connected link with no real internet (captive portal,
router with no upstream) is exactly the case InternetChecker is
supposed to catch. If hasInternet is genuinely wrong on your network,
check whether your internetCheckUrls are reachable from that network
specifically (corporate firewalls sometimes block 1.1.1.1/8.8.8.8
directly) — point internetCheckUrls at your own domain if so.
"Requests aren't retrying."
execute() only retries if you pass a retryPolicy with maxAttempts > 1 — the default, RetryPolicy.none, is exactly one attempt. Also check
that the failure is actually reaching RetryEngine: a 4xx status your
own code throws as an exception is retried according to retryIf (if
you supplied one) or the package's default heuristic, not
retryableStatusCodes directly — that set is meant to be read by your
own retryIf when you're inspecting an HTTP response status yourself.
"Two calls to execute() with the same key both hit the network."
Deduplication only kicks in while the first call is genuinely still in
flight. If the first call already completed (even a moment ago) before
the second starts, there's nothing to deduplicate against — that's two
sequential calls, not a duplicate.
"The offline queue isn't processing automatically."
Confirm autoProcessQueueOnRestore is true (it is by default) and that
you actually configured offlineQueue in NetworkGuardConfig — without
it, NetworkGuard.instance.queue throws a StateError rather than
silently doing nothing.
"macOS app always reports offline." See the sandboxing entitlement
note in section 5 — this is almost
always a missing com.apple.security.network.client entitlement, not a
bug in this package.
12. What this package does not do #
Being upfront about scope:
- It does not measure bandwidth/throughput —
NetworkQualityis a latency estimate only. - It does not guarantee delivery of queued requests — see the note in 7.9.
- It does not run any checks while your app is backgrounded or terminated, on any platform.
- It does not forcibly abort an in-flight socket on cancellation — that depends on your underlying HTTP client's own cancellation support.
- It is not a replacement for server-side idempotency keys on writes —
NetworkRequest.idempotencyKeygives you a place to carry one, but you still need your backend to honor it.
13. Contributing #
Issues and pull requests are welcome on the issue tracker. Please include your Flutter/Dart version and target platform when filing a bug — a large share of "why doesn't this work" reports turn out to be platform-specific setup (see section 5) rather than package bugs.
14. License #
MIT — see LICENSE.



