Capstone: Real-World Flutter Project

Firestore CRUD & Offline-First Data Layer

16 min Lesson 6 of 10

Firestore CRUD & Offline-First Data Layer

Cloud Firestore is a NoSQL document database that gives Flutter apps real-time data synchronization, seamless offline persistence, and a flexible schema. In a clean-architecture Flutter project, all Firestore interactions belong in the data layer — specifically inside a repository implementation that satisfies a domain-layer interface. This lesson walks you through building that repository end-to-end: enabling offline persistence, implementing create/read/update/delete, and exposing reactive streams that your presentation layer can consume without knowing anything about Firestore.

Setting Up the Firestore Data Source

Add the dependency and initialize Firestore with offline persistence enabled before runApp. Offline persistence caches documents locally so reads succeed even when the device has no network, and queued writes are flushed automatically when connectivity is restored.

main.dart — Initialize Firestore with Persistence

import 'package:firebase_core/firebase_core.dart';
import 'package:cloud_firestore/cloud_firestore.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  // Enable unlimited offline persistence (default on mobile is 40 MB)
  FirebaseFirestore.instance.settings = const Settings(
    persistenceEnabled: true,
    cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED,
  );

  runApp(const MyApp());
}
Note: On the web, offline persistence uses IndexedDB and must be enabled by calling FirebaseFirestore.instance.enablePersistence() separately. On Android and iOS, persistenceEnabled: true in Settings is sufficient.

Defining the Domain Interface

Your domain layer declares an abstract repository interface. The data layer provides the concrete Firestore implementation. This inversion keeps business logic testable and swap-able — you can replace Firestore with a local SQLite backend simply by swapping the registered implementation.

domain/repositories/task_repository.dart

import '../entities/task.dart';

abstract class TaskRepository {
  /// Returns a real-time stream of all tasks ordered by creation time.
  Stream<List<Task>> watchAll();

  /// Creates a new task and returns the auto-generated document ID.
  Future<String> create(Task task);

  /// Updates an existing task identified by [task.id].
  Future<void> update(Task task);

  /// Permanently deletes the task with the given [id].
  Future<void> delete(String id);
}

Implementing CRUD Operations in the Repository

The Firestore repository implementation converts raw DocumentSnapshot objects into typed domain entities and back. Each write operation (create, update, delete) returns a Future that resolves when Firestore acknowledges the write — or immediately if the device is offline (the write is queued locally and synced later). The watchAll stream emits a new list every time the underlying collection changes, whether from the local cache or the server.

data/repositories/firestore_task_repository.dart

import 'package:cloud_firestore/cloud_firestore.dart';
import '../../domain/entities/task.dart';
import '../../domain/repositories/task_repository.dart';

class FirestoreTaskRepository implements TaskRepository {
  final FirebaseFirestore _db;
  static const _collection = 'tasks';

  FirestoreTaskRepository({FirebaseFirestore? db})
      : _db = db ?? FirebaseFirestore.instance;

  CollectionReference<Map<String, dynamic>> get _col =>
      _db.collection(_collection);

  // ── CREATE ────────────────────────────────────────────────────────────────
  @override
  Future<String> create(Task task) async {
    final doc = await _col.add(task.toMap());
    return doc.id;
  }

  // ── READ (real-time stream) ────────────────────────────────────────────────
  @override
  Stream<List<Task>> watchAll() {
    return _col
        .orderBy('createdAt', descending: true)
        .snapshots()
        .map((snapshot) => snapshot.docs
            .map((doc) => Task.fromMap(doc.id, doc.data()))
            .toList());
  }

  // ── UPDATE ────────────────────────────────────────────────────────────────
  @override
  Future<void> update(Task task) async {
    await _col.doc(task.id).update(task.toMap());
  }

