12

I'm trying to retrieve posts from a Firestore collection called "posts", which contains the post creator's userID and post description and this is possible by using both StreamBuilder and FutureBuilder(Not preferable, because it gets a snapshot only once and doesn't update when a field changes).

However, I want to query another collection called "users" with the post creator's userID and retrieve the document that matches the userId.

This was my first approach:

StreamBuilder<QuerySnapshot>(
  stream:Firestore.instance.collection("posts").snapshots(),
  builder: (BuildContext context, AsyncSnapshot<QuerySnapshot> snapshot) {
    if (!snapshot.hasData) {
      return Center(
        child: _showProgressBar,
      );
    }

   List<DocumentSnapshot> reversedDocuments = snapshot.data.documents.reversed.toList();
    return ListView.builder(
      itemCount: reversedDocuments.length,
      itemBuilder: (BuildContext context, int index){

        String postAuthorID = reversedDocuments[index].data["postAuthorID"].toString();
        String postAuthorName = '';
        Firestore.instance.collection("users")
        .where("userID", isEqualTo: postAuthorID).snapshots().listen((dataSnapshot) {
            print(postAuthorName = dataSnapshot.documents[0].data["username"]);
          setState(() {
            postAuthorName = dataSnapshot.documents[0].data["username"];                  
          });
        });

        String desc = reversedDocuments[index].data["post_desc"].toString();

        return new ListTile(
          title: Container(
            child: Row(
              children: <Widget>[
                Expanded(
                  child: Card(
                    child: new Column(
                      children: <Widget>[
                        ListTile(
                          title: Text(postAuthorName, //Here, the value is not changed, it holds empty space.
                            style: TextStyle(
                              fontSize: 20.0,
                            ),
                          ),
                          subtitle: Text(desc),
                        ),
                       )

After understanding that ListView.builder() can only render items based on the DocumentSnapshot list and can't handle queries inside the builder.

After many research: I tried many alternatives like, trying to build the list in the initState(), tried using the Nested Stream Builder:

return StreamBuilder<QuerySnapshot>(
  stream: Firestore.instance.collection('posts').snapshots(),
  builder: (context, snapshot1){
    return StreamBuilder<QuerySnapshot>(
      stream: Firestore.instance.collection("users").snapshots(),
      builder: (context, snapshot2){
        return ListView.builder(
          itemCount: snapshot1.data.documents.length,
          itemBuilder: (context, index){
            String desc = snapshot1.data.documents[index].data['post_description'].toString();
            String taskAuthorID = snapshot1.data.documents[index].data['post_authorID'].toString();
            var usersMap = snapshot2.data.documents.asMap();
            String authorName;
            username.forEach((len, snap){
              print("Position: $len, Data: ${snap.data["username"]}");
              if(snap.documentID == post_AuthorID){
                authorName = snap.data["username"].toString();
              }
            });
            return ListTile(
              title: Text(desc),
              subtitle: Text(authorName), //Crashes here...
            );
          },
        );
      }
    );
  }
);

Tried with Stream Group and couldn't figure out a way to get this done, since it just combines two streams, but I want the second stream to be fetched by a value from first stream.

This is my Firebase Collection screenshot:

Firestore "posts" collection: Firestore "posts" Collection

Firestore "users" collection: Firestore "users" collection

I know this is a very simple thing, but still couldn't find any tutorial or articles to achieve this.

sanjay
  • 271
  • 1
  • 3
  • 13
  • There's no need to call `setState`, this is in a build method. In general though, you should stay away from querying Firestore in a build method. Check out [this excellent answer](https://stackoverflow.com/questions/52249578/how-to-deal-with-unwanted-widget-build/52249579#52249579) on keeping your build method pure. – Kirollos Morkos Oct 03 '18 at 23:11
  • Is there any alternative approach to query an user Id from the post and display their details, before the build is created? – sanjay Oct 04 '18 at 06:45
  • Why don't you put that query in your `initState` method? – Kirollos Morkos Oct 04 '18 at 19:08
  • Well, I need the post creator's id from the posts contained in the "posts" collection and then query that id with the "users" collection and then retrieve the user's name, picture or any other values. Now which should I be calling in the initStae method? – sanjay Oct 04 '18 at 19:15

2 Answers2

10

Posting for those in the future since I spent several hours trying to figure this out - hoping it saves someone else.

First I recommend reading up on Streams: https://www.dartlang.org/tutorials/language/streams This will help a bit and its a short read

The natural thought is to have a nested StreamBuilder inside the outer StreamBuilder, which is fine if the size of the ListView wont change as a result of the inner StreamBuilder receiving data. You can create a container with a fixed size when you dont have data, then render the data-rich widget when its ready. In my case, I wanted to create a Card for each document in both the "outer" collection and the "inner" collection. For example, I have a a Group collection and each Group has Users. I wanted a view like this:

[
  Group_A header card,
  Group_A's User_1 card,
  Group_A's User_2 card,
  Group_B header card,
  Group_B's User_1 card,
  Group_B's User_2 card,
]

The nested StreamBuilder approach rendered the data, but scrolling the ListView.builder was an issue. When scrolling, i'm guessing the height was calculated as (group_header_card_height + inner_listview_no_data_height). When data was received by the inner ListView, it expanded the list height to fit and the scroll jerks. Its not acceptable UX.

Key points for the solution:

  • All data should be acquired before StreamBuilder's builder execution. That means your Stream needs to contain data from both collections
  • Although Stream can hold multiple items, you want a Stream<List<MyCompoundObject>>. Comments on this answer (https://stackoverflow.com/a/53903960/608347) helped

The approach I took was basically

  1. Create stream of group-to-userList pairs

    a. Query for groups

    b. For each group, get appropriate userList

    c. Return a List of custom objects wrapping each pair

  2. StreamBuilder as normal, but on group-to-userList objects instead of QuerySnapshots

What it might look like

The compound helper object:

class GroupWithUsers {
  final Group group;
  final List<User> users;

  GroupWithUsers(this.group, this.users);
}

The StreamBuilder

    Stream<List<GroupWithUsers>> stream = Firestore.instance
        .collection(GROUP_COLLECTION_NAME)
        .orderBy('createdAt', descending: true)
        .snapshots()
        .asyncMap((QuerySnapshot groupSnap) => groupsToPairs(groupSnap));

    return StreamBuilder(
        stream: stream,
        builder: (BuildContext c, AsyncSnapshot<List<GroupWithUsers>> snapshot) {
            // build whatever
    });

essentially, "for each group, create a pair" handling all the conversion of types

  Future<List<GroupWithUsers>> groupsToPairs(QuerySnapshot groupSnap) {
    return Future.wait(groupSnap.documents.map((DocumentSnapshot groupDoc) async {
      return await groupToPair(groupDoc);
    }).toList());
  }

Finally, the actual inner query to get Users and building our helper

Future<GroupWithUsers> groupToPair(DocumentSnapshot groupDoc) {
    return Firestore.instance
        .collection(USER_COLLECTION_NAME)
        .where('groupId', isEqualTo: groupDoc.documentID)
        .orderBy('createdAt', descending: false)
        .getDocuments()
        .then((usersSnap) {
      List<User> users = [];
      for (var doc in usersSnap.documents) {
        users.add(User.from(doc));
      }

      return GroupWithUser(Group.from(groupDoc), users);
    });
  }
Scott Tomaszewski
  • 1,009
  • 8
  • 27
5

I posted a similar question and later found a solution: make the widget returned by the itemBuilder stateful and use a FutureBuilder in it.

Additional query for every DocumentSnapshot within StreamBuilder

Here's my code. In your case, your would want to use a new Stateful widget instead of ListTile, so you can add the FutureBuilder to call an async function.

StreamBuilder(
                  stream: Firestore.instance
                      .collection("messages").snapshots(),
                  builder: (context, snapshot) {
                    switch (snapshot.connectionState) {
                      case ConnectionState.none:
                      case ConnectionState.waiting:
                        return Center(
                          child: PlatformProgressIndicator(),
                        );
                      default:
                        return ListView.builder(
                          reverse: true,
                          itemCount: snapshot.data.documents.length,
                          itemBuilder: (context, index) {
                            List rev = snapshot.data.documents.reversed.toList();
                            ChatMessageModel message = ChatMessageModel.fromSnapshot(rev[index]);
                            return ChatMessage(message);
                          },
                        );
                    }
                  },
                )


class ChatMessage extends StatefulWidget {
  final ChatMessageModel _message;
  ChatMessage(this._message);
  @override
  _ChatMessageState createState() => _ChatMessageState(_message);
}

class _ChatMessageState extends State<ChatMessage> {
  final ChatMessageModel _message;

  _ChatMessageState(this._message);

  Future<ChatMessageModel> _load() async {
    await _message.loadUser();
    return _message;
  }

  @override
  Widget build(BuildContext context) {

    return Container(
      margin: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 10.0),
      child: FutureBuilder(
        future: _load(),
        builder: (context, AsyncSnapshot<ChatMessageModel>message) {
          if (!message.hasData)
            return Container();
          return Row(
            children: <Widget>[
              Container(
                margin: const EdgeInsets.only(right: 16.0),
                child: GestureDetector(
                  child: CircleAvatar(
                    backgroundImage: NetworkImage(message.data.user.pictureUrl),
                  ),
                  onTap: () {
                    Navigator.of(context)
                        .push(MaterialPageRoute(builder: (context) => 
                        ProfileScreen(message.data.user)));
                  },
                ),
              ),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Text(
                      message.data.user.name,
                      style: Theme.of(context).textTheme.subhead,
                    ),
                    Container(
                        margin: const EdgeInsets.only(top: 5.0),
                        child: _message.mediaUrl != null
                            ? Image.network(_message.mediaUrl, width: 250.0)
                            : Text(_message.text))
                  ],
                ),
              )
            ],
          );
        },
      ),
    );
  }
}
class ChatMessageModel {
  String id;
  String userId;
  String text;
  String mediaUrl;
  int createdAt;
  String replyId;
  UserModel user;

  ChatMessageModel({String text, String mediaUrl, String userId}) {
    this.text = text;
    this.mediaUrl = mediaUrl;
    this.userId = userId;
  }

  ChatMessageModel.fromSnapshot(DocumentSnapshot snapshot) {
    this.id = snapshot.documentID;
    this.text = snapshot.data["text"];
    this.mediaUrl = snapshot.data["mediaUrl"];
    this.createdAt = snapshot.data["createdAt"];
    this.replyId = snapshot.data["replyId"];
    this.userId = snapshot.data["userId"];
  }

  Map toMap() {
    Map<String, dynamic> map = {
      "text": this.text,
      "mediaUrl": this.mediaUrl,
      "userId": this.userId,
      "createdAt": this.createdAt
    };
    return map;

  }

  Future<void> loadUser() async {
    DocumentSnapshot ds = await Firestore.instance
        .collection("users").document(this.userId).get();
    if (ds != null)
      this.user = UserModel.fromSnapshot(ds);
  }

}
Marcos
  • 194
  • 1
  • 13
  • Hey, thanks for responding, but, I'd prefer a StreamBuilder, since FutureBuilder gets called only once and I want my posts to be updated every time there's a change in the firestore. – sanjay Nov 10 '18 at 18:51
  • @sanjay you would still use your main StreamBuilder. The FutureBuilder would be used to get the additional info that you need and would be triggered anytime the StreamBuilder detects a change in Firestore. – Marcos Nov 12 '18 at 01:20
  • That might do, but I want to be able to perform a query from one collection(posts) document's value to fetch a document ID from another collection called users. Does your ChatMessageModel have any similar operation like that? – sanjay Nov 13 '18 at 19:27
  • @sanjay that's what I'm doing. The StreamBuilder listens to new messages and when a new one comes in a new ChatMessage widget is created and its FutureBuilder loads the user data for the message's owner. – Marcos Nov 15 '18 at 00:44
  • Can you post the ChatMessageModel code too, so that I can understand what's going on? – sanjay Nov 15 '18 at 22:11
  • There you go @sanjay – Marcos Nov 17 '18 at 01:25
  • Thanks Marcos, let me try this and see! – sanjay Nov 17 '18 at 21:54
  • I'm still working on this, but I'll update the answer once, I get my module working, I truly wanted to appreciate the time you took to help me out. I marked it as the answer, cheers! – sanjay Nov 28 '18 at 23:17