Dogly - a mobile app with Flutter: user profile

8 minute read

Introduction

Currently, users can create an account for the Dogly application. The goal of this post is to give them the possibility to edit profile information - avatar and name. The avatar, email, and user name should be displayed at the top of the navigation drawer. On click, it should navigate to the edit profile page. This page should have also implemented the logout button. I hope this post will be shorter than the previous one.

Let’s get to work!

UserAPI

In the previous post, the user model and the concrete implementation of the interface with the method for saving data to the database. I won’t show it again here, but here is an abstract interface reminder:

abstract class IUserAPI {
  FutureEitherVoid saveUserData(UserModel userModel);
  Future<UserModel> getUserData(String uid);
  FutureEitherVoid updateUserData(UserModel userModel);
}

The updateUserData method is quite similar to the saveUserData, it just uses updateDocument instead of createDocument.

 @override
  FutureEitherVoid updateUserData(UserModel userModel) async {
    try {
      await _db.updateDocument(
        databaseId: AppwriteConstants.databaseId,
        collectionId: AppwriteConstants.usersCollection,
        documentId: userModel.uid,
        data: userModel.toMap(),
      );
      return right(null);
    } on AppwriteException catch (e, st) {
      return left(Failure(
        e.message ?? 'Some unexpected error',
        st,
      ));
    } catch (e, st) {
      return left(Failure(e.toString(), st));
    }
  }

The getUserData method uses getDocument with provided user id which equals the document id. It is deserialized to the user model using the fromMap method.

  @override
  Future<UserModel> getUserData(String uid) async {
    final user = await _db.getDocument(
        databaseId: AppwriteConstants.databaseId,
        collectionId: AppwriteConstants.usersCollection,
        documentId: uid);
    return UserModel.fromMap(user.data);
  }

I also create a provider for current user details, because I would like to have it accessible in a simple way from the whole code.

final currentUserDetailsProvider = FutureProvider<UserModel?>((ref) async {
  ref.watch(userProfileControllerProvider);
  final currentUser = ref.watch(currentUserProvider).value;
  if (currentUser != null) {
    final userAPI = ref.watch(userAPIProvider);
    return userAPI.getUserData(currentUser.$id);
  }
  return UserModel.empty;
});

It watches for the changes in currentUserProvider. I created it above the authentication repository. It just provides the current user object or null using the getCurrentUser method.

final currentUserProvider = FutureProvider<User?>((ref) async {
  ref.watch(authControllerProvider);
  final authRepository = ref.watch(authAPIProvider);
  return authRepository.currentUserAccount();
});

If the value is null it returns the empty user model object. I think it is better to manage the empty objects in the UI than the nulls. I am sorry if I am wrong. The provider is refreshed when the state of the user profile controller changes.

ProfileStorageAPI

The ProfileStorageAPI is an interface for communicating with the object storage bucket with the profile photos. It has just one method uploadImage so super simple. Here is the whole code:

final profileStorageAPIProvider = Provider((ref) {
  return ProfileStorageAPI(
    storage: ref.watch(appwriteStorageProvider),
  );
});

class ProfileStorageAPI {
  final Storage _storage;
  ProfileStorageAPI({required Storage storage}) : _storage = storage;

  Future<String> uploadImage(File file) async {
    final uploadedImage = await _storage.createFile(
      bucketId: AppwriteConstants.profilesBucket,
      fileId: ID.unique(),
      file: InputFile.fromPath(path: file.path),
    );
    return AppwriteConstants.imageUrl(
        AppwriteConstants.profilesBucket, uploadedImage.$id);
  }
}

Uploading an image requires a file as an input argument. The id is generated with the unique method, the same as for the user. It returns the URL as a string because it needs to be assigned to the attribute in the user model.

User profile controller

The user profile feature is not the most complex one. It just has one method for updating the profile. It needs two different repositories, but I think it is simple enough to completely skip the application layer.

final userProfileControllerProvider =
    StateNotifierProvider<UserProfileController, bool>((ref) {
  return UserProfileController(
      userAPI: ref.watch(userAPIProvider),
      storageAPI: ref.watch(profileStorageAPIProvider));
});

