← Back to blog

Image optimization with Firebase Extensions

Mais Alheraki

Open Source Engineer

31st October, 2022

Need a custom tool? Invertase can help

Tired of investing in off-the-shelf software that doesn't quite fit your business? Invertase can build solutions tailored to your exact needs. Tell us what you're looking for here and we'll be in touch.

Introduction

Have you wondered how hard it is to build an app similar to LinkedIn or Twitter? Probably much work is needed, but what about using tools to help you make it faster?

In this blog, the use-case discussed is about images and Cloud Storage triggers. You will start from scratch, imagining an app where people can tell stories through words and pictures. The app will utilize Flutter and Firebase features to minimize effort and build scalable solutions.

Project Overview

Storyteller

The project name will be “Storyteller” which will evolve gradually in a series of blogs, where we will demonstrate the usage of different extensions in building real-world apps.

For this tutorial, a user can see the latest posts published on the home page and add new posts with images.

What happens when users upload images that are so big in size? Your Cloud Storage bill will go higher, loading time for users will be longer, and consuming more of their “possibly” limited data plan. In Storyteller’s case, this means waiting on the home page for the number of visible posts to load its images. Therefore, you need to find an effective solution to solve these issues.

Luckily, the issue can be addressed quickly using Firebase Extensions! Let’s discover how.

Let’s start building!

Setting up environment

The basic requirements in this tutorial:

  1. Has Flutter been installed on your machine?

  2. Have the Firebase CLI installed? If not, install it here.

  3. Have the FlutterFire CLI installed, if not, install it by running the following command:

    dart pub global activate flutterfire_cli 
  4. A Google account to create a Firebase project.

If this is your first time using the Firebase CLI, you need to log in to your Google account so you can access and create new projects:

firebase login

Create a new Flutter project

To create a new Flutter project, run the following:

flutter create resize_images_sample

Add required Flutter packages

This sample requires five packages:

# For Firebase integration
flutter pub add firebase_core
flutter pub add firebase_storage
flutter pub add cloud_firestore

# For state management
flutter pub add flutter_riverpod

# To allow users to pick images
flutter pub add image_picker

Set up Firebase for Flutter

The FlutterFire CLI does the whole setup without the need to go to the console for Flutter projects.

In the root, run:

flutterfire configure

This will register new apps for the targeted platforms. You can choose iOS, Android, and web for now.

Storyteller home page

Now that you have a Flutter app with your Firebase project, time to build. The first part is the home page, where a grid of the latest stories will be shown. If a story has an image, this image will also show as a thumbnail. Stories are stored in Firestore, and images are uploaded to Firebase Storage.

In main.dart, erase all the code that already exists, and start with the following:

import 'package:flutter/material.dart'

import 'firebase_options.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Firebase initialization
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Storyteller',
      theme: ThemeData(
        primaryColor: const Color(0xff1A73E9),
      ),
      home: const HomePage(),
    );
  }
}

The HomePage is a StatefulWidget, and will be the home page. Let’s start with an empty home page. Create a new Stateful widget, and add the following to the build method:

@override
Widget build(BuildContext context) {
  return AnnotatedRegion(
    value: SystemUiOverlayStyle.dark,
    child: Scaffold(
      body: Column(
        children: [
          StorytellerAppBar(onNewStoryPressed: _pushAddStory),
          Container(
            alignment: AlignmentDirectional.centerStart,
            padding: const EdgeInsets.symmetric(
              horizontal: AppPadding.large,
              vertical: AppPadding.small,
            ),
            child: const Text('Latest stories'),
          ),
          const Expanded(
            child: Center(
              child: Text('No stories found'),
            ),
          ),
        ],
      ),
    ),
  );
}

The StorytellerAppBar widget is missing, so let’s add it:

class StorytellerAppBar extends StatelessWidget {
  const StorytellerAppBar({
    super.key,
    required this.onNewStoryPressed,
  });

  final VoidCallback onNewStoryPressed;

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      bottom: false,
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: AppPadding.large),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Text(
              'Storyteller',
              style: Theme.of(context).textTheme.headline5,
            ),
            Row(
              children: [
                IconButton(
                  icon: Icon(
                    Icons.add_circle_outline_rounded,
                    color: Theme.of(context).primaryColor,
                  ),
                  onPressed: onNewStoryPressed,
                ),
                IconButton(
                  icon: Icon(
                    Icons.account_circle_rounded,
                    color: Theme.of(context).primaryColor,
                  ),
                  onPressed: () {},
                ),
              ],
            )
          ],
        ),
      ),
    );
  }
}

