flutterfirebasegoogle-cloud-firestoreflutter-streambuilder

Firestore with flutter StreamBuilder widget causing unexpectedly large request on database


This how my database looks like

I'm making a laundry timer for dormitory students in my school. But I'm facing some kind of weird runtime errors.

I was trying to make my MachineCard widget automatically update when the data changes in firestore. And it worked pretty well until I implemented NFC features in my app.

My app works like this:

  1. Select the dorm you live in
  2. If you are currently not using a machine (rather its washer or dryer), you need to scan an NFC tag which is pasted on the machine.
  3. A bottom sheet comes out when the phone scans the NFC tag, and you need to chose the duration.
  4. Pressing start button, you will see the timer turned on in the main page.

Since it is organically connected with many files, I'll share my github repo. my github repo

The main error causing code is below.

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:laundryminder/utils/prefs.dart';
import 'package:laundryminder/widgets/machine_card.dart';
import 'package:laundryminder/widgets/title_text.dart';

class MainPage extends StatefulWidget {
  const MainPage({
    super.key,
  });

  @override
  State<MainPage> createState() => _MainPageState();
}

class _MainPageState extends State<MainPage> {
  final _database = FirebaseFirestore.instance;
  String dorm = Prefs.getStringValue("dorm");
  String current = Prefs.getStringValue("current");

  @override
  Widget build(BuildContext context) {
    double screenWidth = MediaQuery.of(context).size.width;
    return Scaffold(
      body: Column(children: [
        SizedBox(
          height: screenWidth * 0.25,
        ),
        Row(
          mainAxisAlignment: MainAxisAlignment.start,
          children: [
            Padding(
              padding: EdgeInsets.symmetric(horizontal: screenWidth * 0.08),
              child: TitleText(
                data: "Currently using",
                fontSize: screenWidth * 0.07,
              ),
            ),
          ],
        ),
        StreamBuilder(
            stream: _database.collection("dorms").doc(dorm).snapshots(),
            builder: (context, snapshot) {
              return MachineCard(widthArg: screenWidth, machine: const {});
            }),
        Row(
          mainAxisAlignment: MainAxisAlignment.start,
          children: [
            Padding(
              padding: EdgeInsets.symmetric(horizontal: screenWidth * 0.08),
              child: TitleText(
                data: "Machines",
                fontSize: screenWidth * 0.07,
              ),
            ),
          ],
        ),
        StreamBuilder(
            stream: _database.collection("dorms").doc(dorm).snapshots(),
            builder: (context, snapshot) {
              if (snapshot.hasData) {
                int len = snapshot.data!["machines"].length;
                List<dynamic> data = snapshot.data!["machines"];

                return Expanded(
                  child: ListView.builder(
                    itemCount: len,
                    itemBuilder: (context, index) {
                      print("test");
                      Map<String, dynamic> machineData = data[index];
                      if (machineData["type"] + '${machineData["code"]}' ==
                          current) {
                        return Container();
                      }
                      int remainingTime;
                      bool isRunning = machineData["isRunning"];
                      Timestamp timestamp = machineData["startedAt"];
                      DateTime startedAt = timestamp.toDate();

                      switch (machineData["option"]) {
                        case 0:
                          remainingTime = 45 * 60 -
                              (DateTime.now().difference(startedAt)).inSeconds;
                          if (remainingTime <= 0) {
                            remainingTime = 0;
                            isRunning = false;
                          }
                          break;
                        case 1:
                          remainingTime = 50 * 60 -
                              (DateTime.now().difference(startedAt)).inSeconds;
                          if (remainingTime <= 0) {
                            remainingTime = 0;
                            isRunning = false;
                          }
                          break;
                        case 2:
                          remainingTime = 80 * 60 -
                              (DateTime.now().difference(startedAt)).inSeconds;
                          if (remainingTime <= 0) {
                            remainingTime = 0;
                            isRunning = false;
                          }
                          break;
                        default:
                          return Container();
                      }

                      Map<String, dynamic> machine = {
                        "type": machineData["type"],
                        "code": machineData["code"],
                        "isCurrent": false,
                        "isDisabled": machineData["isDisabled"],
                        "isRunning": isRunning,
                        "remainingTime": remainingTime,
                      };

                      return Padding(
                        padding: EdgeInsets.symmetric(
                            horizontal: screenWidth * 0.08),
                        child: MachineCard(
                            widthArg: screenWidth, machine: machine),
                      );
                    },
                  ),
                );
              } else {
                return Container();
              }
            }),
      ]),
    );
  }
}

