GrandPrix Circus - add a post-view

6 minute read

Introduction

Hello, this is the last post of the series. I will create a view that will let the user add a post with a custom title and an image from the gallery. The feature will use the Firebase storage. It should be able to compress the image to avoid uploading huge files. The user will have a preview of the post on the add-a-post screen. I will implement only a way to add a post. In the official product, it would be good to give the user tools to remove or update a post. The post will be super short.

Let’s get to work!

Data layer

Let’s start with the core provider for the Firebase storage instance:

@riverpod
FirebaseStorage firebaseStorage(FirebaseStorageRef ref) =>
    FirebaseStorage.instance;

The repository for my case requires just one method for uploading an image. In the complete application, it would be good to add the methods for removing or editing the posts.

abstract class IStorageAPI {
  Future<String> uploadImage(File filePath, String uid);
  // Future<void> deleteImage(String imgURL);
}

class StorageAPI implements IStorageAPI {
  final FirebaseStorage _storage;

  StorageAPI({required FirebaseStorage storage}) : _storage = storage;

 // @override
 // Future<void> deleteImage(String imgURL) async {
 //   await _storage.refFromURL(imgURL).delete();
 // }

  @override
  Future<String> uploadImage(File filePath, String uid) async {
    final dateTime = DateTime.now().toIso8601String();
    final ref = _storage.ref('$uid/$dateTime');
    final uploadImage = await ref.putFile(filePath);
    return await uploadImage.ref.getDownloadURL();
  }
}

And the provider:

@riverpod
StorageAPI storageAPI(StorageAPIRef ref) {
  return StorageAPI(storage: ref.watch(firebaseStorageProvider));
}

The post creation also uses the repository for FastAPI which was shown in the previous posts. It uses just one method:

  @override
  Future<void> createPost(PostModel postModel) async {
    await _dio.post('$_baseUrl/content/',
        data: postModel.toJson(), options: _options);
  }

That’s all for the data layer.

Controllers

The post view needs to have two controllers for executing a method from two repositories. Probably it could be done via one controller, but in my opinion, it would be more convenient if the number of methods in controllers grows.

@riverpod
class PostCreationController extends _$PostCreationController {
  @override
  FutureOr<void> build() {}

  Future<void> createPost(PostModel postModel) async {
    final contentAPI = ref.read(contentAPIProvider);
    state = const AsyncLoading();
    state = await AsyncValue.guard(() => contentAPI.createPost(postModel));
  }
}
@riverpod
class StorageController extends _$StorageController {
  @override
  FutureOr<String> build() => '';

  FutureOr<void> uploadImage(File filePath, String uid) async {
    final storageAPI = ref.read(storageAPIProvider);
    state = const AsyncLoading();
    state = await AsyncValue.guard(() {
      final res = storageAPI.uploadImage(filePath, uid);
      return res;
    });
  }
}

The post controller has a void state. If I would like to add the other CRUD methods, they would return nothing. That’s because I do not need to access the returned post model in the post-creation view. The feature just adds a post that is visible in the post lists where content is fetched from the database.

The storage controller keeps the URL string in the state. Adding other methods would be problematic, for example method from deleting does not return anything. The workaround would be returning the future with an empty string, like this:

  Future<void> deleteImage(String imgURL) async {
    final storageAPI = ref.read(storageAPIProvider);
    state = const AsyncLoading();
    state = await AsyncValue.guard(() {
      storageAPI.deleteImage(imgURL);
      return Future(() => '');
    });
  }

I have no idea if this is a clean solution (I guess it is not). This is another example of a problem with limited discussion in society or not many examples in the documentation. It might be a bit uncomfortable for beginners.

Presentation layer

The post-creation view is a stateful consumer widget. It keeps the text editing controller, the state of image loading, and the loaded file. It has just one method, for selecting an image:

class PostCreationView extends ConsumerStatefulWidget {
  const PostCreationView({super.key});

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

class _PostCreationViewState extends ConsumerState<PostCreationView> {
  final titleController = TextEditingController();
  File? imageFile;
  bool imageLoading = false;

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

  void selectImage() async {
    final image = await pickImage();

    if (image != null) {
      setState(() {
        imageLoading = true;
      });

      final compressedImage = await compressImage(image);

      setState(() {
        imageFile = compressedImage;
        imageLoading = false;
      });
    }
  }

The functions for picking and compressing images are pretty standard, mostly copied from the internet. They look as follows:

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

Future<File> compressImage(File originalImage) async {
  final List<int> imageBytes = await originalImage.readAsBytes();
  final Uint8List uint8ImageBytes = Uint8List.fromList(imageBytes);

  final img.Image image = img.decodeImage(uint8ImageBytes)!;

  final img.Image compressedImage = img.copyResize(image, width: 540);

  final Uint8List compressedBytes =
      Uint8List.fromList(img.encodeJpg(compressedImage));
  final File compressedFile = File(originalImage.path)
    ..writeAsBytesSync(compressedBytes);

  return compressedFile;
}

The widget watches for changes in the state of three providers: storage controller, post-creation controller, and authentication. The first two can have standard loading, error, and data states. The Riverpod does not give a clean solution for handling states of two async notifiers at once, but it can be resolved using standard Flutter/Dart syntax.