Finally, you need a _pushAddStory method to push a new page and allow users to create new stories.

void _pushAddStory() {
  Navigator.of(context).push(
    MaterialPageRoute(
      builder: (_) => const AddStoryPage(),
    ),
  );
}

Create a new file named create_story.dart and add the page widget:

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';

import 'padding.dart';
import 'providers/stories_provider.dart';

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

  @override
  ConsumerState createState() => _AddStoryPageState();
}

class _AddStoryPageState extends ConsumerState {
  final formKey = GlobalKey<FormState>();
  final contentController = TextEditingController();
  final authorController = TextEditingController();
  final picker = ImagePicker();
  bool loading = false;

  XFile? imageFile;

  /// Validate the form, and submit the story if valid.
  void _addNewStory() async {}

  /// Let the user pick an image from their gallery.
  void _selectImage() async {}

  @override
  Widget build(BuildContext context) {
    return Stack(
      fit: StackFit.expand,
      children: [
        Scaffold(
          appBar: AppBar(
            centerTitle: false,
            backgroundColor: Colors.transparent,
            elevation: 0,
            systemOverlayStyle: SystemUiOverlayStyle.dark,
            title: const Text(
              'Add Story',
              style: TextStyle(color: Colors.black),
            ),
            automaticallyImplyLeading: false,
            leading: IconButton(
              onPressed: Navigator.of(context).pop,
              icon: const Icon(
                Icons.arrow_back_ios_new_rounded,
                color: Colors.black,
              ),
            ),
          ),
          body: SizedBox(),
  }
}

The body of this page is still empty; you will add to it in the following sections.

Story data model

To build up a grid of stories, you first need a data source and model.

A story will have a body, an author, and an image URL. Create a new file name story.dart and add the following:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'story.freezed.dart';
part 'story.g.dart';

@freezed
class Story with _$Story {
  const factory Story({
    required String content,
    required String author,
    String? imagePath,
  }) = _Story;

  factory Story.fromJson(Map json) => _$StoryFromJson(json);
}

You can see that this code uses the package freezed to create a data model. Don’t panic about the squishy red lines; you will know how to fix them now.

First, install the required packages by freezed to generate data classes:

flutter pub add freezed_annotation
flutter pub add --dev build_runner
flutter pub add --dev freezed

# if using freezed to generate fromJson/toJson, also add:
flutter pub add json_annotation
flutter pub add --dev json_serializable

Then, you can generate the Story data classes with Json serializable methods by running the following at the root of your project:

flutter pub run build_runner build

The result would be two new files:

Read and add new stories

You will use Firestore to store new stories. Therefore, you would need a service object with some methods to get and add stories. Create a new file named stories_service.dart, and add the following:

import 'dart:io';

import 'package:cloud_firestore/cloud_firestore.dart';

import '../data/story.dart';

final FirebaseFirestore _db = FirebaseFirestore.instance;

class StoryService {
    // Use the serialization methods generated by freezed to 
    // create a type-safe Firestore reference
  final storiesRef = _db.collection('stories').withConverter(
        fromFirestore: (snapshot, _) => Story.fromJson(snapshot.data()!),
        toFirestore: (story, _) => story.toJson(),
      );

  Future<QuerySnapshot> getStories() {
    return storiesRef.get();
  }
}

To get and cache the result, you can use Riverpod, which is our preference in this tutorial. However, you can do this part the way you prefer with any other state management solution.

To use Riverpod, get back to the main() function and re-write it like this:

void main() async {
  // ... Firebase initialization code here

  runApp(
    // Wrap MyApp() with ProviderScope()
    const ProviderScope(child: MyApp()),
  );
}

Create a new file named stories_provider.dart and add the following code:

import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../data/story.dart';
import '../services/stories.dart';

final storyService = Provider((_) => StoryService());

final stories = FutureProvider<List>((ref) async {
  final res = await ref.watch(storyService).getStories();

  return res.docs.map((e) => e.data()).toList();
});

You can now read and display the stories on the home page using these providers.

Back to main.dart, remove this part:

const Expanded(
  child: Center(
    child: Text('No stories found'),
  ),
),

And add this:

Expanded(
  child: ref.watch(stories).when(
        data: (stories) => stories.isEmpty
            ? const Center(
                child: Text('No stories found'),
              )
            : GridView.builder(
                padding: const EdgeInsets.symmetric(
                  horizontal: AppPadding.large,
                  vertical: AppPadding.small,
                ),
                gridDelegate:
                    const SliverGridDelegateWithFixedCrossAxisCount(
                  crossAxisCount: 2,
                  childAspectRatio: 0.74,
                  crossAxisSpacing: 10,
                  mainAxisSpacing: 10,
                ),
                itemCount: stories.length,
                itemBuilder: (context, index) => StoryBriefWidget(
                  story: Story(
                    content: stories[index].content,
                    author: stories[index].author,
                    imagePath: stories[index].imagePath,
                  ),
                ),
              ),
        loading: () =>
            const Center(child: CircularProgressIndicator()),
        error: (error, stack) => Text('Error: $error'),
      ),
),

However, ref does not exist. To have access to all providers in the app scope, you need to convert a StatefulWidget to a ConumerStatefulWidget. Re-write HomePage as:

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

  @override
  ConsumerState createState() => _ResizeImagesAppState();
}

class _ResizeImagesAppState extends ConsumerState {

Next, you would notice StoryBriefWidget is missing, either add the following code in the same file or a new file:

class StoryBriefWidget extends StatelessWidget {
  const StoryBriefWidget({
    super.key,
    required this.story,
  });

  final Story story;

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(10),
        boxShadow: [
          BoxShadow(
            color: Theme.of(context).primaryColorLight.withOpacity(0.1),
            spreadRadius: 2,
            blurRadius: 9,
            offset: const Offset(0, 5),
          ),
        ],
      ),
      height: 600,
      padding: const EdgeInsets.all(AppPadding.small),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            story.content,
            maxLines: 2,
            overflow: TextOverflow.ellipsis,
          ),
          const SizedBox(height: 10),
          Text(
            story.author,
            style: Theme.of(context).textTheme.caption,
          ),
        ],
      ),
    );
  }
}

