Flutter Offline-First IoT Apps: Hive + Sync Architecture That Works in the Field
Here's a scenario that happens constantly: a maintenance technician is inspecting IoT sensors in a basement or a remote site with poor signal. The app spins, shows "No connection", and the technician can't see the last known device state or log their inspection notes.
IoT apps that require internet to display data have failed in their fundamental purpose. The devices exist in the physical world; your app needs to too.
This guide builds a production offline-first Flutter architecture using Hive for local storage and a smart sync queue for cloud reconciliation.
Why Hive for IoT Apps
Hive is a pure Dart NoSQL key-value store — no native code, no SQLite overhead. For IoT apps:
For relational queries (filter all devices by location), combine Hive with a simple in-memory index or use Isar (Hive's successor with full query support).
Step 1: Hive Setup
dependencies:
hive_flutter: ^1.1.0
connectivity_plus: ^6.0.0
riverpod: ^2.5.0dev_dependencies:
hive_generator: ^2.0.0
build_runner: ^2.4.0
Define your data models:
import 'package:hive/hive.dart';part 'device_snapshot.g.dart';
@HiveType(typeId: 0)
class DeviceSnapshot extends HiveObject {
@HiveField(0) String deviceId;
@HiveField(1) double? temperature;
@HiveField(2) double? humidity;
@HiveField(3) bool isOnline;
@HiveField(4) DateTime lastSeen;
@HiveField(5) String firmwareVersion;
@HiveField(6) String location;
DeviceSnapshot({
required this.deviceId,
this.temperature,
this.humidity,
required this.isOnline,
required this.lastSeen,
required this.firmwareVersion,
required this.location,
});
}
@HiveType(typeId: 1)
class SyncQueueItem extends HiveObject {
@HiveField(0) String id;
@HiveField(1) String type; // 'command', 'note', 'ack'
@HiveField(2) String payload; // JSON
@HiveField(3) DateTime createdAt;
@HiveField(4) int retryCount;
@HiveField(5) bool synced;
SyncQueueItem({ required this.id, required this.type, required this.payload,
required this.createdAt, this.retryCount = 0, this.synced = false });
}
flutter pub run build_runner build --delete-conflicting-outputs
Initialize Hive in main.dart:
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
await Hive.initFlutter();
Hive.registerAdapter(DeviceSnapshotAdapter());
Hive.registerAdapter(SyncQueueItemAdapter());
await Hive.openBox('devices');
await Hive.openBox('syncQueue');
runApp(const ProviderScope(child: MyApp()));
}
Step 2: Repository Pattern
Wrap all data access behind a repository — the UI never talks to Hive or the API directly:
class DeviceRepository {
final Box _local = Hive.box('devices');
final ApiService _api; DeviceRepository(this._api);
// Always read local first
List getAllDevices() => _local.values.toList();
DeviceSnapshot? getDevice(String deviceId) => _local.get(deviceId);
// Update local immediately, queue cloud sync
Future updateFromTelemetry(String deviceId, Map data) async {
final existing = _local.get(deviceId);
final snapshot = DeviceSnapshot(
deviceId: deviceId,
temperature: data['temperature']?.toDouble(),
humidity: data['humidity']?.toDouble(),
isOnline: true,
lastSeen: DateTime.now(),
firmwareVersion: data['firmware'] ?? existing?.firmwareVersion ?? 'unknown',
location: existing?.location ?? 'Unknown',
);
await _local.put(deviceId, snapshot);
}
// For initial load: fetch from API, cache locally
Future refreshFromCloud() async {
try {
final devices = await _api.getDevices();
for (final device in devices) {
await _local.put(device.deviceId, device);
}
} catch (_) {
// Silently fail — we have local data
}
}
}
Step 3: Connectivity Monitoring
final connectivityProvider = StreamProvider((ref) {
return Connectivity().onConnectivityChanged.map(
(result) => result != ConnectivityResult.none,
);
});// Trigger sync when connectivity restored
final syncOnReconnectProvider = Provider((ref) {
ref.listen>(connectivityProvider, (prev, next) {
final wasOffline = prev?.value == false;
final isNowOnline = next.value == true;
if (wasOffline && isNowOnline) {
ref.read(syncServiceProvider).processSyncQueue();
}
});
});
Step 4: Sync Queue for Offline Actions
When offline, user actions (sending a device command, logging an inspection note) go into a sync queue:
class SyncService {
final Box _queue = Hive.box('syncQueue');
final ApiService _api; Future enqueue(String type, Map payload) async {
final item = SyncQueueItem(
id: const Uuid().v4(),
type: type,
payload: jsonEncode(payload),
createdAt: DateTime.now(),
);
await _queue.put(item.id, item);
}
Future processSyncQueue() async {
final pending = _queue.values.where((i) => !i.synced).toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt)); // FIFO
for (final item in pending) {
try {
await _processItem(item);
item.synced = true;
await item.save();
} catch (e) {
item.retryCount++;
await item.save();
if (item.retryCount >= 5) {
// Dead letter — notify user
await _notifyFailedSync(item);
}
}
}
}
Future _processItem(SyncQueueItem item) async {
final payload = jsonDecode(item.payload) as Map;
switch (item.type) {
case 'command':
await _api.sendDeviceCommand(payload['deviceId'], payload['command']);
case 'inspection_note':
await _api.saveNote(payload);
case 'alert_ack':
await _api.acknowledgeAlert(payload['alertId']);
}
}
}
Step 5: Optimistic UI
Show the user the result of their action immediately — don't wait for cloud confirmation:
Future sendDeviceCommand(String deviceId, String command) async {
// 1. Update local state immediately (optimistic)
final device = _local.get(deviceId)!;
device.lastCommand = command;
device.lastCommandAt = DateTime.now();
await device.save(); // 2. Attempt cloud sync
final isOnline = await Connectivity().checkConnectivity() != ConnectivityResult.none;
if (isOnline) {
try {
await _api.sendDeviceCommand(deviceId, command);
} catch (_) {
// Fall through to queue
}
}
// 3. Queue for sync if offline or API failed
await syncService.enqueue('command', {'deviceId': deviceId, 'command': command});
}
Offline Indicators in the UI
Always show the user when they're working with cached data:
class DeviceScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isOnline = ref.watch(connectivityProvider).value ?? true;
final device = ref.watch(deviceProvider(deviceId)); return Scaffold(
appBar: AppBar(
title: const Text('Device Monitor'),
actions: [
if (!isOnline)
const Chip(
label: Text('Offline — cached data'),
backgroundColor: Colors.orange,
),
],
),
body: Column(children: [
if (device.lastSeen.isBefore(DateTime.now().subtract(const Duration(hours: 1))))
const Banner(message: 'Data may be stale', location: BannerLocation.topEnd),
DeviceDataPanel(device: device),
]),
);
}
}
Stale Data Strategy
| Data Age | UI Treatment | |----------|--------------| | < 5 min | Show normally | | 5–60 min | Subtle "last seen X ago" label | | 1–24 hr | Yellow warning badge | | > 24 hr | Red staleness warning, prompt refresh |
IoT apps deployed in factories, farms, and remote installations need to work where connectivity doesn't. [Contact Code Caracal](/contact) to build your offline-first IoT mobile app.