Building Offline-First Flutter Apps with bedrock_sync

Full-stack developer specializing in Flutter mobile applications and Spring Boot backends. I architect scalable cross-platform solutions with expertise in API integration, data management, and end-to-end application development
Stop letting bad networks ruin your user experience
We've all been there. You're on the subway, your Flutter app loses signal, and suddenly everything breaks. Spinners spin forever. Buttons stop working. Data disappears. Your users get frustrated and uninstall.
What if your app worked perfectly offline — and silently synced everything the moment connectivity returned?
That's exactly what bedrock_sync does. And in this article, I'll show you how to build a real-world field service app using it.
What is bedrock_sync?
bedrock_sync is an offline-first sync engine for Flutter. The name comes from geology — bedrock is the solid rock layer deep underground that never moves, no matter what happens above it. Your data works the same way — always available on-device, always reliable, syncing upward to the cloud when ready.
Unlike other sync packages that just queue HTTP requests, bedrock_sync gives you a complete local database with reactive streams, conflict resolution, and pluggable backend adapters.
dependencies:
bedrock_sync: ^0.1.0
The Problem We're Solving
Imagine you're building a field service app for technicians who repair equipment at customer sites. These technicians work in basements, warehouses, and remote locations with zero connectivity.
They need to:
Log job reports offline
Upload photos and notes
Mark jobs as complete
Have their manager see updates in real time
With a traditional approach, everything breaks without internet. With bedrock_sync, the app works identically online or offline.
Architecture Overview
Here's how bedrock_sync works under the hood:
Your Flutter UI
↓
BedrockEngine (orchestrator)
↓
┌────────────────────────────────┐
│ Local Store │ Sync Queue │
│ (SQLite) │ (SQLite) │
└────────────────────────────────┘
↓ when online
Connectivity Monitor
↓
Conflict Resolver
↓
Sync Adapter (REST/Firebase/Supabase)
↓
Your Backend
Every write goes to local SQLite instantly (0ms, no network). At the same time, the operation is queued. When the device goes online, the queue flushes to your backend automatically.
Real World Example: Field Service App
Step 1 — Installation
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
bedrock_sync: ^0.1.0
flutter pub get
Step 2 — Initialize the Engine
// main.dart
import 'package:flutter/material.dart';
import 'package:bedrock_sync/bedrock_sync.dart';
late final BedrockEngine engine;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
engine = BedrockEngine(
adapter: RestSyncAdapter(
baseUrl: 'https://api.fieldservice.com',
defaultHeaders: {
'Authorization': 'Bearer ${AuthService.token}',
'Content-Type': 'application/json',
},
// Custom URL builder for your API structure
buildUrl: (collection, id) =>
id != null ? '/v1/\(collection/\)id' : '/v1/$collection',
// Transform server response to local format
transformResponse: (res) => res['data'] as Map<String, dynamic>,
),
config: SyncConfig(
enableLogging: true,
maxRetries: 5,
retryStrategy: RetryStrategy.exponential,
syncTrigger: SyncTrigger.hybrid,
syncInterval: const Duration(minutes: 3),
conflictResolver: LastWriteWinsResolver(),
// Auto cleanup — keep 30 days of sync history
autoClearSynced: true,
keepSyncedForDays: 30,
// Lifecycle hooks
onAfterSync: (count) {
debugPrint('✅ Synced $count operations');
NotificationService.show('$count job reports synced');
},
onError: (error, stack) {
ErrorReporting.capture(error, stack);
},
),
);
await engine.init();
runApp(const FieldServiceApp());
}
Step 3 — Data Models
// models/job_report.dart
class JobReport {
final String id;
final String jobId;
final String technicianId;
final String notes;
final String status; // pending | in_progress | completed
final List<String> photoUrls;
final DateTime reportedAt;
final DateTime? completedAt;
JobReport({
required this.id,
required this.jobId,
required this.technicianId,
required this.notes,
required this.status,
required this.photoUrls,
required this.reportedAt,
this.completedAt,
});
Map<String, dynamic> toMap() => {
'id': id,
'jobId': jobId,
'technicianId': technicianId,
'notes': notes,
'status': status,
'photoUrls': photoUrls.join(','),
'reportedAt': reportedAt.toIso8601String(),
'completedAt': completedAt?.toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
};
factory JobReport.fromMap(Map<String, dynamic> map) => JobReport(
id: map['_id'] ?? map['id'],
jobId: map['jobId'],
technicianId: map['technicianId'],
notes: map['notes'],
status: map['status'],
photoUrls: (map['photoUrls'] as String? ?? '').split(','),
reportedAt: DateTime.parse(map['reportedAt']),
completedAt: map['completedAt'] != null
? DateTime.parse(map['completedAt'])
: null,
);
}
Step 4 — Repository Layer
Wrap BedrockEngine in a clean repository for your feature:
// repositories/job_repository.dart
class JobRepository {
final BedrockEngine _engine;
JobRepository(this._engine);
// Create a new job report (works offline)
Future<String> createReport(JobReport report) async {
return _engine.write(
'job_reports',
report.toMap(),
id: report.id,
metadata: {
'technicianId': report.technicianId,
'priority': 'high',
},
);
}
// Update job status (works offline)
Future<void> updateStatus(String id, String status) async {
final existing = await _engine.readById('job_reports', id);
if (existing == null) return;
await _engine.updateRecord(
'job_reports',
id,
{
...existing,
'status': status,
'completedAt': status == 'completed'
? DateTime.now().toIso8601String()
: null,
'updatedAt': DateTime.now().toIso8601String(),
},
);
}
// Get all reports for today (instant, local)
Future<List<JobReport>> getTodayReports(String technicianId) async {
final all = await _engine.query(
'job_reports',
{'technicianId': technicianId},
);
final today = DateTime.now();
return all
.where((r) {
final date = DateTime.parse(r['reportedAt']);
return date.day == today.day &&
date.month == today.month &&
date.year == today.year;
})
.map(JobReport.fromMap)
.toList();
}
// Watch reports in real time — UI auto-updates
Stream<List<JobReport>> watchReports() {
return _engine
.watch('job_reports')
.map((list) => list.map(JobReport.fromMap).toList());
}
// Fetch latest from server (pull-to-refresh)
Future<void> refreshFromServer() async {
await _engine.pullFromServer('job_reports');
}
// Manual sync trigger
Future<SyncResult> sync() => _engine.sync();
// Sync status stream for UI
Stream<SyncStatus> get syncStatus => _engine.statusStream;
}
Step 5 — The UI
Now the fun part — the UI is simple because bedrock_sync handles all the complexity:
// screens/job_list_screen.dart
class JobListScreen extends StatefulWidget {
const JobListScreen({super.key});
@override
State<JobListScreen> createState() => _JobListScreenState();
}
class _JobListScreenState extends State<JobListScreen> {
late final JobRepository _repo;
@override
void initState() {
super.initState();
_repo = JobRepository(engine);
}
Future<void> _addReport() async {
final report = JobReport(
id: const Uuid().v4(),
jobId: 'JOB-${DateTime.now().millisecondsSinceEpoch}',
technicianId: AuthService.currentUser.id,
notes: 'Equipment inspected. No issues found.',
status: 'completed',
photoUrls: [],
reportedAt: DateTime.now(),
completedAt: DateTime.now(),
);
// ⚡ This returns instantly — no waiting for network!
await _repo.createReport(report);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Report saved! Will sync when online.')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Job Reports'),
actions: [
// Live sync status indicator
StreamBuilder<SyncStatus>(
stream: _repo.syncStatus,
builder: (context, snapshot) {
final status = snapshot.data;
return Padding(
padding: const EdgeInsets.only(right: 12),
child: Icon(
status?.isOnline == true
? Icons.cloud_done
: Icons.cloud_off,
color: status?.isOnline == true
? Colors.green
: Colors.orange,
),
);
},
),
],
),
body: Column(
children: [
// Sync status banner
StreamBuilder<SyncStatus>(
stream: _repo.syncStatus,
builder: (context, snapshot) {
final status = snapshot.data;
if (status == null) return const SizedBox.shrink();
if (!status.isOnline) {
return Container(
color: Colors.orange.shade100,
padding: const EdgeInsets.all(8),
child: const Row(
children: [
Icon(Icons.wifi_off, size: 16),
SizedBox(width: 8),
Text('Offline mode — reports saved locally'),
],
),
);
}
if (status.pendingCount > 0) {
return Container(
color: Colors.blue.shade100,
padding: const EdgeInsets.all(8),
child: Text(
'🔄 Syncing ${status.pendingCount} reports...',
),
);
}
return Container(
color: Colors.green.shade100,
padding: const EdgeInsets.all(8),
child: const Text('✅ All reports synced'),
);
},
),
// Job reports list — auto-updates via stream
Expanded(
child: RefreshIndicator(
onRefresh: _repo.refreshFromServer,
child: StreamBuilder<List<JobReport>>(
stream: _repo.watchReports(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final reports = snapshot.data!;
if (reports.isEmpty) {
return const Center(
child: Text('No reports yet. Add your first one!'),
);
}
return ListView.builder(
itemCount: reports.length,
itemBuilder: (context, index) {
final report = reports[index];
return JobReportCard(
report: report,
onStatusChange: (newStatus) =>
_repo.updateStatus(report.id, newStatus),
);
},
);
},
),
),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _addReport,
icon: const Icon(Icons.add),
label: const Text('Add Report'),
),
);
}
}
Step 6 — Conflict Resolution for Field Service
Field service apps have a classic conflict problem: a technician updates a job offline, and a manager also updates it from the web portal. Who wins?
bedrock_sync gives you full control:
// Option 1: Last write wins (default)
conflictResolver: LastWriteWinsResolver()
// Option 2: Field technician always wins
conflictResolver: ClientWinsResolver()
// Option 3: Manager (server) always wins
conflictResolver: ServerWinsResolver()
// Option 4: Merge — keep notes from technician, status from server
conflictResolver: MergeResolver(
localPriorityFields: ['notes', 'photoUrls'],
serverPriorityFields: ['status', 'assignedTo'],
)
// Option 5: Custom business logic
conflictResolver: CustomResolver(
resolver: (local, server) async {
// If job is completed locally, never let server overwrite
if (local.data['status'] == 'completed') {
return local.data;
}
// Otherwise trust the server
return server;
},
)
Step 7 — Firebase Adapter (Custom)
Want to use Firebase instead of REST? Implement the adapter:
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:bedrock_sync/bedrock_sync.dart';
class FirestoreAdapter extends FirebaseSyncAdapter {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
@override
Future<SyncAdapterResult> push(SyncOperation op) async {
try {
final ref = _firestore.collection(op.collection);
switch (op.type) {
case OperationType.create:
await ref.doc(op.recordId).set(op.data);
return SyncAdapterResult.success(op.data);
case OperationType.update:
await ref.doc(op.recordId).update(op.data);
return SyncAdapterResult.success(op.data);
case OperationType.delete:
await ref.doc(op.recordId).delete();
return SyncAdapterResult.success({});
default:
return SyncAdapterResult.success(op.data);
}
} on FirebaseException catch (e) {
if (e.code == 'permission-denied') {
return SyncAdapterResult.failure('Permission denied: ${e.message}');
}
return SyncAdapterResult.failure(e.message ?? 'Firebase error');
}
}
@override
Future<Map<String, dynamic>?> pull(String collection, String id) async {
final doc = await _firestore.collection(collection).doc(id).get();
return doc.exists ? doc.data() : null;
}
@override
Future<List<Map<String, dynamic>>> pullAll(String collection) async {
final snapshot = await _firestore.collection(collection).get();
return snapshot.docs.map((doc) => {'id': doc.id, ...doc.data()}).toList();
}
@override
Future<bool> isReachable() async {
try {
await _firestore.collection('_health').limit(1).get();
return true;
} catch (_) {
return false;
}
}
}
// Use it:
final engine = BedrockEngine(
adapter: FirestoreAdapter(),
config: SyncConfig(...),
);
Step 8 — Custom Local Store (Hive)
Prefer Hive over SQLite? Implement LocalStore:
import 'package:hive_flutter/hive_flutter.dart';
import 'package:bedrock_sync/bedrock_sync.dart';
class HiveLocalStore extends LocalStore {
late Box<Map> _box;
@override
Future<void> init() async {
await Hive.initFlutter();
_box = await Hive.openBox<Map>('bedrock_sync');
}
@override
Future<void> save(String collection, String id, Map<String, dynamic> data) async {
await _box.put('\(collection:\)id', data);
}
@override
Future<Map<String, dynamic>?> findById(String collection, String id) async {
final result = _box.get('\(collection:\)id');
return result?.cast<String, dynamic>();
}
@override
Future<List<Map<String, dynamic>>> findAll(String collection) async {
return _box.keys
.where((k) => k.toString().startsWith('$collection:'))
.map((k) => (_box.get(k) as Map).cast<String, dynamic>())
.toList();
}
// ... implement remaining methods
}
// Use it:
final engine = BedrockEngine(
adapter: myAdapter,
store: HiveLocalStore(), // plug in your store
);
Viewing Your Local Database
During development, you can visualize the SQLite tables using SQLite Online Viewer. Pull the DB from your emulator:
# Android emulator
adb exec-out "run-as com.yourapp cat /data/data/com.yourapp/app_flutter/bedrock_sync.db" > bedrock_sync.db
You'll see two tables:
local_records — your app's local data store:
collection | id | data | createdAt
todos | f47ac10b... | {"title":"Fix pump",...} | 2026-03-10T10:00:00Z
sync_queue — operation history:
collection | type | status | retryCount | syncedAt
todos | create | synced | 0 | 2026-03-10T10:00:01Z
todos | update | synced | 0 | 2026-03-10T10:02:00Z
Performance Considerations
bedrock_sync currently runs on the main thread. For most apps with under 500 records this is perfectly fine. For large datasets, here's what to expect:
| Dataset Size | Performance |
|---|---|
| < 100 records | Instant ✅ |
| 100–500 records | Very fast ✅ |
| 500–2000 records | Slight delay ⚠️ |
| 2000+ records | Needs Dart isolates |
Dart isolate support is planned for v0.2.0 along with background sync via WorkManager.
Roadmap
v0.1.0 → offline sync, REST adapter, conflict resolution ✅ (current)
v0.2.0 → background sync via WorkManager (app closed sync)
v0.3.0 → Dart isolates for performance
v0.4.0 → Firebase + Supabase built-in adapters
Summary
With bedrock_sync, your Flutter app:
Works perfectly with zero internet
Syncs automatically when connectivity returns
Handles conflicts intelligently
Stays responsive no matter what
Works with any backend
The field service app we built today handles one of the hardest real-world scenarios — and it took less than 100 lines of business logic.
Try It
dependencies:
bedrock_sync: ^0.1.0
👉 pub.dev/packages/bedrock_sync 👉 github.com/abhishek-900/bedrock_sync
If this helped you, give the package a ⭐ on GitHub and a 👍 on pub.dev. Questions? Drop them in the comments below!