You can notice that we are not displaying the images, yet. This will be covered in the upcoming sections.

If you run the app now, it’s still empty with no stories. To add new stories, get back to the service class and add a new method:

Future addStory(String content, String author, [File? image]) async {
  final docRef = await storiesRef
      .add(Story.fromJson({'content': content, 'author': author}));

  if (image != null) {
    final imagePath = '${docRef.path}${path.extension(image.path)}';

    await FirebaseStorage.instance.ref(imagePath).putFile(image);

    await docRef.update({'imageUrl': await _getImageUrl(imagePath)});
  }
}

Future _getImageUrl(String imagePath) async {
  final ref = FirebaseStorage.instance.ref(imagePath);

  final listResult = await ref.getDownloadURL();

  if (listResult.isEmpty) {
    return null;
  }

  return listResult;
}

The addStory() method will add the new story to Firestore, then check if there’s an image. If there’s an image, it will upload it to Storage, then get the download URL and update the story document.

Add story page

Now that the logic to add stories is done let’s get back to add_story.dart page. You need two fields, one for content, one for the author name, and a button to pick an image. However, the author’s name is to keep things simple for this tutorial; in a real-life context, the name should be taken from the currently logged-in user.

Re-write the build method in AddStory a page like this:

@override
Widget build(BuildContext context) {
  return Stack(
    fit: StackFit.expand,
    children: [
      Scaffold(
        appBar: AppBar(
          centerTitle: false,
          backgroundColor: Colors.transparent,
          elevation: 0,
          systemOverlayStyle: SystemUiOverlayStyle.dark,
          title: const Text(
            'Add Story',
            style: TextStyle(color: Colors.black),
          ),
          automaticallyImplyLeading: false,
          leading: IconButton(
            onPressed: Navigator.of(context).pop,
            icon: const Icon(
              Icons.arrow_back_ios_new_rounded,
              color: Colors.black,
            ),
          ),
        ),
        body: SingleChildScrollView(
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(AppPadding.large),
              child: Column(
                children: [
                  if (imageFile != null) ...[
                    Container(
                      decoration: BoxDecoration(
                        borderRadius: BorderRadius.circular(10),
                        image: DecorationImage(
                          image: FileImage(File(imageFile!.path)),
                          fit: BoxFit.cover,
                        ),
                      ),
                      height: 200,
                    ),
                    const SizedBox(height: AppPadding.large),
                  ],
                  TextFormField(
                    controller: contentController,
                    decoration: InputDecoration(
                      hintText: 'Start writing...',
                      border: OutlineInputBorder(
                        borderRadius: BorderRadius.circular(10),
                        borderSide: BorderSide.none,
                      ),
                      fillColor: Theme.of(context)
                          .colorScheme
                          .background
                          .withOpacity(0.1),
                      filled: true,
                    ),
                    minLines: 10,
                    maxLines: 40,
                  ),
                  const SizedBox(height: 10),
                  TextFormField(
                    controller: authorController,
                    decoration: InputDecoration(
                      hintText: 'Your name',
                      border: OutlineInputBorder(
                        borderRadius: BorderRadius.circular(10),
                        borderSide: BorderSide.none,
                      ),
                      fillColor: Theme.of(context)
                          .colorScheme
                          .background
                          .withOpacity(0.1),
                      filled: true,
                    ),
                  ),
                  const SizedBox(height: 10),
                  SizedBox(
                    width: double.infinity,
                    height: 50,
                    child: TextButton(
                      onPressed: () async {
                        if (imageFile != null) {
                          setState(() {
                            imageFile = null;
                          });
                        } else {
                          final image = await picker.pickImage(
                              source: ImageSource.gallery);

                          setState(() {
                            imageFile = image;
                          });
                        }
                      },
                      child: Row(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          Icon(imageFile != null
                              ? Icons.delete_forever_rounded
                              : Icons.camera_alt_rounded),
                          const SizedBox(width: 10),
                          Text(imageFile != null
                              ? 'Delete image'
                              : 'Pick an image'),
                        ],
                      ),
                    ),
                  ),
                  const SizedBox(height: 30),
                  SizedBox(
                    width: double.infinity,
                    height: 50,
                    child: OutlinedButton(
                      onPressed: loading
                          ? null
                          : _addNewStory,
                      child: const Text('Submit story'),
                    ),
                  )
                ],
              ),
            ),
          ),
        ),
      ),
      AnimatedSwitcher(
        duration: const Duration(milliseconds: 500),
        child: !loading
            ? const SizedBox()
            : Container(
                width: double.infinity,
                height: double.infinity,
                color: Colors.black12,
                child: const CircularProgressIndicator.adaptive(),
              ),
      )
    ],
  );
}

