Skip to main content

Firestore data in a ListView

About 4 min

Firestore data in a ListView

In this chapter we will continue the Travel Notes app. The app already has Firebase setup and Authentication. Now we will store trips in Cloud Firestore and show them in a realtime ListView.

The app will:

  • read trips from Firestore
  • store trips under the logged-in user
  • show realtime updates in a ListView
  • add, update and delete trips
  • store flexible NoSQL fields such as tags and checklist

Before you start

Make sure you completed the Firebase project setup and Authentication chapters. Your app must contain firebase_options.dart, Firebase.initializeApp() must be called before runApp(), and the AuthGate should be used as the home page.

Firestore structure

We will store every user's trips in a subcollection:

users
  userUid
    trips
      tripId
        title: "Summer in Japan"
        destination: "Tokyo"
        description: "Food, temples and game arcades"
        tags: ["city", "food", "culture"]
        checklist: ["Passport", "JR Pass", "Camera"]
        createdAt: timestamp

This structure has an important advantage: the path already contains the user id. Later, security rules can use that id to make sure users can only access their own trips.

Trip model

Create a new file trip.dart in the models folder.

class Trip {
  String id;
  String title;
  String destination;
  String description;
  List<String> tags;
  List<String> checklist;

  Trip({
    required this.id,
    required this.title,
    required this.destination,
    required this.description,
    required this.tags,
    required this.checklist,
  });

  factory Trip.fromFirestore(String id, Map<String, dynamic> data) {
    return Trip(
      id: id,
      title: data['title'] ?? '',
      destination: data['destination'] ?? '',
      description: data['description'] ?? '',
      tags: List<String>.from(data['tags'] ?? []),
      checklist: List<String>.from(data['checklist'] ?? []),
    );
  }

  Map<String, dynamic> toFirestore() {
    return {
      'title': title,
      'destination': destination,
      'description': description,
      'tags': tags,
      'checklist': checklist,
    };
  }
}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
codedescription
idFirestore document id
tagslist stored directly inside the trip document
checklistanother list stored inside the document
fromFirestoreconverts Firestore data to a Dart object
toFirestoreconverts a Dart object to Firestore data

The tags and checklist fields show why Firestore is useful for this kind of app. We can store lists inside the document instead of creating extra tables and joins.

Trip repository

Create a new file trip_repository.dart in the repositories folder.

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

import '../models/trip.dart';

class TripRepository {
  CollectionReference<Map<String, dynamic>> get trips {
    final uid = FirebaseAuth.instance.currentUser!.uid;

    return FirebaseFirestore.instance
        .collection('users')
        .doc(uid)
        .collection('trips');
  }

  Stream<List<Trip>> watchTrips() {
    return trips
        .orderBy('createdAt', descending: true)
        .snapshots()
        .map((snapshot) {
      return snapshot.docs.map((doc) {
        return Trip.fromFirestore(doc.id, doc.data());
      }).toList();
    });
  }

  Future<Trip?> fetchTrip(String id) async {
    final snapshot = await trips.doc(id).get();

    if (!snapshot.exists || snapshot.data() == null) {
      return null;
    }

    return Trip.fromFirestore(snapshot.id, snapshot.data()!);
  }

  Future<void> createTrip(Trip trip) {
    return trips.add({
      ...trip.toFirestore(),
      'createdAt': FieldValue.serverTimestamp(),
    });
  }

  Future<void> updateTrip(Trip trip) {
    return trips.doc(trip.id).update(trip.toFirestore());
  }

  Future<void> deleteTrip(String id) {
    return trips.doc(id).delete();
  }
}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
codedescription
FirebaseAuth.instance.currentUser!.uidid of the logged-in user
users/{uid}/tripstrips are stored under the current user
snapshots()listens to realtime changes
FieldValue.serverTimestamp()lets Firestore fill in the creation time
trips.add(...)creates a new document with an automatic id
trips.doc(id).update(...)updates an existing document

NoSQL design

We do not need a separate trip_tags table or checklist_items table. The lists belong to the trip and are stored inside the trip document.

Trip list page

Replace the temporary trip_list.dart in the pages folder with the code below.

import 'package:flutter/material.dart';

import '../models/trip.dart';
import '../repositories/trip_repository.dart';
import '../services/auth_service.dart';
import 'trip_detail.dart';

class TripListPage extends StatelessWidget {
  const TripListPage({super.key});

  static final TripRepository repository = TripRepository();
  static final AuthService authService = AuthService();

  
  Widget build(BuildContext context) {
    final ColorScheme colors = Theme.of(context).colorScheme;

    return Scaffold(
      appBar: AppBar(
        title: const Text("My trips"),
        actions: [
          IconButton(
            onPressed: authService.signOut,
            icon: const Icon(Icons.logout),
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          _navigateToDetail(context);
        },
        tooltip: "Add trip",
        child: const Icon(Icons.add),
      ),
      body: StreamBuilder<List<Trip>>(
        stream: repository.watchTrips(),
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return const Center(child: Text("Could not load trips"));
          }

          if (!snapshot.hasData) {
            return const Center(child: CircularProgressIndicator());
          }

          final trips = snapshot.data!;

          if (trips.isEmpty) {
            return const Center(child: Text("No trips yet"));
          }

          return ListView.builder(
            padding: const EdgeInsets.all(8),
            itemCount: trips.length,
            itemBuilder: (context, index) {
              final trip = trips[index];

              return Card(
                child: ListTile(
                  leading: CircleAvatar(
                    backgroundColor: colors.primary,
                    foregroundColor: colors.onPrimary,
                    child: Text(trip.destination.substring(0, 1)),
                  ),
                  title: Text(trip.title),
                  subtitle: Text(
                    "${trip.destination} - ${trip.tags.join(', ')}",
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                  ),
                  onTap: () {
                    _navigateToDetail(context, trip.id);
                  },
                ),
              );
            },
          );
        },
      ),
    );
  }

  void _navigateToDetail(BuildContext context, [String? id]) {
    Navigator.push(
      context,
      MaterialPageRoute(builder: (context) => TripDetailPage(id: id)),
    );
  }
}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 



