DartFirebaseFlutter

Integrating Firebase Firestore in a Flutter Project (Beginner to Pro)

20250407 0924 Firebase Firestore Integration Simple Compose 01jr7j5rzze9ptame3cagay2cq 1024x683

Introduction to Firebase Firestore in Flutter

Firebase Firestore is a flexible, scalable NoSQL cloud database for mobile, web, and server development. In this comprehensive guide, you’ll learn how to integrate Firebase Firestore into your Flutter project – from initial setup to production-ready patterns including type-safe models, repository patterns, and real-time data synchronization.

Whether you’re building a to-do app or a full-blown chat system, Firestore provides powerful real-time capabilities with offline support out of the box.

Step 1: Create a Firebase Project

Start by setting up your Firebase project:

  1. Go to Firebase Console
  2. Click Add project and follow the setup wizard
  3. Once created, click Add app and select Flutter

The Firebase CLI simplifies setup significantly:

# Install Firebase CLI
npm install -g firebase-tools

# Login to Firebase
firebase login

# Install FlutterFire CLI
dart pub global activate flutterfire_cli

# Configure your Flutter project
flutterfire configure

This automatically generates the firebase_options.dart file with your project configuration.

Step 2: Add Firebase Dependencies

Add the required packages to your pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  firebase_core: ^2.24.2
  cloud_firestore: ^4.13.6
  firebase_auth: ^4.16.0  # Often used with Firestore
  freezed_annotation: ^2.4.1
  json_annotation: ^4.8.1

dev_dependencies:
  build_runner: ^2.4.7
  freezed: ^2.4.5
  json_serializable: ^6.7.1

Initialize Firebase in your main.dart:

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  runApp(const MyApp());
}

Type-Safe Models with Freezed

Define type-safe models that work seamlessly with Firestore:

// lib/models/user_model.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'user_model.freezed.dart';
part 'user_model.g.dart';

@freezed
class UserModel with _$UserModel {
  const UserModel._();

  const factory UserModel({
    required String id,
    required String email,
    required String name,
    String? photoUrl,
    @Default([]) List<String> roles,
    @TimestampConverter() required DateTime createdAt,
    @TimestampConverter() DateTime? updatedAt,
  }) = _UserModel;

  factory UserModel.fromJson(Map<String, dynamic> json) =>
      _$UserModelFromJson(json);

  factory UserModel.fromFirestore(DocumentSnapshot doc) {
    final data = doc.data() as Map<String, dynamic>;
    return UserModel.fromJson({
      'id': doc.id,
      ...data,
    });
  }

  Map<String, dynamic> toFirestore() {
    final json = toJson();
    json.remove('id'); // Don't store ID in document data
    return json;
  }
}

// Custom converter for Firestore Timestamps
class TimestampConverter implements JsonConverter<DateTime, dynamic> {
  const TimestampConverter();

  @override
  DateTime fromJson(dynamic value) {
    if (value is Timestamp) {
      return value.toDate();
    }
    if (value is String) {
      return DateTime.parse(value);
    }
    throw ArgumentError('Invalid timestamp value: $value');
  }

  @override
  dynamic toJson(DateTime date) => Timestamp.fromDate(date);
}

// lib/models/task_model.dart
@freezed
class TaskModel with _$TaskModel {
  const TaskModel._();

  const factory TaskModel({
    required String id,
    required String userId,
    required String title,
    String? description,
    @Default(false) bool completed,
    @Default(TaskPriority.medium) TaskPriority priority,
    DateTime? dueDate,
    @TimestampConverter() required DateTime createdAt,
  }) = _TaskModel;

  factory TaskModel.fromJson(Map<String, dynamic> json) =>
      _$TaskModelFromJson(json);

  factory TaskModel.fromFirestore(DocumentSnapshot doc) {
    final data = doc.data() as Map<String, dynamic>;
    return TaskModel.fromJson({
      'id': doc.id,
      ...data,
    });
  }

  Map<String, dynamic> toFirestore() {
    final json = toJson();
    json.remove('id');
    return json;
  }

  bool get isOverdue =>
      dueDate != null && !completed && dueDate!.isBefore(DateTime.now());
}

enum TaskPriority { low, medium, high, urgent }

Run build_runner to generate the serialization code:

dart run build_runner build --delete-conflicting-outputs

Repository Pattern for Clean Architecture

