Skip to main content

Command Palette

Search for a command to run...

Building Offline-First Flutter Apps with bedrock_sync

Updated
10 min read
Building Offline-First Flutter Apps with bedrock_sync
A

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!

46 views