StreamBuilder is a good fit for Firestore because Firestore can push changes to your app. When a trip is created, updated or deleted, the list is rebuilt automatically.

Trip detail page

Create a new file trip_detail.dart in the pages folder.

import 'package:flutter/material.dart';

import '../models/trip.dart';
import '../repositories/trip_repository.dart';

class TripDetailPage extends StatefulWidget {
  final String? id;

  const TripDetailPage({super.key, this.id});

  
  State<TripDetailPage> createState() => _TripDetailPageState();
}

class _TripDetailPageState extends State<TripDetailPage> {
  final TripRepository repository = TripRepository();
  final TextEditingController titleController = TextEditingController();
  final TextEditingController destinationController = TextEditingController();
  final TextEditingController descriptionController = TextEditingController();
  final TextEditingController tagsController = TextEditingController();
  final TextEditingController checklistController = TextEditingController();

  Trip? trip;
  bool isLoading = false;

  bool get isNewTrip => widget.id == null;

  
  void initState() {
    super.initState();

    if (!isNewTrip) {
      _loadTrip();
    }
  }

  Future<void> _loadTrip() async {
    setState(() {
      isLoading = true;
    });

    trip = await repository.fetchTrip(widget.id!);

    titleController.text = trip?.title ?? '';
    destinationController.text = trip?.destination ?? '';
    descriptionController.text = trip?.description ?? '';
    tagsController.text = trip?.tags.join(', ') ?? '';
    checklistController.text = trip?.checklist.join('\n') ?? '';

    setState(() {
      isLoading = false;
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(isNewTrip ? "New trip" : "Trip details"),
        actions: [
          if (!isNewTrip)
            IconButton(
              onPressed: _deleteTrip,
              icon: const Icon(Icons.delete),
            ),
          IconButton(
            onPressed: _saveTrip,
            icon: const Icon(Icons.save),
          ),
        ],
      ),
      body: isLoading
          ? const Center(child: CircularProgressIndicator())
          : Padding(
              padding: const EdgeInsets.all(12),
              child: ListView(
                children: [
                  TextField(
                    controller: titleController,
                    decoration: const InputDecoration(
                      labelText: "Title",
                      border: OutlineInputBorder(),
                    ),
                  ),
                  const SizedBox(height: 12),
                  TextField(
                    controller: destinationController,
                    decoration: const InputDecoration(
                      labelText: "Destination",
                      border: OutlineInputBorder(),
                    ),
                  ),
                  const SizedBox(height: 12),
                  TextField(
                    controller: descriptionController,
                    maxLines: 3,
                    decoration: const InputDecoration(
                      labelText: "Description",
                      border: OutlineInputBorder(),
                    ),
                  ),
                  const SizedBox(height: 12),
                  TextField(
                    controller: tagsController,
                    decoration: const InputDecoration(
                      labelText: "Tags",
                      helperText: "Separate tags with commas",
                      border: OutlineInputBorder(),
                    ),
                  ),
                  const SizedBox(height: 12),
                  TextField(
                    controller: checklistController,
                    minLines: 3,
                    maxLines: 6,
                    decoration: const InputDecoration(
                      labelText: "Checklist",
                      helperText: "Put each item on a new line",
                      border: OutlineInputBorder(),
                    ),
                  ),
                ],
              ),
            ),
    );
  }

  Future<void> _saveTrip() async {
    final editedTrip = Trip(
      id: widget.id ?? '',
      title: titleController.text,
      destination: destinationController.text,
      description: descriptionController.text,
      tags: _parseCommaSeparated(tagsController.text),
      checklist: _parseLines(checklistController.text),
    );

    if (isNewTrip) {
      await repository.createTrip(editedTrip);
    } else {
      await repository.updateTrip(editedTrip);
    }

    if (mounted) {
      Navigator.pop(context);
    }
  }

  Future<void> _deleteTrip() async {
    await repository.deleteTrip(widget.id!);

    if (mounted) {
      Navigator.pop(context);
    }
  }

  List<String> _parseCommaSeparated(String value) {
    return value
        .split(',')
        .map((item) => item.trim())
        .where((item) => item.isNotEmpty)
        .toList();
  }

  List<String> _parseLines(String value) {
    return value
        .split('\n')
        .map((item) => item.trim())
        .where((item) => item.isNotEmpty)
        .toList();
  }

  
  void dispose() {
    titleController.dispose();
    destinationController.dispose();
    descriptionController.dispose();
    tagsController.dispose();
    checklistController.dispose();
    super.dispose();
  }
}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Try the app

  • Run the app.
  • Create an account or log in.
  • Add a trip.
  • Add a few tags, for example city, food, culture.
  • Add checklist items, each on a new line.
  • Save the trip.

Open Firestore in the Firebase console. You should see the trip document under your own user id:

users/{uid}/trips/{tripId}

This is the important NoSQL idea in this chapter: one trip document can contain normal text fields, lists and timestamps, and Firestore can stream those documents directly to the Flutter UI.

Exercise

Extend the trip documents with one extra flexible field:

  • activities: a list of activities for the trip

Update the model, repository and detail page so activities can be stored and shown in the app.