In _addNewStory method, you can call the story service by using the provider:

void _addNewStory() async {
  final nav = Navigator.of(context);
  setState(() {
    loading = true;
  });

  try {
    await ref.watch(storyService).addStory(
          contentController.text,
          authorController.text,
          imageFile != null ? File(imageFile!.path) : null,
        );
    ref.refresh(storyService);
    nav.pop();
  } catch (e) {
    setState(() {
      loading = false;
    });
  }
}

The image size issue

Up till now, you allowed users to upload images of any size and type without any limitation on the client side. To have better control over the size of images uploaded by users, you would need to implement it yourself, which takes time and effort. Since the images are uploaded to Firebase Storage, you can use the solutions offered by Firebase.

Currently, the image gets uploaded to Storage, then you get the public link and add it to the story document. To resize the image and save some space, there’re two steps:

  1. Use the Resize Image Extension, which will create a new smaller image in the desired location in Storage.
  2. Update the public link in Firestore.

Resizing images using Firebase Extension

The Resize Images extension will automatically resize any images uploaded to the location you tell it to watch.

Click here to install; it will prompt you to choose a Firebase project; make sure it’s the same one you’re using to store images in the previous steps.

💡 Why Blaze plan?

If your project is still on the Spark plan (free), the configuration process will ask you to upgrade.

Any Firebase project has a free monthly limit for all its products, but some require the project to be on Blaze before you can use it. This, however, does not mean you will be charged. Even if you’re on Blaze, you still have the free limit, which you are likely not to cross in this tutorial or in any small hobby project.

Once you’re on the Blaze plan, you can continue the configuration.

Click “Next.” The next step is about the resources used by this extension. Firebase extensions do a lot of work behind the scenes, so you don’t have to do it yourself. The resize images extension uses Storage, Eventarc API, and Functions to resize any image once uploaded to a Storage bucket.

Note that if these resources were not enabled for your project, you must click “Enable” for them.

Extensions do things for you. Therefore, they require access. This step tells you that the Resize Images extension will have access as a “Storage Admin,” so it can perform any storage operation on an admin level; click “Next”.