  // ── DELETE ────────────────────────────────────────────────────────────────
  @override
  Future<void> delete(String id) async {
    await _col.doc(id).delete();
  }
}
Tip: Prefer .snapshots() over .get() for data that your UI displays. snapshots() returns a Stream that emits a fresh list whenever the data changes, giving you real-time updates with zero extra code. Use .get() only for one-shot reads where live updates are unnecessary (e.g., populating a print preview).

The Task Entity and Data Mapping

Domain entities are plain Dart classes — they must not import Firebase packages. Serialization logic lives in the data layer via fromMap factory constructors and toMap methods. Using FieldValue.serverTimestamp() for createdAt ensures the timestamp is set by Firestore's servers, not the client clock, which prevents skew across devices.

domain/entities/task.dart (entity) and data/models/task_model.dart (mapping)

// ── Domain entity — no Firebase imports ───────────────────────────────────
class Task {
  final String id;
  final String title;
  final bool isDone;
  final DateTime? createdAt;

  const Task({
    required this.id,
    required this.title,
    this.isDone = false,
    this.createdAt,
  });

  Task copyWith({String? title, bool? isDone}) => Task(
        id: id,
        title: title ?? this.title,
        isDone: isDone ?? this.isDone,
        createdAt: createdAt,
      );

  // ── Firestore serialization helpers ──────────────────────────────────────
  factory Task.fromMap(String id, Map<String, dynamic> map) => Task(
        id: id,
        title: map['title'] as String,
        isDone: map['isDone'] as bool? ?? false,
        createdAt: (map['createdAt'] as Timestamp?)?.toDate(),
      );

  Map<String, dynamic> toMap() => {
        'title': title,
        'isDone': isDone,
        'createdAt': FieldValue.serverTimestamp(),
      };
}
Warning: Never store a Timestamp or any Firestore type inside your domain entity. Convert Firestore types (Timestamp, GeoPoint, DocumentReference) to plain Dart types (DateTime, LatLng, String) in fromMap. This keeps your domain layer decoupled from the Firebase SDK and makes unit testing trivial.

Connecting the Stream to the Presentation Layer

With Riverpod (or any reactive state management solution), you can pipe the repository stream directly into the UI. A StreamProvider automatically rebuilds the widget tree whenever a new list of tasks arrives from Firestore — whether from the local offline cache or the remote server — without any manual setState calls.

Riverpod StreamProvider wiring

import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/entities/task.dart';
import '../../domain/repositories/task_repository.dart';
import '../repositories/firestore_task_repository.dart';

// ── DI: expose the repository implementation ─────────────────────────────
final taskRepositoryProvider = Provider<TaskRepository>((ref) {
  return FirestoreTaskRepository();
});

// ── Stream of tasks — auto-updated in real time ───────────────────────────
final tasksProvider = StreamProvider<List<Task>>((ref) {
  return ref.watch(taskRepositoryProvider).watchAll();
});

// ── In a widget ───────────────────────────────────────────────────────────
class TaskListScreen extends ConsumerWidget {
  const TaskListScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final tasksAsync = ref.watch(tasksProvider);

    return tasksAsync.when(
      loading: () => const CircularProgressIndicator(),
      error: (e, _) => Text('Error: $e'),
      data: (tasks) => ListView.builder(
        itemCount: tasks.length,
        itemBuilder: (_, i) => ListTile(
          title: Text(tasks[i].title),
          trailing: Icon(
            tasks[i].isDone ? Icons.check_circle : Icons.circle_outlined,
          ),
        ),
      ),
    );
  }
}

Summary

You now have a fully offline-first Firestore data layer that follows clean-architecture principles. Key takeaways from this lesson:

  • Enable offline persistence in Settings before runApp so the app works without a connection.
  • Define the repository contract in the domain layer and implement it in the data layer.
  • Use .add() for create, .update() for partial updates, .delete() for removal, and .snapshots() for real-time reads.
  • Keep domain entities free of Firestore types; serialize/deserialize in fromMap / toMap.
  • Pipe the repository stream into a StreamProvider so the UI rebuilds reactively with zero boilerplate.