Building an Upload Service for Flutter Web Using Firebase Storage

Building an Upload Service for Flutter Web Using Firebase Storage

A simple Upload Service for Single and Multiple File Uploads Using Firebase Storage

Uploading files in a Flutter web application can be a powerful feature, especially when integrated with cloud storage like Firebase. This article will guide you through building an upload service using Firebase Storage for Flutter Web for single and multiple file uploads, allowing you to manage file uploads efficiently. We'll also implement Dependency Injection (DI) using injectable to make our codebase more modular and maintainable.

Overview

In this article, we'll cover the following steps:

  1. Setting up Firebase Storage for file uploads.

  2. Creating a service interface for uploading single and multiple files.

  3. Implementing the upload functionality using Firebase Storage.

  4. Utilizing injectable for Dependency Injection (DI) to decouple the upload service from the rest of the app.

Prerequisites

Before we begin, ensure that you have:

  • A Flutter project configured for web.

  • Firebase set up in your Flutter project, including Firebase Storage. You can refer to the official Firebase documentation for setup instructions.

  • The firebase_storage package added to your pubspec.yaml.

1. Defining the Upload Service Interface

First, we’ll define the data model and service interface for our upload functionality. This interface ensures that the implementation can be easily replaced or extended later.

Here’s the UploadData class, which represents the file data that will be uploaded to Firebase:

import 'dart:typed_data';

class UploadData {
  final Uint8List fileData;   // The actual file in binary format
  final String folderName;    // Folder in Firebase Storage where the file will be stored
  final String fileName;      // Name of the file

  const UploadData({
    required this.fileData,
    required this.fileName,
    required this.folderName,
  });
}

We also create an abstract class IUploadService to define the contract for uploading files:

abstract class IUploadService {
  Future<String> uploadDoc({
    required UploadData file,
  });

  Future<List<String>> uploadMultipleDoc({
    required List<UploadData> files,
  });
}

This interface defines two main functions:

  • uploadDoc: Uploads a single file to Firebase and returns the download URL.

  • uploadMultipleDoc: Uploads multiple files in parallel and returns a list of download URLs.

2. Implementing the Upload Service

Now that we have our interface and data model, let's implement the actual upload service. The service will handle interactions with Firebase Storage to upload documents and retrieve their download URLs.

Here is the implementation of the UploadService class:

import 'package:firebase_storage/firebase_storage.dart';
import 'package:flutter/foundation.dart';
import 'package:injectable/injectable.dart';
import 'i_upload_service.dart';
import 'custom_error.dart'; // Custom error handling class

@LazySingleton(as: IUploadService)
class UploadService extends IUploadService {
  final FirebaseStorage firebaseStorage;

  UploadService({
    required this.firebaseStorage,
  });

  @override
  Future<String> uploadDoc({
    required UploadData file,
  }) async {
    try {
      // Create a reference to the file location in Firebase Storage
      var storageRef = firebaseStorage.ref('${file.folderName}/${file.fileName}');
      var uploadTask = storageRef.putData(file.fileData); // Upload the file

      TaskSnapshot snapshot = await uploadTask;  // Wait for the upload to complete

      // Return the download URL
      return await snapshot.ref.getDownloadURL();
    } on FirebaseException catch (e) {
      // Handle Firebase-specific errors
      throw CustomError(
        errorMsg: "An error occurred! ($e)",
        code: e.code,
        plugin: e.plugin,
      );
    } catch (e) {
      // Catch any other error
      if (kDebugMode) {
        print('Error: $e');
      }
      rethrow;
    }
  }

  @override
  Future<List<String>> uploadMultipleDoc({
    required List<UploadData> files,
  }) async {
    // Use Future.wait to upload all files in parallel
    return await Future.wait(files.map((file) => uploadDoc(file: file)));
  }
}

3. Error Handling

In the UploadService, we handle errors using a CustomError class. This class helps us capture more specific information about errors, especially those from Firebase:

import 'package:equatable/equatable.dart';

class CustomError extends Equatable {
  final String errorMsg;
  final String code;
  final String plugin;

  const CustomError({
    required this.errorMsg,
    required this.code,
    required this.plugin,
  });

  @override
  List<Object?> get props => [errorMsg, code, plugin];

  @override
  String toString() {
    return 'CustomError{errorMsg: $errorMsg, code: $code, plugin: $plugin}';
  }
}

This class provides a structured way to throw and handle errors throughout the upload process.

4. Injecting Dependencies with injectable

To maintain clean code architecture and ensure loose coupling between components, we use the injectable package for Dependency Injection (DI). DI allows us to inject FirebaseStorage into the UploadService class, making the service easier to mock or replace during testing or future updates.

Here’s how you can set up injectable in your project:

  1. Add injectable and get_it packages to your pubspec.yaml:
dependencies:
  injectable: ^2.3.2
  get_it: ^7.6.7

dev_dependencies:
  flutter_test:
    sdk: flutter

  build_runner:
  injectable_generator: ^2.4.1
  1. Annotate the UploadService class with @LazySingleton to register it with the DI container:
@LazySingleton(as: IUploadService)
class UploadService extends IUploadService {
  // constructor and methods
}
  1. Configure injectable to handle the DI logic for you. Run the injectable generator:
flutter pub run build_runner build

With injectable, the UploadService is automatically injected wherever needed, ensuring that the FirebaseStorage instance is provided without manually creating it in every part of the app.

5. Using the Upload Service

To use the UploadService in your Flutter app, you can simply call its methods after injecting it via get_it or directly if you're not using DI for that part yet.

Here’s an example of how you could use the service to upload a single file:

final uploadService = getIt<IUploadService>();

Future<void> uploadFile(Uint8List fileData) async {
  final file = UploadData(
    fileData: fileData,
    fileName: 'example.txt',
    folderName: 'documents',
  );

  try {
    final downloadUrl = await uploadService.uploadDoc(file: file);
    print('File uploaded successfully: $downloadUrl');
  } catch (e) {
    print('Upload failed: $e');
  }
}

By implementing an upload service using Firebase Storage and injectable, you’ve created a scalable and modular way to manage file uploads in your Flutter web app. This setup also ensures that your code is easily testable and maintainable. You can extend this service further to handle other types of file operations, such as deleting files or tracking upload progress.

With Dependency Injection through injectable, your upload service remains loosely coupled with other parts of the app, making it easier to maintain and upgrade over time.

To delve more into this here are some helpful links to the official documentation related to Firebase Storage and Dependency Injection in Flutter:

  1. Firebase Storage for Flutter:

  2. Dependency Injection in Flutter with injectable:

Did you find this article valuable?

Support Flutter Aware by becoming a sponsor. Any amount is appreciated!