  @override
  Widget build(BuildContext context) {
    final storageStatus = ref.watch(storageControllerProvider);
    final postCreationStatus = ref.watch(postCreationControllerProvider);
    final currentUser = ref.watch(authAPIProvider).currentUser;

    return Scaffold(
        appBar: AppBar(
          title: const Text("Add a post šŸ’Ž"),
        ),
        body: (storageStatus is AsyncError || postCreationStatus is AsyncError)
            ? const Center(child: Text("Error"))
            : (storageStatus is AsyncLoading ||
                    postCreationStatus is AsyncLoading)
                ? const Center(child: CircularProgressIndicator())
                : SingleChildScrollView(
                    child: Column(
                      children: [
                        const SizedBox(
                          height: 8,
                        ),
                        Padding(
                          padding: const EdgeInsets.all(8.0),
                          child: TextFormField(
                            maxLength: 70,
                            keyboardType: TextInputType.text,
                            decoration: InputDecoration(
                              hintText: 'Title',
                              contentPadding: const EdgeInsets.symmetric(
                                  horizontal: 8, vertical: 8),
                              prefixIcon: const Icon(Icons.title),
                              focusedBorder: OutlineInputBorder(
                                borderRadius: BorderRadius.circular(8),
                                borderSide: const BorderSide(width: 1),
                              ),
                              enabledBorder: OutlineInputBorder(
                                borderRadius: BorderRadius.circular(8),
                                borderSide: const BorderSide(width: 1),
                              ),
                            ),
                            controller: titleController,
                          ),
                        ),
                        CommonButton(
                            onTap: selectImage, label: 'Select an image'),
                        Column(
                          children: [
                            ValueListenableBuilder(
                              valueListenable: titleController,
                              builder: (context, title, child) {
                                return Row(
                                  mainAxisAlignment: MainAxisAlignment.center,
                                  children: [
                                    SizedBox(
                                      width: MediaQuery.of(context).size.width *
                                          0.8,
                                      child: Text(
                                        title.text.isEmpty
                                            ? "Your title"
                                            : title.text,
                                        style: const TextStyle(
                                          fontWeight: FontWeight.bold,
                                        ),
                                        softWrap: true,
                                        maxLines: 2,
                                      ),
                                    ),
                                  ],
                                );
                              },
                            ),
                            const SizedBox(height: 8),
                            imageLoading == true
                                ? Container(
                                    decoration:
                                        BoxDecoration(border: Border.all()),
                                    height: MediaQuery.of(context).size.height *
                                        0.4,
                                    width:
                                        MediaQuery.of(context).size.width * 0.8,
                                    child:
                                        const Center(child: Text("Loading...")),
                                  )
                                : imageFile != null
                                    ? Image.file(
                                        imageFile!,
                                        width:
                                            MediaQuery.of(context).size.width *
                                                0.8,
                                        fit: BoxFit.cover,
                                      )
                                    : Container(
                                        decoration:
                                            BoxDecoration(border: Border.all()),
                                        height:
                                            MediaQuery.of(context).size.height *
                                                0.4,
                                        width:
                                            MediaQuery.of(context).size.width *
                                                0.8,
                                        child: const Center(
                                            child: Text("Your image")),
                                      ),
                          ],
                        ),
                        const SizedBox(
                          height: 8,
                        ),
                        CommonButton(
                          onTap: () async {
                            if (imageFile == null ||
                                titleController.text.isEmpty) {
                              showDialog(
                                context: context,
                                builder: (BuildContext context) {
                                  return AlertDialog(
                                    title: const Text("Incomplete information"),
                                    content: const Text(
                                        "Please select an image and enter a title."),
                                    actions: [
                                      TextButton(
                                        onPressed: () {
                                          Navigator.of(context).pop();
                                        },
                                        child: const Text("OK"),
                                      ),
                                    ],
                                  );
                                },
                              );
                            } else {
                              final uid = currentUser!.uid;

                              await ref
                                  .read(storageControllerProvider.notifier)
                                  .uploadImage(imageFile!, uid);

                              final imgUrl =
                                  ref.read(storageControllerProvider);

                              await ref
                                  .read(postCreationControllerProvider.notifier)
                                  .createPost(
                                    PostModel(
                                        id: '',
                                        creationTime: DateTime.now(),
                                        author: uid,
                                        title: titleController.text,
                                        contentId: imgUrl.value!,
                                        tag: "users",
                                        source: getNameFromEmail(
                                            currentUser.email!),
                                        likesCount: 0,
                                        dislikesCount: 0),
                                  );

                              setState(() {
                                imageFile = null;
                                titleController.clear();
                              });
                            }
                          },
                          label: "Add",
                        ),
                      ],
                    ),
                  ));
  }
}

In my opinion, the code looks less readable, but I didn’t figure out any better way. The widget displays a default title and image placeholder if they are not filled. The user must provide both of them to click the ‘add’ button.

The other important thing is to add a listener to the posts list widget, so it can refresh if a new post is added by the user.

    ref.listen(postCreationControllerProvider, (previous, next) {
      Future.sync(() => _pagingController.refresh());
    });

The page should be also included in the navigation drawer.

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: UIConstants.titleAppBar(context),
        drawer: const MyNavDrawer(
          pages: [
            {
              'page': PostCreationView(),
              'icon': Icon(Icons.add_circle),
              'title': 'Add a post'
            },
            {
              'page': SettingsView(),
              'icon': Icon(Icons.settings),
              'title': 'Settings',
            }
          ],
        ),
        body: const PostsList());
  }
}

The view is presented like this: empty

filled

Conclusion

The whole demo application is created. No worries, developing the application didn’t take me a whole month; I’m just finding it difficult to write a blog lately. I’m reasonably satisfied, but creating a fully-fledged product would be much more challenging and would require a more extensive conceptual process. What I miss the most is professional design, as my artistic sense is at a very low level. In addition to the features mentioned in each part of the series, I would definitely implement a well-thought-out analytical system, allowing for quick and specific insights. It would also be essential to obfuscate the code and secure secret keys in some way. I would like the next project to be more related to data engineering, as this year the blog was lacking in that aspect. Cya in the next post!