DEV Community

Cover image for Making a Google Drive Clone | Flutter and AWS
FlutterArticles
FlutterArticles

Posted on • Updated on

Making a Google Drive Clone | Flutter and AWS

Let’s make a file-sharing application — affectionately named Doogle Grive — to show off what we can accomplish with Flutter and Amplify.

*NOTE: Amplify Flutter is still in developer preview and is not recommended for production use at this time. *

Introducing Doogle Grive

Demonstration of the AppDemonstration of the App

Amplify is great, you won’t be 4 nested maps deep in a JSON config trying to bit twiddle your way to a working full-stack application.

Some Background

What is Amplify?

Put simply, Amplify is a toolset that makes it easy (as in, one-click easy) to set up an AWS backend for your mobile application. That might sound a little too good to be true. Honestly, you still need to write some code, but at least you won’t be 4 nested maps deep in a JSON config trying to bit twiddle your way to a working full-stack application.

Why should I care?

Up until now, Flutter developers have been somewhat restricted to the Google ecosystem of backend resources. This has some pitfalls, particularly for people who are AWS aficionados or already have AWS deployed systems that they want to be integrated with a Flutter app.

With AWS officially supporting Amplify for Flutter, there is only room for growth in the *Futter w/ AWS *universe, so learning the fundamentals has some serious upside potential.

Enough talk, let’s get into it.

Setting up Amplify

For super-detailed installation instructions, check out this article I wrote on the topic. I’ll give you speedier instructions in this article, don’t blink or you might miss it.

  • Create an AWS account

Head over to the AWS portal and create an account under the ‘free’ plan.

  • Install the Amplify CLI

You will need flutter, node.js, npm, and git, then it is as simple as:

npm install -g @aws-amplify/cli@flutter-preview
Enter fullscreen mode Exit fullscreen mode
  • Initialize your Flutter project

In the root directory of your flutter project, run:

amplify init

This will guide you through the setup process for your application. Once that is complete, run:

amplify add auth

amplify add analytics

amplify add storage

  • Configuration Code

Our application contains some code that adds the AWS plugins we want to use. In this case, we need Authentication, Analytics, and of course, Storage. All powered by our omnipresent pal in the sky — AWS.

  void configureAmplify() async {
    if (!mounted) return;

    try {
      AmplifyAnalyticsPinpoint analyticsPlugin = AmplifyAnalyticsPinpoint();
      AmplifyAuthCognito authPlugin = AmplifyAuthCognito();
      AmplifyStorageS3 storage = AmplifyStorageS3();

      // Authentication -> AWS Cognito
      // Analytics -> AWS Pinpoint
      // Storage -> AWS S3
      amplifyInstance.addPlugin(
          authPlugins: [authPlugin],
          analyticsPlugins: [analyticsPlugin],
          storagePlugins: [storage]);

      await amplifyInstance.configure(amplifyconfig);
    } catch (e) {
      print(e.toString());
    }
  }
Enter fullscreen mode Exit fullscreen mode

If any of that confused you or gave you trouble, check out this video or this article (also made by me), or the official docs

Introducing the UI

Time to crack the knuckles and start coding. If you want to follow along, ‘checkout’ **this commit **in the repo.

This app is designed to make the AWS logic as clear as possible — so it is minimal in its design.

Login and Signup — using open-source for all its worth

The Login UI is built using the flutter_login package, which makes it super trivial to make an interactive user interface.

Not bad eh? All we have to do is specify the callbacks to achieve the functionality we want. We will do this in the next part though.


FlutterLogin(
              logo: 'images/trash_can.png',
              onLogin: // SIGN IN FUNCTION,
              onSignup: // REGISTER FUNCTION ,
              onRecoverPassword: (_) => null,
              onSubmitAnimationCompleted: // PORTAL FUNCTION,
              title: 'Doogle Grive')
Enter fullscreen mode Exit fullscreen mode

Confirmation

psst…you can probably skip this section, its just a simple input form to submit the confirmation code.

Still here? Fine, I will show you the confirmation UI and its code.

Super-Confirmation FormSuper-Confirmation Form


Center(
            child: Column(
          children: [
            SizedBox(
              height: 50,
            ),
            Padding(
              padding: const EdgeInsets.all(20.0),
              child: TextField(
                onChanged: (text) {
                  code = text;
                },
                decoration: InputDecoration(
                    border: OutlineInputBorder(
                      borderRadius: BorderRadius.circular(20),
                      borderSide: BorderSide(color: Colors.teal),
                    ),
                    hintText: 'Enter your confirm code here...'),
              ),
            ),
            RaisedButton(onPressed: confirmUser, child: Text("Submit Code")),
            RaisedButton(
              onPressed: () {
                Navigator.pushReplacement(
                    context, MaterialPageRoute(builder: (_) => Login()));
              },
              child: Text("Go Back"),
            ),
          ],
        )),
Enter fullscreen mode Exit fullscreen mode