Implement a repository pattern to abstract Firestore operations:

// lib/repositories/base_repository.dart
import 'package:cloud_firestore/cloud_firestore.dart';

abstract class BaseRepository<T> {
  final FirebaseFirestore _firestore;
  final String collectionPath;

  BaseRepository(this._firestore, this.collectionPath);

  CollectionReference<Map<String, dynamic>> get collection =>
      _firestore.collection(collectionPath);

  T fromFirestore(DocumentSnapshot doc);
  Map<String, dynamic> toFirestore(T item);

  Future<T?> getById(String id) async {
    final doc = await collection.doc(id).get();
    if (!doc.exists) return null;
    return fromFirestore(doc);
  }

  Future<List<T>> getAll() async {
    final snapshot = await collection.get();
    return snapshot.docs.map(fromFirestore).toList();
  }

  Stream<List<T>> watchAll() {
    return collection.snapshots().map(
      (snapshot) => snapshot.docs.map(fromFirestore).toList(),
    );
  }

  Stream<T?> watchById(String id) {
    return collection.doc(id).snapshots().map(
      (doc) => doc.exists ? fromFirestore(doc) : null,
    );
  }

  Future<String> create(T item, {String? id}) async {
    final data = toFirestore(item);
    if (id != null) {
      await collection.doc(id).set(data);
      return id;
    }
    final doc = await collection.add(data);
    return doc.id;
  }

  Future<void> update(String id, Map<String, dynamic> data) async {
    await collection.doc(id).update(data);
  }

  Future<void> delete(String id) async {
    await collection.doc(id).delete();
  }
}

// lib/repositories/task_repository.dart
class TaskRepository extends BaseRepository<TaskModel> {
  TaskRepository(FirebaseFirestore firestore) : super(firestore, 'tasks');

  @override
  TaskModel fromFirestore(DocumentSnapshot doc) => TaskModel.fromFirestore(doc);

  @override
  Map<String, dynamic> toFirestore(TaskModel item) => item.toFirestore();

  // User-specific queries
  Stream<List<TaskModel>> watchUserTasks(String userId) {
    return collection
        .where('userId', isEqualTo: userId)
        .orderBy('createdAt', descending: true)
        .snapshots()
        .map((snapshot) => snapshot.docs.map(fromFirestore).toList());
  }

  Stream<List<TaskModel>> watchUserIncompleteTasks(String userId) {
    return collection
        .where('userId', isEqualTo: userId)
        .where('completed', isEqualTo: false)
        .orderBy('dueDate')
        .snapshots()
        .map((snapshot) => snapshot.docs.map(fromFirestore).toList());
  }

  Stream<List<TaskModel>> watchTasksByPriority(String userId, TaskPriority priority) {
    return collection
        .where('userId', isEqualTo: userId)
        .where('priority', isEqualTo: priority.name)
        .where('completed', isEqualTo: false)
        .snapshots()
        .map((snapshot) => snapshot.docs.map(fromFirestore).toList());
  }

  Future<void> toggleComplete(String taskId) async {
    final doc = await collection.doc(taskId).get();
    if (doc.exists) {
      final current = doc.data()?['completed'] as bool? ?? false;
      await collection.doc(taskId).update({'completed': !current});
    }
  }

  Future<void> updatePriority(String taskId, TaskPriority priority) async {
    await collection.doc(taskId).update({'priority': priority.name});
  }

  // Batch operations
  Future<void> markAllComplete(String userId) async {
    final batch = FirebaseFirestore.instance.batch();
    final tasks = await collection
        .where('userId', isEqualTo: userId)
        .where('completed', isEqualTo: false)
        .get();

    for (final doc in tasks.docs) {
      batch.update(doc.reference, {'completed': true});
    }

    await batch.commit();
  }

  Future<void> deleteCompletedTasks(String userId) async {
    final batch = FirebaseFirestore.instance.batch();
    final tasks = await collection
        .where('userId', isEqualTo: userId)
        .where('completed', isEqualTo: true)
        .get();

    for (final doc in tasks.docs) {
      batch.delete(doc.reference);
    }

    await batch.commit();
  }
}

Riverpod Integration

Integrate the repository with Riverpod for reactive state management:

// lib/providers/firestore_providers.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../repositories/task_repository.dart';
import '../models/task_model.dart';