class UserProfileController extends StateNotifier<bool> {
  final UserAPI _userAPI;
  final ProfileStorageAPI _storageAPI;

  UserProfileController(
      {required UserAPI userAPI, required ProfileStorageAPI storageAPI})
      : _userAPI = userAPI,
        _storageAPI = storageAPI,
        super(false);

  void updateUserProfile({
    required UserModel userModel,
    required BuildContext context,
    required File? profileFile,
  }) async {
    state = true;

    if (profileFile != null) {
      final imgUrl = await _storageAPI.uploadImage(profileFile);
      userModel = userModel.copyWith(profilePic: imgUrl);
    }

    final res = await _userAPI.updateUserData(userModel);
    state = false;
    res.fold(
        (l) => showSnackBar(context, l.message), (r) => Navigator.pop(context));
  }
}

If the user wants to change his profile avatar - it is not null, the method uploads it and copies the user model with a new image URL. Other data to update is provided in the input user model. If there is an error it shows the snack bar with the message, otherwise it closes the view.

User profile view

The view is a ConsumerStatefulWidget because it needs to handle texts. It shall have a route defined because it will be accessible from the sign-in button.

class UserProfileView extends ConsumerStatefulWidget {
  static route() =>
      MaterialPageRoute(builder: (context) => const UserProfileView());
  const UserProfileView({super.key});

  @override
  ConsumerState<ConsumerStatefulWidget> createState() =>
      _UserProfileViewState();
}

class _UserProfileViewState extends ConsumerState<UserProfileView> {
  late TextEditingController nameController;
  File? profileFile;

  @override
  void initState() {
    super.initState();
    nameController = TextEditingController(
        text: ref.read(currentUserDetailsProvider).value?.name ?? '');
  }

  @override
  void dispose() {
    super.dispose();
    nameController.dispose();
  }

As you can see it has a late controller variable. It means it will be initialized later. In the initState it reads the value from the currentUserDetailsProvider and assigns it to the controller, so if the user moves to the user profile view, the name text field is already filled.

The view displays the profile image also. I create the void function that will manage to pick the image:

  void selectProfileImage() async {
    final profileImage = await pickImage();
    if (profileImage != null) {
      setState(() {
        profileFile = profileImage;
      });
    }
  }

It uses the pickImage function. I keep it in the utils directory of the core.

Future<File?> pickImage() async {
  final ImagePicker picker = ImagePicker();
  final imageFile = await picker.pickImage(source: ImageSource.gallery);
  if (imageFile != null) {
    return File(imageFile.path);
  }
  return null;
}

The image_picker is an external plugin. It can be installed with flutter pub get image_picker. It makes picking images from the library or with the camera a really easy task.

The user profile view needs also access to the logout method.

void onLogout() {
    ref.read(authControllerProvider.notifier).logout(context);
  }

It just calls the method from the authentication controller.

The last thing to do is the widget. It watches for user details provider. It is a simple column with a circle avatar which is an image picker on click also, a text field, and two buttons - for saving the changes and logout.

@override
  Widget build(BuildContext context) {
    final user = ref.watch(currentUserDetailsProvider).value;

    return Scaffold(
        appBar: AppBar(
          title: const Text('Edit profile'),
          centerTitle: true,
        ),
        body: Column(
          children: [
            SizedBox(
              height: 150,
              child: Center(
                child: Stack(children: [
                  GestureDetector(
                      onTap: selectProfileImage,
                      child: profileFile != null
                          ? CircleAvatar(
                              backgroundImage: FileImage(profileFile!),
                              radius: 45,
                            )
                          : CircleAvatar(
                              backgroundImage: NetworkImage(user!.profilePic),
                              radius: 45,
                            )),
                  const Positioned(
                      bottom: 2,
                      right: 8,
                      child: Icon(
                        Icons.edit_square,
                        size: 20,
                      ))
                ]),
              ),
            ),
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 20),
              child: MyFormField(
                controller: nameController,
                hintText: "What's your name?",
              ),
            ),
            const SizedBox(height: 20),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                RoundedTinyButton(
                  onTap: () {
                    ref
                        .read(userProfileControllerProvider.notifier)
                        .updateUserProfile(
                            userModel:
                                user!.copyWith(name: nameController.text),
                            context: context,
                            profileFile: profileFile);
                  },
                  label: 'Save',
                ),
                const SizedBox(width: 15),
                RoundedTinyButton(
                  onTap: onLogout,
                  label: 'Logout',
                ),
              ],
            )
          ],
        ));
  }