The last step is where you will configure and customize the extension. Let’s take it step by step.

Resize Images Extension Configurations

The first question is about the Cloud Function location. This extension is a cloud function trigger, and functions must have a location setting. The wizard will fill in your current location for cloud functions, but you can customize it to your needs.

Next is the Cloud Storage bucket. By default, you will see your default bucket, but there’re some important considerations here.

This extension will watch the whole bucket, meaning whatever file you upload to it on any path will trigger the extension. Thus, it’s highly advised you create a new bucket for the images you want to resize.

The preferred size depends on your use case; for this tutorial, the size needed to show on the home page is small, so 200×200 looks fine.

You can either keep or delete the original image. For this tutorial, you will keep the original images.

You also have control over the image link. In Storage, you can make links public or private. For this tutorial, you would want users to see stories even if they don’t have an account, so it’s better to make them public.

The path is where the images will live. By default, it will be a folder in your bucket named thembnails. You can change this to wherever you want.

For this tutorial, make it stories_thumbnails, and the path that will contain it is /stories.

Therefore, the resized images will live on the path /stories/stories_thumbnails.

It’s essential to specify the path here. Otherwise, as mentioned earlier, the extension will resize every image in your bucket.

The last option to highlight in this section is the image type. You can choose whether to convert an image to a specific single or multiple types. For this tutorial, leave it to original.

Finally, click the Install extension button and wait for the setup to finish.

Get the thumbnail on the client side

Now that the images are resized and saved to a location you know, you need to show them in the app.

The thumbnail is saved to a known location with a known extension, all thumbnails will have the following path scheme:

stories/stories_thumbnails/{STORY_ID}_300x300.jpeg

With this knowledge in hand, you can retrieve the link to the thumbnail using the Storage SDK. Add the following method to stories_service.dart:

Future<String> getThumbnailUrl(String imagePath) async {
  final name = path.basenameWithoutExtension(imagePath);
  final thumbnailPath = 'stories/stories_thumbnails/${name}_300x300.jpeg';

  final ref = FirebaseStorage.instance.ref(thumbnailPath);

  return ref.getDownloadURL();
}

Then, you can create a FutureProvider to handle getting the URL for you:

final storyThumbnail = FutureProvider.autoDispose.family<String, String>((ref, path) async {
  final res = await ref.watch(storyService).getThumbnailUrl(path);

  return res;
});

Finally, let’s display the thumbnail:

// Change from STatelessWidget to ConsumerWidget
class StoryBriefWidget extends ConsumerWidget {
  const StoryBriefWidget({
    super.key,
    required this.story,
  });

  final Story story;

  @override
  // Add the WidgetRef to cosume the provider
  Widget build(BuildContext context, WidgetRef ref) {
    return Container(
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(10),
        boxShadow: [
          BoxShadow(
            color: Theme.of(context).primaryColorLight.withOpacity(0.1),
            spreadRadius: 2,
            blurRadius: 9,
            offset: const Offset(0, 5),
          ),
        ],
      ),
      height: 600,
      padding: const EdgeInsets.all(AppPadding.small),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // ====== Add from here
          if (story.imagePath != null)
            ref.watch(storyThumbnail(story.imagePath!)).when(
                  data: (url) => Container(
                    margin: const EdgeInsets.only(bottom: AppPadding.medium),
                    decoration: BoxDecoration(
                      borderRadius: BorderRadius.circular(10),
                      image: DecorationImage(
                        image: NetworkImage(url),
                        fit: BoxFit.cover,
                      ),
                    ),
                    height: 150,
                  ),
                  loading: () => const Center(
                    child: CircularProgressIndicator(),
                  ),
                  error: (error, stack) => Text('Error: $error'),
                ),
           // ====== To here

// ... the rest of the widget code

Final result

You can see here a sample where 2 stories are displayed on the home page, one of them has the image at its full size, and the other was resized by the extension. This optimization can give a significant enhancement to your app’s experience 🙂

You can view the full Flutter project here.

Finally, by using Firebase Extensions, you can get a functionality done in a few hours with much less effort, making shipping your products faster 🚀

Stay tuned for more updates and exciting news that we will share in the future. Follow us on Invertase TwitterLinkedin, and Youtube, and subscribe to our monthly newsletter to stay up-to-date. You may also join our Discord to have an instant conversation with us.

Mais Alheraki

Open Source Engineer

Categories

Tags