// Firestore instance
final firestoreProvider = Provider((ref) => FirebaseFirestore.instance);

// Repository providers
final taskRepositoryProvider = Provider((ref) {
  return TaskRepository(ref.read(firestoreProvider));
});

// Current user ID (from your auth provider)
final currentUserIdProvider = Provider<String?>((ref) {
  // Get from your auth state
  return ref.watch(authProvider).user?.id;
});

// Stream of user's tasks
final userTasksProvider = StreamProvider<List<TaskModel>>((ref) {
  final userId = ref.watch(currentUserIdProvider);
  if (userId == null) return Stream.value([]);

  return ref.read(taskRepositoryProvider).watchUserTasks(userId);
});

// Stream of incomplete tasks
final incompleteTasksProvider = StreamProvider<List<TaskModel>>((ref) {
  final userId = ref.watch(currentUserIdProvider);
  if (userId == null) return Stream.value([]);

  return ref.read(taskRepositoryProvider).watchUserIncompleteTasks(userId);
});

// Tasks filtered by priority
final tasksByPriorityProvider =
    StreamProvider.family<List<TaskModel>, TaskPriority>((ref, priority) {
  final userId = ref.watch(currentUserIdProvider);
  if (userId == null) return Stream.value([]);

  return ref.read(taskRepositoryProvider).watchTasksByPriority(userId, priority);
});

// Task actions notifier
@riverpod
class TaskActions extends _$TaskActions {
  @override
  FutureOr<void> build() {}

  Future<void> createTask({
    required String title,
    String? description,
    TaskPriority priority = TaskPriority.medium,
    DateTime? dueDate,
  }) async {
    final userId = ref.read(currentUserIdProvider);
    if (userId == null) throw Exception('Not authenticated');

    final task = TaskModel(
      id: '', // Will be set by Firestore
      userId: userId,
      title: title,
      description: description,
      priority: priority,
      dueDate: dueDate,
      createdAt: DateTime.now(),
    );

    await ref.read(taskRepositoryProvider).create(task);
  }

  Future<void> toggleComplete(String taskId) async {
    await ref.read(taskRepositoryProvider).toggleComplete(taskId);
  }

  Future<void> deleteTask(String taskId) async {
    await ref.read(taskRepositoryProvider).delete(taskId);
  }

  Future<void> updateTask(String taskId, {
    String? title,
    String? description,
    TaskPriority? priority,
    DateTime? dueDate,
  }) async {
    final updates = <String, dynamic>{};
    if (title != null) updates['title'] = title;
    if (description != null) updates['description'] = description;
    if (priority != null) updates['priority'] = priority.name;
    if (dueDate != null) updates['dueDate'] = Timestamp.fromDate(dueDate);

    await ref.read(taskRepositoryProvider).update(taskId, updates);
  }
}

UI Implementation

Build the UI with real-time updates:

// lib/screens/tasks_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/firestore_providers.dart';
import '../models/task_model.dart';

class TasksScreen extends ConsumerWidget {
  const TasksScreen({super.key});

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

    return Scaffold(
      appBar: AppBar(
        title: const Text('My Tasks'),
        actions: [
          PopupMenuButton<String>(
            onSelected: (value) async {
              if (value == 'mark_all') {
                final userId = ref.read(currentUserIdProvider);
                if (userId != null) {
                  await ref.read(taskRepositoryProvider).markAllComplete(userId);
                }
              } else if (value == 'delete_completed') {
                final userId = ref.read(currentUserIdProvider);
                if (userId != null) {
                  await ref.read(taskRepositoryProvider).deleteCompletedTasks(userId);
                }
              }
            },
            itemBuilder: (context) => [
              const PopupMenuItem(
                value: 'mark_all',
                child: Text('Mark all complete'),
              ),
              const PopupMenuItem(
                value: 'delete_completed',
                child: Text('Delete completed'),
              ),
            ],
          ),
        ],
      ),
      body: tasksAsync.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, stack) => Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const Icon(Icons.error_outline, size: 48, color: Colors.red),
              const SizedBox(height: 16),
              Text('Error: $error'),
              const SizedBox(height: 16),
              ElevatedButton(
                onPressed: () => ref.invalidate(userTasksProvider),
                child: const Text('Retry'),
              ),
            ],
          ),
        ),
        data: (tasks) {
          if (tasks.isEmpty) {
            return const Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Icon(Icons.task_outlined, size: 64, color: Colors.grey),
                  SizedBox(height: 16),
                  Text('No tasks yet'),
                  Text('Tap + to create your first task'),
                ],
              ),
            );
          }

          return ListView.builder(
            itemCount: tasks.length,
            itemBuilder: (context, index) => TaskTile(task: tasks[index]),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showCreateTaskDialog(context, ref),
        child: const Icon(Icons.add),
      ),
    );
  }

  void _showCreateTaskDialog(BuildContext context, WidgetRef ref) {
    showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      builder: (context) => const CreateTaskSheet(),
    );
  }
}

