2

I have a DataProvider and a FutureBuilder.

class _UserHeaderState extends State<UserHeader> {

  @override
  Widget build(BuildContext context) {
    var _dataProvider = context.watch<DataProvider>();
    var _userProfile = _dataProvider.getUserProfile();

    return FutureBuilder<UserProfile>(
      future: _userProfile,
      builder: (context, snapshot) {
...

My DataProvider.photoURL has profile URL and DataProvider.updatePhotoURL() updates it. DataProvider.getUserProfile() returns UserProfile class, and UserProfile.name is user's name and so on.

I made a button to update profile image using imagepicker. In onPressed, I wrapped DataProvider.updatePhotoURL(); and _userProfile = DataProvider.getUserProfile(); with setState.

Desired output is that when the user selects photo from imagepicker my CircleAvatar should show the newly picked photo right away.

Actual output is that CircleAvatar shows the old photo until I hit hot reload or visits another page and comeback.

Seems like combination of setState() and FutureBuilder snapshot is messed up, but can't figure out how to fix it.

Entire code is below. (excluding DataProvider and UserProfile class)

class UserHeader extends StatefulWidget {

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

class _UserHeaderState extends State<UserHeader> {

  @override
  Widget build(BuildContext context) {
    var _dataProvider = context.watch<DataProvider>();
    var _userProfile = _dataProvider.getUserProfile();

    return FutureBuilder<UserProfile>(
      future: _userProfile,
      builder: (context, snapshot) {
        Widget body = Center(child: null);

        if (snapshot.hasError) {
          print(snapshot.error);
          body = Center(child: ErrorPage(context));
        } else if (!snapshot.hasData) {
          body = Center(child: CircularProgressIndicator());
        } else {
          body = Padding(
            padding: EdgeInsets.all(16),
            child: Row(
                children: [
                  CircleAvatar(
                    backgroundImage: NetworkImage(snapshot.data!.photoURL),
                    radius: 40,
                    child: Stack(
                        children: [
                          Align(
                            alignment: Alignment.bottomRight,
                            child: RawMaterialButton(
                              elevation: 1.0,
                              fillColor: Colors.grey[800],
                              child: Icon(Icons.add_a_photo_rounded, size: 16),
                              shape: CircleBorder(),
                              materialTapTargetSize: MaterialTapTargetSize
                                  .shrinkWrap,
                              padding: EdgeInsets.all(6),
                              constraints: BoxConstraints(minWidth: 0),
                              onPressed: () {
                                print('update photo button pressed');
                                setState(() {
                                  _dataProvider.updatePhotoURL();
                                  _userProfile = _dataProvider.getUserProfile();
                                });
                              },
                            ),
                          )
                        ]
                    ),
                  ),
                ],
              ),
          );
        }
        return body;
      }
    );
  }
}
Younghak Jang
  • 449
  • 3
  • 8
  • 22
  • 1
    Using `setState` to update `_userProfile` will not trigger a rebuild, since `_userProfile` is a local variable inside `build`. It should be a member of `_UserHeaderState` class. – Peter Koltai Nov 03 '21 at 17:11

4 Answers4

5

I encoutered this same problem before...

When you change the definition of Future (in _userProfile), you expect the future to update... but nothing happens...

The reason is that the Future will NOT update the snapshot.hasError, nor snapshot.hasData nor the else... The only thing that changes is ConnectionState.done

Solution:

add a new condition after if (snapshot.hasError) that will include ConnectionState.done. Then when you setState, it will rebuild properly

More information in this excellent explanation

Let me know if you still have trouble.

Canada2000
  • 1,688
  • 6
  • 14
  • Further clarification. `snapshot.hasError` is true if the call throws an exception. `snapshot.hasData` is false if the call returned a null. `ConnectionState.done` is true when the call is finished (returns a value, throws an exception or returns null). – Roslan Amir Nov 04 '21 at 03:17
  • Do you mean if (snapshot.hasError) { body = Center(child: ErrorPage(context)); } else if (snapshot.connectionState != ConnectionState.done) { body = Center(child: CircularProgressIndicator()); } else { body = Padding( ... or, if (snapshot.hasError) { body = Center(child: ErrorPage(context)); } else if (!snapshot.hasData) { body = Center(child: CircularProgressIndicator()); } else if (snapshot.connectionState == ConnectionState.done) { body = Padding( ... this? Unfortunately neither works – Younghak Jang Nov 04 '21 at 03:28
  • No. See this code it will work `if (snapshot.hasError) {//error...} else if (!snapshot.hasData) {//loading...} else if (snapshot.connectionState == ConnectionState.done) {//normal code containing your setState (that used to be in the 'else")...}` – Canada2000 Nov 04 '21 at 04:05
  • that version still doesn't work... – Younghak Jang Nov 04 '21 at 04:13
  • can you paste your code here? – Canada2000 Nov 04 '21 at 04:56
2

After reading comments here and FutureBuilder class documentation, I rewrote my code. Firebase Realtime Database youtube also helped me a lot.

  1. Variables were inside my build method because I had provider and needed context to load them(@il_boga). After all, I didn't need provider. I made a userProfileHandler class that does things my provider did, and just made a new instance of it.

  2. Documentation says combining FutureBuilder and calling SetState later is not a good choice. So I decided to use StreamBuilder instead of FutureBuilder.

  3. Since I'm not calling SetState, I changed entire widget to StatelessWidget.

Now my photo updates when I pick new photo without reloading or visiting other page.

StreamBuilder code:

Widget UserHeader() {   // it's inside StatelessWidget class UserPage()
    return StreamBuilder(
        stream: _userProfileHandler.userProfileStream(),  // returns Stream<UserProfile>
        builder: (context, snapshot) {
          //error, loading
          if (snapshot.hasError) {
            print(snapshot.error);
            return Center(child: ErrorPage(context));
          } else if (snapshot.connectionState == ConnectionState.waiting) {
            return LoadingGif();
          }

          UserProfile userProfile = snapshot.data as UserProfile;

          return Padding(
              padding: EdgeInsets.all(16),
              child: Row(
                  children: [
                    // Circle Avatar with edit button which updates userProfile and Realtime Database behind
                    ProfilePhotoWithEdit(userProfile.photoURL),                   
                  ]
              )
          );
        }
    );
  }

Handler code:

class UserProfileHandler {
  late final DatabaseReference _database;
  late final String _uid;
  late final firebase_storage.FirebaseStorage _storage;

  UserProfileHandler() {
    _database = FirebaseDatabase.instance.reference();
    _uid = FirebaseAuth.instance.currentUser!.uid;
    _storage = firebase_storage.FirebaseStorage.instance;
  }

  Stream<UserProfile> userProfileStream() {
    final stream = _database.child('/users/$_uid').onValue;

    final Stream<UserProfile> resultStream = stream.map((event) {
      return UserProfile.fromRTDB(Map<String, dynamic>.from(event.snapshot.value));
    });

    return resultStream;
  }

  Future<void> updatePhotoURL() async {
    
    //pick new profile image
    var image = await ImagePicker.platform.pickImage(source: ImageSource.gallery);
    File file = File(image!.path);

    //upload and get URL
    await _storage.ref('profile-photo/$_uid.png').putFile(file);
    String URL = await _storage.ref('profile-photo/$_uid.png').getDownloadURL();

    //update photoURL
    _database.child('/users/$_uid').update({'photoURL': URL,});
  }
}
Younghak Jang
  • 449
  • 3
  • 8
  • 22
1

This is how I would do it:

return FutureBuilder<UserProfile>(
  future: _userProfile,
  builder: (context, snapshot) {
    if (snapshot.connectionState != ConnectionState.done) {
      return const Center(child: CircularProgressIndicator());
    }
    if (snapshot.hasError) {
      print(snapshot.error);
      return Center(child: ErrorPage(context));
    }
    if (!snapshot.hasData) {
      print("_userProfile returns null!");
      return Center(child: ErrorPage(context));
    }
    final userProfile = snapshot.data as UserProfile; // cast to UserProfile
    return Padding(...); // use userProfile in here
  }
}
Roslan Amir
  • 1,141
  • 8
  • 16
1

I think that the problem lies in the fact that _userProfile (and _dataProvider) are defined as variables in your build method, while they should be fields of your state class. The setState method, as far as I know, affects only the state fields, not variables inside methods. You should try changing your code like this:

[...]
class _UserHeaderState extends State<UserHeader> {
var _dataProvider = context.watch<DataProvider>();
var _userProfile = _dataProvider.getUserProfile();

@override
Widget build(BuildContext context) {       
   return FutureBuilder<UserProfile>(
[...]

Credits to Peter Koltai for pointing this out in the comments

il_boga
  • 1,305
  • 1
  • 13
  • 21