Also, it didn't give me errors when I was manually parsing the "startedAt" part, since its db format was just string. (Yet it was not successful because it required to rebuild "main-page" again to reflect the change even though it works fine without refreshing when I change other fields in db like "isDisabled" or "isRunning" and the machine types) However when I changed it into Timestamp, it started to make crazy many queries.

EDIT:

class _MainPageState extends State<MainPage> {
  final _database = FirebaseFirestore.instance;
  String dorm = Prefs.getStringValue("dorm");
  String current = Prefs.getStringValue("current");
  late Stream stream;

  @override
  void initState() {
    stream =
        FirebaseFirestore.instance.collection("dorms").doc(dorm).snapshots();
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    double screenWidth = MediaQuery.of(context).size.width;
    return Scaffold(
      body: Column(children: [
        SizedBox(
          height: screenWidth * 0.25,
        ),
        Row(
          mainAxisAlignment: MainAxisAlignment.start,
          children: [
            Padding(
              padding: EdgeInsets.symmetric(horizontal: screenWidth * 0.08),
              child: TitleText(
                data: "Currently using",
                fontSize: screenWidth * 0.07,
              ),
            ),
          ],
        ),
        StreamBuilder(
            stream: stream,
            builder: (context, snapshot) {
              return MachineCard(widthArg: screenWidth, machine: const {});
            }),
        Row(
          mainAxisAlignment: MainAxisAlignment.start,
          children: [
            Padding(
              padding: EdgeInsets.symmetric(horizontal: screenWidth * 0.08),
              child: TitleText(
                data: "Machines",
                fontSize: screenWidth * 0.07,
              ),
            ),
          ],
        ),
        StreamBuilder(
            stream: stream,
            builder: (context, snapshot) {
              if (snapshot.hasData) {
                int len = snapshot.data!["machines"].length;
                List<dynamic> data = snapshot.data!["machines"];
                ...

I changed as the first person told me, but still its sending extraordinary requests. Moreover, I found that the trigger of this situation is in the submit button widget.

void onPressed() {
      Map<String, dynamic> current = Prefs.getMapValue("current");
      String currentDorm =
          ["Men A", "Men B", "Women A", "Women B"][current["dorm"]];
      bool matches = Prefs.getStringValue("dorm") == currentDorm;
      if (matches) {
        database
            .collection("dorms")
            .doc(currentDorm)
            .snapshots()
            .listen((event) {
          List<dynamic> response = event.data()!["machines"];

          for (int i = 0; i < response.length; i++) {
            if (response[i]["type"] == ["Washer", "Dryer"][current["type"]] &&
                response[i]["code"] == current["code"]) {
              response[i]["option"] = Prefs.getIntValue("option");

              Timestamp now = Timestamp.fromDate(DateTime.now());
              response[i]["startedAt"] = now;
              response[i]["isRunning"] = true;
              response[i]["isDisabled"] = false;
              break;
            }
          }
          database
              .collection("dorms")
              .doc(currentDorm)
              .set({"machines": response}, SetOptions(merge: true)).onError(
                  (error, stackTrace) => print(error));
        });
      }
    }

This is my onpressed function in my submit button widget. For more information, you can look at my MachineCard widget and SubmitButton widget in my github repo.

I guess that I did something wrong on my database connection code in the function, but the weird thing is it triggers the StreamBuilder widget, which I fixed, to malfunction.


Solution

  • The problem is here:

    StreamBuilder(
      stream: _database.collection("dorms").doc(dorm).snapshots(),
      ...
    

    The StreamBuilder is re-built every time the UI is rendered, so every time that happens your code goes to the database and requests all dorms. There's a lot of situations that cause the UI to re-render, and in many of those cases the dorm data likely hasn't changed.

    So you'll want to:

    With these steps, the stream will be kept between renders - and the additional rendering operations will end up rendering the documents that were already loaded.