class TaskTile extends ConsumerWidget {
  final TaskModel task;

  const TaskTile({required this.task, super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Dismissible(
      key: Key(task.id),
      direction: DismissDirection.endToStart,
      background: Container(
        color: Colors.red,
        alignment: Alignment.centerRight,
        padding: const EdgeInsets.only(right: 16),
        child: const Icon(Icons.delete, color: Colors.white),
      ),
      onDismissed: (_) {
        ref.read(taskActionsProvider.notifier).deleteTask(task.id);
      },
      child: ListTile(
        leading: Checkbox(
          value: task.completed,
          onChanged: (_) {
            ref.read(taskActionsProvider.notifier).toggleComplete(task.id);
          },
        ),
        title: Text(
          task.title,
          style: TextStyle(
            decoration: task.completed ? TextDecoration.lineThrough : null,
            color: task.completed ? Colors.grey : null,
          ),
        ),
        subtitle: task.description != null
            ? Text(
                task.description!,
                maxLines: 1,
                overflow: TextOverflow.ellipsis,
              )
            : null,
        trailing: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            _buildPriorityIndicator(task.priority),
            if (task.isOverdue)
              const Padding(
                padding: EdgeInsets.only(left: 8),
                child: Icon(Icons.warning, color: Colors.orange, size: 20),
              ),
          ],
        ),
        onTap: () => _showTaskDetails(context, ref),
      ),
    );
  }

  Widget _buildPriorityIndicator(TaskPriority priority) {
    final color = switch (priority) {
      TaskPriority.low => Colors.green,
      TaskPriority.medium => Colors.blue,
      TaskPriority.high => Colors.orange,
      TaskPriority.urgent => Colors.red,
    };

    return Container(
      width: 8,
      height: 8,
      decoration: BoxDecoration(
        color: color,
        shape: BoxShape.circle,
      ),
    );
  }

  void _showTaskDetails(BuildContext context, WidgetRef ref) {
    showModalBottomSheet(
      context: context,
      builder: (context) => TaskDetailsSheet(task: task),
    );
  }
}

class CreateTaskSheet extends ConsumerStatefulWidget {
  const CreateTaskSheet({super.key});

  @override
  ConsumerState<CreateTaskSheet> createState() => _CreateTaskSheetState();
}

class _CreateTaskSheetState extends ConsumerState<CreateTaskSheet> {
  final _titleController = TextEditingController();
  final _descriptionController = TextEditingController();
  TaskPriority _priority = TaskPriority.medium;
  DateTime? _dueDate;
  bool _isLoading = false;

