Listening to a Firestore collection and its subcollections in Flutter

1,339

I´ll recommend you wrapping your widgets with two Streambuilders. Just use the first one to stream the tables and the second one to access orders inside each table. Don´t add a Streambuilder each time you want to access data. Generate the data with only two streams and pass it through variables.

enter image description here

Here is the exmaple code:

database.dart

import 'package:cloud_firestore/cloud_firestore.dart';

class Tables {
  CollectionReference tablesReference =
      FirebaseFirestore.instance.collection("tables");

  Stream<QuerySnapshot> getTables() {
    // Returns all tables
    return tablesReference.snapshots();
  }

  Stream<QuerySnapshot> getOrdersFromTables(String tableName) {
    // Returns specific table orders
    return tablesReference.doc(tableName).collection("orders").snapshots();
  }
}

main.dart

import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'tables.dart';


void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData.dark(),
      // Initialize FlutterFire:
      home: FutureBuilder(
          future: Firebase.initializeApp(),
          builder: (context, snapshot) {
            // Check for errors
            if (snapshot.hasError) {
              return Center(
                child: Text("Error"),
              );
            }

            // Once complete, show your application
            if (snapshot.connectionState == ConnectionState.done) {
              return TablesPage();
            }

            // Otherwise, show something whilst waiting for initialization to complete
            return Center(
              child: CircularProgressIndicator(),
            );
          }),
    );
  }
}

tables.dart

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'orders.dart';
import 'database.dart';

class TablesPage extends StatefulWidget {
  @override
  _TablesPageState createState() => _TablesPageState();
}

class _TablesPageState extends State<TablesPage> {
  @override
  Widget build(BuildContext context) {
    // Get Tables
    return Scaffold(
      appBar: AppBar(
        title: Text("Tables"),
      ),
      body: StreamBuilder<QuerySnapshot>(
        stream: Tables().getTables(),
        builder: (context, tables) {
          if (tables.hasError)
            return Text(tables.error);
          else if (tables.hasData) {
            if (tables.data.docs.isEmpty) {
              return Text("No tables.");
            } else {
              return ListView.builder(
                itemCount: tables.data.docs.length,
                itemBuilder: (context, index) {
                  // Get Orders
                  return StreamBuilder<QuerySnapshot>(
                      stream: Tables()
                          .getOrdersFromTables(tables.data.docs[index].id),
                      builder: (context, orders) {
                        if (orders.hasData) {
                          return ListTile(
                              title:
                                  Text("Table ${tables.data.docs[index].id}"),
                              subtitle: Text(DateFormat("HH:mm").format(tables
                                  .data.docs[index]["checkinTime"]
                                  .toDate())),
                              trailing: Text(
                                  "Total orders: ${orders.data.docs.length.toString()}"),
                              onTap: () {
                                // Navigate to Orders Page
                                Navigator.push(
                                  context,
                                  MaterialPageRoute(
                                    builder: (context) => Orders(
                                      orders: orders.data.docs,
                                    ),
                                  ),
                                );
                              });
                        } else {
                          return Container();
                        }
                      });
                },
              );
            }
          } else {
            print(tables
                .connectionState); //is stuck in ConnectionState.waiting or ConnectionState.active
            return Center(child: CircularProgressIndicator());
          }
        },
      ),
    );
  }
}

orders.dart

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';

class Orders extends StatelessWidget {
  const Orders({
    Key key,
    @required this.orders,
  }) : super(key: key);

  final List<QueryDocumentSnapshot> orders;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Orders"),
      ),
      body: ListView.builder(
        itemCount: orders.length,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text(orders[index].id),
            subtitle: Text(orders[index]["barStatus"]),
            leading: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text((index+1).toString()),
              ],
            ),
          );
        },
      ),
    );
  }
}
Share:
1,339
CodingDavid
Author by

CodingDavid

Updated on December 26, 2022