AWS Cognito will send a 6 digit confirmation code to the signup email, and upon entering this code the user is granted access to their account.

Bucket Viewer™

The BucketViewer™ UI is all about displaying the cloud files that belong to the current user.

We will render each file on the UI as a Card, the end result looks something like this:

We have an icon representing the file type, the name of the file, and IconButtons for downloading and deleting the file.

  Widget fileViewer(StorageItem file) {
    return Card(
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Expanded(
              flex: 3,
              child: Row(
                children: [
                  getFileIcon(file.key.split('/').last),
                  Padding(
                    padding: const EdgeInsets.all(10.0),
                    child: Text(file.key.split('/')[1]),
                  )
                ],
              )),
          Expanded(
            flex: 1,
            child: Row(
              children: [
                IconButton(
                  icon: Icon(Icons.cloud_download),
                  onPressed: () {downloadFile(file);},
                ),
                IconButton(
                  icon: Icon(Icons.delete),
                  onPressed: () {deleteFile(file);},
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
Enter fullscreen mode Exit fullscreen mode

TLDR: The widget is constructed using the cloud data (of typeStorageItem ), and the buttons trigger some more of our cloud functions called downloadFile anddeleteFile . Don’t worry, we will cover these soon.

Each of our files is displayed in a ListView widget, which gives us the scroll-ability we desire. The ListView widget is rendered by a FutureBuilder, which waits on the result of one of our trusty AWS API calls. Take a look at the final result and the UI code:

Scrollable File ListScrollable File List


FutureBuilder(
              future: listFiles(),
              builder: (context, snapshot) {
                if (snapshot.hasData) {
                  return ListView.builder(
                    physics: AlwaysScrollableScrollPhysics(),
                    scrollDirection: Axis.vertical,
                    shrinkWrap: true,
                    itemBuilder: (BuildContext context, int index) {
                      return fileViewer(snapshot.data[index]);
                    },
                    itemCount: snapshot.data.length,
                  );
                } else {
                  return ListView();
                }
              },
            )
Enter fullscreen mode Exit fullscreen mode

So our yet-to-be-revealed listFiles function creates a Future containing a List , which in turn gets converted into a UI object when the Future is ready. When the UI is still waiting on the result of our function, an empty list is displayed.

One last thing, a handy logout button in the top right corner to log our user out.

Logout ButtonLogout Button

Okay okay okay, I know what you are thinking…you came here to learn about AWS and I keep showing you basic Flutter UI code. Let's get to the good stuff.

Powering our UI with AWS

Authentication Functions featuring AWS Cognito

Our authentication service is provisioned using AWS Cognito, which is accessible via calls to Amplify.Auth.methodName . Let’s walk through the functions that power our authentication.

Sign Up

This is the first function used by a prospective user. We sign up using the name (in our case an email) and password submitted in our login form. It is simple, and it is supposed to be.


  Future<String> _registerUser(LoginData data) async {
    try {
      Map<String, dynamic> userAttributes = {
        "email": data.name,
      };
      SignUpResult res = await Amplify.Auth.signUp(
          username: data.name,
          password: data.password,
          options: CognitoSignUpOptions(userAttributes: userAttributes));

      _userName = data.name;

      _isSignedUp = res.isSignUpComplete;

      return null;
    } on AuthError catch (e) {
      print(e);
      return "Register Error: " + e.toString();
    }
  }
Enter fullscreen mode Exit fullscreen mode

After signing up our first use, run amplify auth console to explore our Cognito managed user pool.

An unconfirmed user added to the user poolAn unconfirmed user added to the user pool

We still need to confirm our user somehow, which segues us into…

Confirm

Pretty self-explanatory on this one. A confirmation code is sent to our user’s signup email and they enter it to activate their account.

  confirmUser() async {
    SignUpResult res = await Amplify.Auth.confirmSignUp(
        username: this.widget.username,
        confirmationCode: code.trim()
    );
  }
Enter fullscreen mode Exit fullscreen mode

Confirming our codeConfirming our code

In your Cognito console, your user should now have the status of CONFIRMED.

Sign In

This is more or less the same as the signup. Note how we are keeping track of the results of our API calls using _isSignedIn and _isSignedUp to eventually decide what screen to send our user to after they are finished with our login page. I don’t show this function in this article, but check out the repo if you are curious.


  Future<String> _signIn(LoginData data) async {
    try {
      SignInResult res = await Amplify.Auth.signIn(
        username: data.name,
        password: data.password,
      );

      _isSignedin = res.isSignedIn;
    } on AuthError catch (e) {
      print(e);
      Alert(context: context, type: AlertType.error, title: "Login Failed")
          .show();
      return 'Log In Error: ' + e.toString();
    }
  }
Enter fullscreen mode Exit fullscreen mode

Logout

We log our user out and then issue a call to pushReplacement to pop the current page of the stack and replace it with the **Login **page.


  logout() async {
    try {
      Amplify.Auth.signOut();

      Navigator.pushReplacement(
          context, MaterialPageRoute(builder: (context) => Login()));
    } on AuthError catch (e) {
      Alert(
          context: context,
          type: AlertType.error,
          desc: "Error Logging Out: " + e.toString());
    }
  }
Enter fullscreen mode Exit fullscreen mode

Upload File

There are a few important things that happen here.

First, we get the user that is currently logged in using the auth package in the call to getCurrentUser()**. **We use this username along with the filename to create a unique key for our file, then we upload it.

setState()and the uploading flag allow us to change the appearance of the upload-file button while our file is uploading. A small detail but a nice touch for our application.

Finally, we submit an analytics event to Amazon Pinpoint that tracks the number of bytes a user has uploaded.


  void uploadFile() async {
    File file = await FilePicker.getFile();
    AuthUser user = await Amplify.Auth.getCurrentUser();

    try {
      if (file.existsSync()) {
        setState(() {
          uploading = true;
        });

        final key = user.username + '/' + file.path.split('/').last;

        await Amplify.Storage.uploadFile(key: key, local: file);

        // log an upload event, tracking the amount of bytes uploaded
        AnalyticsEvent event = AnalyticsEvent("file_upload_event");

        event.properties.addStringProperty("user", user.username);
        event.properties.addIntProperty("file_size", file.lengthSync());

        Amplify.Analytics.recordEvent(event: event);
        Amplify.Analytics.flushEvents();

        setState(() {
          uploading = false;
        });
      }
    } catch (e) {
      Alert(
          context: context,
          type: AlertType.error,
          desc: "Error Uploading File: " + e.toString());
    }
  }
Enter fullscreen mode Exit fullscreen mode

Download File

First, we use DownloadsPathProvider to get the target directory for our download.

getUrl fetches the download URL for our file, which will expire after 1 hour because of the expires option in GetUrlOptions.

The calls to checkPermission and FlutterDownloader check the app’s download permissions and then downloads the file. For more details, check out the FlutterDownloader documentation or the GitHub repo for the full codebase.

  void downloadFile(StorageItem item) async {
    try {
      var dir = await DownloadsPathProvider.downloadsDirectory;
      var url = await Amplify.Storage.getUrl(
          key: item.key, options: GetUrlOptions(expires: 3600));

      await checkPermission();

      await FlutterDownloader.enqueue(
        url: url.url,
        fileName: item.key.split('/').last,
        savedDir: dir.path,
        showNotification: true,
        openFileFromNotification: true,
      );
    } catch (e) {
      print(e.toString());
    }
  }
Enter fullscreen mode Exit fullscreen mode

Delete File

Deleting a file works in much the same way as adding a file, we use the item key to remove the file from the cloud. The calls to setState trigger a rebuild that populates the interface with an up-to-date file list.


  void deleteFile(StorageItem item) async {
    try {

      await Amplify.Storage.remove(
        key: item.key,
      );

    } catch (e) {
      Alert(
          context: context,
          type: AlertType.error,
          desc: "Error Deleting File: " + e.toString()).show();
    }
  }
Enter fullscreen mode Exit fullscreen mode

List Files

This is the moneymaker…

Our storage bucket contains every single user’s files all stored in a bucket, meaning that they aren’t organized in any kind of database-like way.

Since we can’t query a bucket, we have to filter our files based on user.username . With a single command (line 7), we can filter the complete list of files into a list that contains **only **files that belong to the current user.

Remember the FutureBuilder UI from earlier? This list is the future — when it is ready, the UI draws all of our files.

  Future<List<StorageItem>> listFiles() async {
    try {
      ListResult res = await Amplify.Storage.list();
      AuthUser user = await Amplify.Auth.getCurrentUser();

      List<StorageItem> items = res.items
          .where((e) => e.key.split('/').first.contains(user.username))
          .toList();

      return items;
    } catch (e) {
      Alert(
          context: context,
          type: AlertType.error,
          desc: "Error Listing Files: " + e.toString()).show();

      // return an empty list if something fails
      return List<StorageItem>(0);
    }
  }
Enter fullscreen mode Exit fullscreen mode

RESULTS

Uploading a file works as expected.

Launch the analytics platform using amplify analytics console . For a more detailed look on using AWS analytics check out this article that I wrote.

The number of uploaded bytes are being tracked in Amazon PinpointThe number of uploaded bytes are being tracked in Amazon Pinpoint

Future Work

Going from a bucket to a datastore is the next logical evolution of this app. Adding a feature to give another user (referenced by email) access to a particular file would also be a useful addition

If you want to take a stab at adding a new feature, feel free to open a pull request on the git repo — If you have read this far I trust that you have the perseverance to make a worthwhile contribution.

Amplify for Flutter is still a novel technology — and it shows in the lack of online content. In the coming months, I will produce more applications that use Amplify and demonstrate how to implement many of the core features modern apps require.

Until then, enjoy your open-source file storage app.

Acknowledgments and Sources

Discussion (0)