  @override
  void dispose() {
    _titleController.dispose();
    _descriptionController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: EdgeInsets.only(
        bottom: MediaQuery.of(context).viewInsets.bottom,
        left: 16,
        right: 16,
        top: 16,
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Text(
            'New Task',
            style: Theme.of(context).textTheme.headlineSmall,
          ),
          const SizedBox(height: 16),
          TextField(
            controller: _titleController,
            decoration: const InputDecoration(
              labelText: 'Title',
              border: OutlineInputBorder(),
            ),
            autofocus: true,
          ),
          const SizedBox(height: 12),
          TextField(
            controller: _descriptionController,
            decoration: const InputDecoration(
              labelText: 'Description (optional)',
              border: OutlineInputBorder(),
            ),
            maxLines: 3,
          ),
          const SizedBox(height: 12),
          DropdownButtonFormField<TaskPriority>(
            value: _priority,
            decoration: const InputDecoration(
              labelText: 'Priority',
              border: OutlineInputBorder(),
            ),
            items: TaskPriority.values.map((p) {
              return DropdownMenuItem(value: p, child: Text(p.name));
            }).toList(),
            onChanged: (value) {
              if (value != null) setState(() => _priority = value);
            },
          ),
          const SizedBox(height: 12),
          ListTile(
            title: Text(_dueDate == null
                ? 'Set due date'
                : 'Due: ${_dueDate!.toString().split(' ')[0]}'),
            trailing: const Icon(Icons.calendar_today),
            onTap: () async {
              final date = await showDatePicker(
                context: context,
                initialDate: DateTime.now(),
                firstDate: DateTime.now(),
                lastDate: DateTime.now().add(const Duration(days: 365)),
              );
              if (date != null) setState(() => _dueDate = date);
            },
          ),
          const SizedBox(height: 16),
          ElevatedButton(
            onPressed: _isLoading ? null : _createTask,
            child: _isLoading
                ? const SizedBox(
                    height: 20,
                    width: 20,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  )
                : const Text('Create Task'),
          ),
          const SizedBox(height: 16),
        ],
      ),
    );
  }

  Future<void> _createTask() async {
    if (_titleController.text.trim().isEmpty) return;

    setState(() => _isLoading = true);

    try {
      await ref.read(taskActionsProvider.notifier).createTask(
            title: _titleController.text.trim(),
            description: _descriptionController.text.trim().isEmpty
                ? null
                : _descriptionController.text.trim(),
            priority: _priority,
            dueDate: _dueDate,
          );

      if (mounted) Navigator.pop(context);
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Error: $e')),
        );
      }
    } finally {
      if (mounted) setState(() => _isLoading = false);
    }
  }
}

Firestore Security Rules

Secure your data with proper rules:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // User documents
    match /users/{userId} {
      allow read: if request.auth != null && request.auth.uid == userId;
      allow create: if request.auth != null && request.auth.uid == userId;
      allow update: if request.auth != null && request.auth.uid == userId;
      allow delete: if false; // Users can't delete their own profile
    }

    // Task documents
    match /tasks/{taskId} {
      allow read: if request.auth != null &&
                     resource.data.userId == request.auth.uid;

      allow create: if request.auth != null &&
                       request.resource.data.userId == request.auth.uid &&
                       request.resource.data.title is string &&
                       request.resource.data.title.size() > 0 &&
                       request.resource.data.title.size() <= 200;

      allow update: if request.auth != null &&
                       resource.data.userId == request.auth.uid &&
                       request.resource.data.userId == resource.data.userId;

      allow delete: if request.auth != null &&
                       resource.data.userId == request.auth.uid;
    }
  }
}

Common Mistakes to Avoid

1. Not creating indexes for compound queries

// This query requires a composite index
collection
    .where('userId', isEqualTo: userId)
    .where('completed', isEqualTo: false)
    .orderBy('dueDate')
    .get();

// Check Firestore console for index creation links in error messages

2. Not handling offline data

// Enable offline persistence (enabled by default on mobile)
FirebaseFirestore.instance.settings = const Settings(
  persistenceEnabled: true,
  cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED,
);

3. Forgetting to dispose stream subscriptions

// Wrong: Memory leak
@override
void initState() {
  super.initState();
  FirebaseFirestore.instance.collection('tasks').snapshots().listen((snapshot) {
    // Handle data
  });
}

// Correct: Cancel subscription
StreamSubscription? _subscription;

@override
void initState() {
  super.initState();
  _subscription = FirebaseFirestore.instance
      .collection('tasks')
      .snapshots()
      .listen((snapshot) {
    // Handle data
  });
}

@override
void dispose() {
  _subscription?.cancel();
  super.dispose();
}

4. Not using batched writes for multiple operations

// Wrong: Multiple network calls
for (final task in tasks) {
  await collection.doc(task.id).update({'completed': true});
}

// Correct: Single batched write
final batch = FirebaseFirestore.instance.batch();
for (final task in tasks) {
  batch.update(collection.doc(task.id), {'completed': true});
}
await batch.commit();

Final Thoughts

Firebase Firestore provides a powerful, real-time database that scales effortlessly with your Flutter application. By combining type-safe models with Freezed, implementing the repository pattern for clean architecture, and integrating with Riverpod for reactive state management, you can build production-ready applications with minimal boilerplate.

Remember to secure your data with proper Firestore rules, create indexes for complex queries, and leverage offline persistence for a seamless user experience regardless of network connectivity.