Flutter: Strange behavour of StreamProvider, widgets rebuilt with incomplete data

Viewed 216

I am getting incomplete data from a StreamProvider.

The next minimum widget tree reproduces my problem: A SreamProvider over a Tabbed Screen.

My User object contains a map of several values (among other properties) and I use these values for one screen of the Tabbed View by calling final user = Provider.of<User>(context); inside the build() method in order to get these values in this screen whenever the app starts or is completely rebuilt (i.e. hot reload).

The problem: Whenever I switch tabs and go back to this tab, the build() method is called only once with final user = Provider.of<User>(context); returning an incomplete copy of User: some data is missing (some properties of a map inside the User object) and build() is never called again to complete the data (given that the StreamProvider should return the complete object at some point, possibly causing some rebuilds. The result: some data is missing and some widgets of the Screen cannot be built.

class Wrapper extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final firebaseUser = Provider.of<FirebaseUser>(context);
    // return either Home or Authenticate widget:
    if (firebaseUser == null) {
      return WelcomeScreen();
    } else {
      return StreamProvider<User>.value(
        value: FirestoreService(uid: firebaseUser.uid).user,
        child: TabsWrapper(),
      );
    }
  }
}


class TabsWrapper extends StatefulWidget {
  final int initialIndex;
  TabsWrapper({this.initialIndex: 0});

  @override
  _TabsWrapperState createState() => _TabsWrapperState();
}

class _TabsWrapperState extends State<TabsWrapper> with TickerProviderStateMixin {
  TabController tabController;

  @override
  void initState() {
    super.initState();
    tabController = TabController(vsync: this, length: choices.length);
  }

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      initialIndex: widget.initialIndex,
      length: 3,
      child: Scaffold(
        backgroundColor: lightBlue,
        bottomNavigationBar: TabBar(
          controller: tabController,
          labelStyle: navi.copyWith(color: darkBlue),
          unselectedLabelColor: darkBlue,
          labelColor: darkBlue,
          indicatorColor: darkBlue,
          tabs: choices.map((TabScreen choice) {
            return Tab(
              text: choice.title,
              icon: Icon(choice.icon, color: darkBlue),
            );
          }).toList(),
        ),
        body: TabBarView(
          controller: tabController,
          children: <Widget>[
            FirstScreen(),
            SecondScreen(),
            ThirdScreen(),
          ],
        ),
      ),
    );
  }
}

The problematic Screen (FirstScreen):

class FirstScreen extends StatelessWidget {
  final TabController tabController;
  final User user;

  FirstScreen ();

  @override
  Widget build(BuildContext context) {
    final user = Provider.of<User>(context);
    print('**************************************** ${user.stats}');  //Here I can see the problem
    
    return SomeBigWidget(user: user);
  }
}

The print(${user.stats}) shows the incomplete map (stats) and the build() is never called again (with the remaining data) so the User object remains incomplete. It is only called twice (and the data returned with the complete object) when reloading or launching the app.

Any solution will be welcome!

PD: I found a simpler way to reproduce the situation. No need to change tabs:

Inside FirstScreen() I have a column of StatelessWidgets. If I call Provider.of<User>(context) within one of them I get an incomplete version of the object. The user.stats map has half of the key-value pairs that it has when accesing it through Provider.of some widgets above.

This is how the stream is updated from Firebase. (the object is created every time):

Stream<User> get user {
    Stream<User> userStream = usersCollection.document(uid).snapshots().map((s) => User.fromSnapshot(s));
   
    return userStream;
  }

I also have updateShouldNotify = (_,__) => true;

The User model:

class User {
  String uid;
  String name;
  String country;

  Map stats;

  User.fromUID({@required this.uid});


  User.fromSnapshot(DocumentSnapshot snapshot)
      : uid = snapshot.documentID,
        name = snapshot.data['name'] ?? '',
        country = snapshot.data['country'] {
    try {
      stats = snapshot.data['stats'] ?? {};
    } catch (e) {
      print('[User.fromSnapshot] Exception building user from snapshot');
      print(e);
    }
  }

}

This is the stats data Map in Firestore: stats map

1 Answers

Although firebase does allow you to use a map as a type for you to use I don't think it is your best option in this case, as it seems to be confusing.

There are multiple solutions to your problem.

The best one I think is to make a collection side by side your users collection called "stats" and have the docid of each stat = the userid of the user with that stats, this makes querying easier for you in the future.

after this you can use the method you have already used to get the stats just like you get the users.

class Stat {
  String statid;
  int avg;
  int avg_score;
  List<int> last_day;
  int n_samples;
  int total_score;
  int words;

 



  Stat.fromSnapshot(DocumentSnapshot snapshot){
     statid = snapshot.documentID,
     avg = snapshot.data['average'] ?? '',
     //rest of data
  }
        
}

perhaps the simple solution that you need is to change your map in the User class to this.

Map<String, dynamic> stats = Map<String, dynamic>();

remove the try and catch block, then you should be able to access your data like this user.stats['average'];

hope this helps, I will update my answer when I can actually put this to the test in an emulator, but if you try it and it works let me know.

Related