On clicking the save button it copies the user model with a value from the text form field. I am sure the user value is not null, because if it is empty, this view is not accessible from UI. The whole view is presented on the screen below.

profile

The navigation drawer is a part of the home page view. It is strictly connected with the user profile because it displays the user info at the top and it is also the only way to access the view created in the previous section.

class MyNavigationDrawer extends ConsumerWidget {
  final List<Map<String, dynamic>> pages;

  const MyNavigationDrawer({super.key, required this.pages});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final currentUser = ref.watch(currentUserDetailsProvider).value;

    return SizedBox(
        width: MediaQuery.of(context).size.width * 0.5,
        child: Drawer(
            child: ListView(children: [
          currentUser == null
              ? const DrawerHeader(child: Loader())
              : currentUser.isEmpty
                  ? DrawerHeader(
                      child: RoundedTinyButton(
                        label: 'Sign in!',
                        onTap: () {
                          Navigator.pop(context);
                          Navigator.push(context, SignUpView.route());
                        },
                      ),
                    )
                  : UserAccountsDrawerHeader(
                      accountName: Text(currentUser.name),
                      accountEmail: Text(currentUser.email),
                      currentAccountPicture: CircleAvatar(
                        backgroundImage: NetworkImage(currentUser.profilePic),
                        radius: 45,
                      ),
                      onDetailsPressed: () {
                        Navigator.pop(context);
                        Navigator.push(context, UserProfileView.route());
                      }),
          ...pages.map((page) {
            final Widget pageWidget = page['page'];
            final Widget icon = page['icon'];
            final String title = page['title'];
            return ListTile(
                leading: icon,
                title: Text(title),
                onTap: () {
                  Navigator.pop(context);
                  Navigator.push(
                      context,
                      MaterialPageRoute(
                          builder: (BuildContext context) => pageWidget));
                });
          }),
          const AboutListTile(
            icon: FaIcon(FontAwesomeIcons.circleInfo),
            applicationName: InfoConstants.appName,
            applicationVersion: InfoConstants.appVersion,
            applicationLegalese: InfoConstants.appCompany,
            child: Text('About'),
          )
        ])));
  }
}

The stateless consumer widget takes a list of maps as an argument. I use it to quickly add a page with icons and titles to the drawer. It watches the future provider with user details. On loading the provided value is null, so the loader is rendered in the header of the drawer. If the returned value is an empty user model, the sign-in button is in the header. This is a reason why the empty object is returned when there is no user authenticated instead of null, the loader can be rendered instead of the weird blinking button in some edge cases. If there is a user object with data, the header of the drawer contains the account name, email, and a circle avatar. Clicking on details pushes the user to the user profile view. At the bottom of the drawer is the About section. It contains basic information about the app, like name, version, author, and used packages. It looks like this in the UI (please do not care about other things on the screen, I didn’t figure out instantly that screenshots for the presentation layer might be a good idea :O):

drawer

Conclusion

The application is starting to take a real shape. I know the user can edit just two things - name and avatar. It is not so much. In the professional application, I would also implement a possibility to reset the password or delete an account. Authenticating with Google or social media services would also good idea. Oauth2 is even really easy with Appwrite, but it requires some extra work on the third party side. I do not think the app that will never go live needs it so much. In the next post, I will implement the first page visible in the app - dog management. Cya there!

References

  1. https://blog.logrocket.com/how-to-add-navigation-drawer-flutter/
  2. https://www.freecodecamp.org/news/flutter-app-development-create-a-twitter-clone/