Comments

  • CodingDavid
    CodingDavid over 1 year

    The result I try to achieve

    I use the Firestore server for the backend of a Flutter application for an order system of a restaurant. I found myself in kind of a challenge when implementing a screen that shows real time changes of the database.

    The structure in the Firestore DB is as follows: structure in the Firestore DB

    So I've got a collection called 'tables' which contains several documents (which might have a different name for different restaurants). Each of those documents has several fields, e.g. 'checkinTime'. Additionally, every table document also has a collection of orders ("the subcollection").

    I want to get a Stream on my Flutter screen which receives all of the tables, including their orders. The screen should give an overview of all tables, that means it is supposed to update whenever...

    • a new table document was created
    • a table document was updated
    • a new order was placed into a tables subcollection 'orders'
    • an order document was updated

    I have a model of the Order and the Table class. An instance of the Table class should have a List<Order> _orders which contains all the orders stored in the subcollection. The Stream should provide me with the List<Table>.

    Note: I try to achieve this with Flutter Web, but that probably doesn't make a difference regarding the solution of my problem.

    Current progress

    I actually managed to almost solve the challenge so far. I've managed to get a Stream of all the tables onto the screen.

    In most of the cases, it initially loads the data successfully. Sometimes the first load of the screen results in an infinite loading CircularProgressIndicator and the data is not loaded.

    The screen updates, when another Table is added to the collection. Adding an order doesn't work immediately, but if I reload the page a second time or after I add another order to the order subcollection it works. It seems like the error occurs independent of the timing.

    It seems like updating an order document doesn't work/reload the order state at all.

    There is no error message in any case.

    Current code

    Class TableInfo for the Stream:

    import 'package:cloud_firestore/cloud_firestore.dart';
    import 'package:flutter/material.dart' as m;
    
    import '../../../../../domain/infrastructure/firestore_infrastructure.dart';
    import '../../../../../domain/models/orders_models/order.dart';
    import '../../../../../domain/models/orders_models/table.dart';
    import '../../../../../locator.dart';
    
    ///Class for getting all tables and orders in realtime and managing the current filter settings.
    class TableInfo extends m.ChangeNotifier {
      ///List of all tables
      List<Table> _tables = [];
    
      ///List of all orders
      List<Order> _orders = [];
    
      final CollectionReference _tableCollection = locator<CustomFirestore>().tablesRef;
    
      Stream<QuerySnapshot> get _stream => _tableCollection.snapshots();
    
      ///returns all tables in the Firestore `tables` collection of the restaurant.
      ///Also sets a listener for the orders and matches them from the [_orders] list.
      Stream<List<Table>> getTableStream(
          {void Function(Order savedOrder) onOrderSaved}) async* {
        print("Reloading");
    
        final tableDocs = await _tableCollection.get().then((value) => value.docs);
        for (QueryDocumentSnapshot tableDoc in tableDocs) {
          _tableCollection
              .doc(tableDoc.id)
              .collection(CustomFirestore.ORDERS_COLLECTION)
              .snapshots()
              .listen((snapshot) =>
                  _saveOrders(snapshot, tableDoc.id, onOrderSaved: onOrderSaved));
        }
    
        final tableStream =
            _stream.map((snapShot) => _unfilteredTablesFromTableSnapshot(snapShot));
    
        yield* tableStream;
      }
    
      ///Help function to get all tables with ordes from a table doc snapshot.
      List<Table> _unfilteredTablesFromTableSnapshot(QuerySnapshot snapShot) {
        return snapShot.docs.map((tableDocument) {
          final table = Table.fromSnapshot(tableDocument);
    
          table.addOrders(_orders.where((element) => element.tableId == table.id),
              overwrite: true);
    
          _tables.add(table);
    
          return table;
        }).toList();
      }
    
      ///Function to save the orders from a snapshot of an order collection of a single table.
      void _saveOrders(QuerySnapshot orderCollectionSnapshot, String tableId,
          {void Function(Order savedOrder) onOrderSaved}) {
        final orderDocs = orderCollectionSnapshot.docs;
        print(
            "Saving ${orderDocs.length} orders for $tableId at ${DateTime.now()} :)");
    
        for (QueryDocumentSnapshot orderDoc in orderDocs) {
          final order = Order.fromSnapshot(orderDoc, tableId);
          final existing = _orders.firstWhere(
              (element) => element.orderId == order.orderId,
              orElse: () => null);
          if (existing != null) _orders.remove(existing);
          _orders.add(order);
          if (onOrderSaved != null) onOrderSaved(order);
        }
      }
    }
    

    As you can see, I first get all of the table documents and then add a listener for the subcollection 'orders', called _saveOrders. It seems like the code gets the orders first, that's why I initialize the Table in Table.fromSnapshot with _orders = [] and then add the orders from the order list within the _unfilteredTablesFromTableSnapshot method.

    Here is the relevant part of the screens build method (placed within a Scaffold, obviously):

    final tableInfo = Provider.of<TableInfo>(context);
    return StreamProvider.value(
                                    value: tableInfo.getTableStream(),
                                    catchError: (ctx, error) {
                                      print("Error occured! $error");
                                      print(error.runtimeType);
                                      //throw error;
                                      return [
                                        t.Table(
                                            id: 'fehler',
                                            name: 'Fehler $error',
                                            status: t.TableStatus.Free,
                                            orders: [],
                                            checkinTime: DateTime.now())
                                      ];
                                    },
                                    builder: (context, child) {
                                      final allUnfilteredTables = Provider.of<
                                              List<t.Table>>(
                                          context); //corresponds to TableInfo.filteredTableStream
                                      ///while the Tables are loading:
                                      if (allUnfilteredTables == null)
                                        return Center(
                                            child: CircularProgressIndicator());
    
                                      ///if there are no tables at all:
                                      if (allUnfilteredTables.isEmpty)
                                        return Text(
                                            "No tables detected.");
    
                                      ///if an error occured:
                                      if (allUnfilteredTables[0].id == 'fehler')
                                        return Text(allUnfilteredTables[0].name);
    
                                      return GridView.builder(
                                        gridDelegate:
                                            SliverGridDelegateWithFixedCrossAxisCount(
                                                crossAxisSpacing: (50 / 1920) *
                                                    constraints.maxWidth,
                                                crossAxisCount: isTablet ? 3 : 6),
                                        itemCount: allUnfilteredTables.length,
                                        itemBuilder: (ctx, index) {
                                          t.Table table = allUnfilteredTables[index];
    
                                          return TableMiniDisplay(table);
                                        },
                                      );
                                    });
    

    What I've already tried

    I've read a lot about related issues but couldn't find any answer that helped me that much in order to fix my remaining errors. I also tried using rxdart and using a CombinedStream but couldn't get it running.

    The major part of the real time synchronization works, so I guess there's only little holding me back from success and I thank everyone of you who took your time in order to read about my problem. I appreciate any ideas or code samples that could help me.

    ( Also if you got any other advice to improve the code, feel free to leave a comment :) )

    Thank you in advance!

    Cheers, David

  • CodingDavid
    CodingDavid over 3 years
    Thanks for your help! I think this is still a bit too much code in case I want to replicate it on every screen that shows all the tables with the orders. I'll try to get a cleaner way of coding it (and wait for other responses as well), but otherwise I'll use it. Thx again :)
  • LucasACH
    LucasACH over 3 years
    Actually you dont need to add both streambuilders to each page. Just get the data once and pass it trough every class. You can use a listView.builder() for example. Using "index" you can access specific table orders. The final result would be a list of tables and when they get tapped, orders are displayed. Let me know if you want some example code.
  • CodingDavid
    CodingDavid over 3 years
    The thing is, the data is displayed in different ways on several screens. On one screen, I need the list of all tables, so a List<Table> tables where every table contains its corresponding orders. On the other screen I actually only need all of the orders. For this purpose, I have used final orders = tables.expand((element) => element.orders).toList() so far. Some example code would be pretty helpful, thanks :)
  • CodingDavid
    CodingDavid over 3 years
    So I tried it out but now I can't even get all of the tables and I don't know why. The first Streambuilder is stuck with the CircularProgressIndicator and seems to stay either in ConnectionState.waiting or ConnectionState.active. Source: pastebin.com/M6NaJ2gf
  • CodingDavid
    CodingDavid over 3 years
    Thanks for your support. Unfortunately I am still stuck with ConnectionState.active and simply see a CircularProgressIndicator :( Could you take a look into my source code paste?
  • CodingDavid
    CodingDavid over 3 years
    Got it working! I simply removed the snapshot.connectionState == ConnectionState.done from the if clause in my code paste and added the Firebase.initializeApp at the beginning of the app launch. Thanks for your support! <3
  • LucasACH
    LucasACH over 3 years
    Perfect! No problem. Can you please rate my amswer, thanks!