Front-End Web & Mobile

Announcing Flutter Web and Desktop support for AWS Amplify Storage, Analytics and API libraries

Earlier this year, AWS Amplify announced a Developer Preview for web and desktop support for its Flutter libraries in the Auth category and Amplify UI Authenticator for creating authentication flows. Today, the Amplify Flutter team is announcing web and desktop support for REST and GraphQL APIs, Analytics, and Storage categories. These categories were written fully in Dart, allowing you to have a consistent experience when using them across all your target platforms. The next milestone for the Amplify Flutter team is expanding Web and Desktop support for the Datastore category.

In this blog post, you will learn how to use AWS Amplify GraphQL API and Storage libraries by creating a grocery list application with Flutter that targets iOS, Android, Web, and Desktop.

By the end of this blog post, you will create a Flutter application that can:

  • Sign up and sign in a user
  • Create grocery items for a grocery list
  • Save the grocery list with uploading an invoice of it
  • See previous groceries

Requirements

  • Flutter SDK version 3.0.0 or higher
  • An AWS Account with AWS Amplify CLI setup, you can follow this documentation
  • Clone of the starter project from GitHub

Minimum project requirements are added to the starter project. Depending on the target, you need to do the platform setup for Flutter.

Getting Started

Open the starter project at the IDE of your choice. The project contains:

  • UI and libraries for the project
  • Minimum project setup
  • “TODO” comments to show where you are going to add features in order.

If you run the starter project you should see the following:

iOS, Android, Web and Desktop starter application

With the example app users can:

  • Add an item to the grocery list shopping cart
  • Select an invoice file
  • Upload the files
  • Submit the grocery shopping list
  • Review previously submitted grocery shopping lists

Initialize Amplify and Add Authentication

You will start off by initializing the Amplify project. Go to the terminal and write the following on the base directory of your cloned project:

$ cd path/to/project/amplify_grocery_list
$ amplify init

Enter a name for your Amplify project, accept the default configuration, select your AWS profile, and let the Amplify CLI do the rest for you.

Once it is done, you will see the following message:

...
Deployment bucket fetched.
✔ Initialized provider successfully.
✅ Initialized your environment successfully.
Your project has been successfully initialized and connected to the cloud!
...

Next step is to add the authentication to the project. Write amplify add auth to the terminal and:

  • Select Default Configuration from the list of configurations
  • Select username from the sign in method
  • Select No, I am done.
Do you want to use the default authentication and security configuration? Default configuration
 Warning: you will not be able to edit these selections. 
 How do you want users to be able to sign in? Username
 Do you want to configure advanced settings? No, I am done.
✅ Successfully added auth resource amplifygrocerylistco0e43b9b0 locally

Now you added the authentication capabilities. Now it is time to push the changes to the cloud. Write amplify push to the terminal.

✔ Successfully pulled backend environment dev from the cloud.

    Current Environment: dev
    
┌──────────┬────────────────────────────┬───────────┬───────────────────┐
│ Category │ Resource name              │ Operation │ Provider plugin   │
├──────────┼────────────────────────────┼───────────┼───────────────────┤
│ Auth     │ amplifygrocerylistb443755b │ Create    │ awscloudformation │
└──────────┴────────────────────────────┴───────────┴───────────────────┘
? Are you sure you want to continue? (Y/n) 

With each push, this table will show you the status of your added resources and what the effect of pushing your changes will be. Continue with the push. This should take a few minutes.

The next step is to add the Authenticator library to your project. The Authenticator library is a UI library to handle authentication flows for you.

Go to pubspec.yaml file in the project and find TODO(1). Remove that and add the following and run flutter pub get:

amplify_flutter: ^1.0.0-next.1
amplify_auth_cognito: ^1.0.0-next.1
amplify_authenticator: ^1.0.0-next.1
  • This will add the Amplify libraries for authentication to your project.

Now go to the main.dart file and update the TODO(2) part:

// (1) Import the Amplify libraries to configure the plugins
import 'package:amplify_authenticator/amplify_authenticator.dart';
import 'package:amplify_auth_cognito/amplify_auth_cognito.dart';
import 'package:amplify_flutter/amplify_flutter.dart';
import 'package:amplify_grocery_list/amplifyconfiguration.dart';

Future<void> main() async {
  // (2) Bind widgets before Amplify is configured
  WidgetsFlutterBinding.ensureInitialized();
  // (3) Wait for Amplify to be configured before you run the app. 
  await _configureAmplify();
  runApp(const AmplifyGroceryListApp());
}

Future<void> _configureAmplify() async {
  try {
    await Amplify.addPlugin(AmplifyAuthCognito());
    await Amplify.configure(amplifyconfig);
    safePrint('Successfully configured');
  } on Exception catch (e) {
    safePrint('Error configuring Amplify: $e');
  }
}

Now, go to TODO(3) in the main.dart file and update the build method with:

@override
Widget build(BuildContext context) {
  return Authenticator(
    child: MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: customLightTheme,
      darkTheme: customDarkTheme,
      builder: Authenticator.builder(),
      home: const CurrentGroceryListPage(),
    ),
  );
}
  • The Authenticator library is wrapping the material app. This way it can show the home page when the user logs in.

Finally, time to add log out functionality. Go to TODO(4) at current_grocery_list_page.dart file and add the following instead of the TODO comment:

// Import the amplify_flutter to reach Amplify Auth
// import 'package:amplify_flutter/amplify_flutter.dart';

IconButton(
  onPressed: () {
    Amplify.Auth.signOut();
  },
  icon: const Icon(Icons.exit_to_app),
),

Now if you run the application, you can create an account, log in and log out:

A user logging in after the auth implemen tation

Adding GraphQL API

Even though you have authentication, the data is still local and limited to each session. Add the API category to store your data in cloud for each user.

Run the command amplify add api in the terminal.

  • Select GraphQL as the service
  • Change Authorization Mode to Amazon Cognito User Pool instead of an API KEY
  • Select Continue to go to the next step
  • Select One-to-many relationship as the schema template (Don’t worry about your selection, you will update the schema later on)
  • Say No to the editing question.
? Select from one of the below mentioned services: GraphQL
? Here is the GraphQL API that we will create. Select a setting to edit or continue Authorization modes: API key (default, expiration time: 7 days from no
w)
? Choose the default authorization type for the API Amazon Cognito User Pool
Use a Cognito user pool configured as a part of this project.
? Configure additional auth types? No
? Here is the GraphQL API that we will create. Select a setting to edit or continue Continue
? Choose a schema template: One-to-many relationship (e.g., “Blogs” with “Posts” and “Comments”)

Now go to your project folder and fine schema.graphql file under amplify/backend/api/<yourappname> and update it with:

type Grocery @model @auth(rules: [{allow: owner}]) {
  id: ID!
  groceryItems: [GroceryItem] @hasMany(indexName: "byGrocery", fields: ["id"])
  title: String
  fileKey: String
  finalizationDate: AWSDate
  totalAmount: Float
}

type GroceryItem @model @auth(rules: [{allow: owner}]) {
  id: ID!
  name: String!
  isBought: Boolean!
  count: Int!
  groceryID: ID! @index(name: "byGrocery")
}
  • Grocery is your model to save grocery items and other previous grocery information:
    • groceryItems: Links to the Grocery items model for individual grocery items that users add
    • title: title for the grocery list
    • fileKey: File key for the uploaded file
    • finalizationDate: Date of groceries
    • totalAmount: Total amount spent for groceries
  • GroceryItem is the model for each grocery item added to the grocery list
    • name: Name of the item
    • isBought: The flag to keep track of bought items
    • count: Count of items
    • groceryID: The grocery id of this item belongs to

Now run amplify push to update your changes to the backend of your Amplify project.

...
✔ Successfully pulled backend environment dev from the cloud.

    Current Environment: dev
    
┌──────────┬────────────────────────────┬───────────┬───────────────────┐
│ Category │ Resource name              │ Operation │ Provider plugin   │
├──────────┼────────────────────────────┼───────────┼───────────────────┤
│ Auth     │ amplifygrocerylist124e7aff │ No Change │ awscloudformation │
├──────────┼────────────────────────────┼───────────┼───────────────────┤
│ Api      │ amplifygrocerylist         │ Create    │ awscloudformation │
└──────────┴────────────────────────────┴───────────┴───────────────────┘
? Are you sure you want to continue? Yes
...

Once the deployment is done, you need to add the API library to the pubspec.yaml file. Go to TODO (5) inside the pubspec.yaml file and paste the following in place of the TODO and run flutter pub get.

Now it is time to generate the models from the GraphQL API and remove the previous ones. Run amplify codegen models to regenerate the GraphQL API models for your application. This should generate the model files for your groceries under lib/models.

After you add the API library and generate the models, go to the project and find the main.dart file, then the TODO(5) and update the _configureAmplify function to include the API plugin:

// Import the following for the libraries
// import 'package:amplify_api/amplify_api.dart';
// import 'package:amplify_grocery_list/models/ModelProvider.dart';

Future<void> _configureAmplify() async {
  try {
    await Amplify.addPlugins([
      AmplifyAuthCognito(),
      // (1) Add Amplify API to the plugins to support GraphQL API in your applications. 
      AmplifyAPI(modelProvider: ModelProvider.instance),
    ]);

    await Amplify.configure(amplifyconfig);
    safePrint('Successfully configured');
  } on Exception catch (e) {
    safePrint('Error configuring Amplify: $e');
  }
}

Now you can remove the temporary_grocery_item.dart and temporary_previous_grocery.dart files. Now follow the steps below to update each reference with the new models.

  • Go to add_grocery_item_view.dart and update the constructor as:
// Import the GroceryItem model and remove the unused one
// import 'package:amplify_grocery_list/models/GroceryItem.dart';

const AddGroceryItemView({
  required this.groceryId,
  required this.onItemAdded,
  Key? key,
}) : super(key: key);

final ValueSetter<GroceryItem> onItemAdded;
final String groceryId;

This will keep a reference to the current grocery id while adding a new grocery item.

Now go to utils/helpers.dart file and find TODO(8) there. Remove comments from the commented out code. This function is created to run your GraphQL mutations. GraphQL mutations can be in create, update or delete format and each mutation can have an error scenario. runMutation function either returns the saved element or throws an exception.

Future<T?> runMutation<T>(
  GraphQLRequest<T> request,
  ValueSetter<String> onError,
) async {
  try {
    final response = await Amplify.API.mutate(request: request).response;
    final item = response.data;
    final errors = response.errors;
    if (errors.isNotEmpty) {
      throw Exception(errors);
    }
    if (item == null) {
      throw Exception('Received null item');
    }
    return item;
  } on Exception catch (e) {
    onError('Error: $e');
    return null;
  }
}
  • Go to TODO(9): update the onItemAdded callback as following:
// New grocery item is created
final item = GroceryItem(
    count: int.parse(countController.text),
    name: itemController.text,
    isBought: false,
    groceryID: widget.groceryId,
);

// Mutation is created and passed.
final mutation = ModelMutations.create(item);
final result = await runMutation(mutation, (error) {
    safePrint(error);
});

// Added item returned to be part of the list
if (result != null) {
    widget.onItemAdded(result);
    if (mounted) {
      Navigator.of(context).pop();
    }
}

Now use the GroceryItem model instead of the temporary item while adding new items.

Go to finalize_grocery_view.dart and update the constructor:

// Import the GroceryItem and Grocery models and remove the unused one
// import 'package:amplify_grocery_list/models/Grocery.dart';
// import 'package:amplify_grocery_list/models/GroceryItem.dart';

const FinalizeGroceryView({
  required this.items,
  required this.onFinalized,
  required this.currentGrocery,
  Key? key,
}) : super(key: key);

final VoidCallback onFinalized;
final List<GroceryItem> items;
final Grocery currentGrocery;

You are adding the current grocery to submit the open grocery items including setting a title and a uploading an invoice

  • Go to TODO(10) remove the previousGroceries callback:
// Import amplify_flutter for TemporalDate
// import 'package:amplify_flutter/amplify_flutter.dart';
// Import for API
// import 'package:amplify_api/amplify_api.dart';                      

final grocery = widget.currentGrocery.copyWith(
    totalAmount: double.parse(amountController.text),
    fileKey: platformFile.name,
    finalizationDate: TemporalDate(DateTime.now()),
    title: titleController.text,
    groceryItems: List.from(widget.items),
);

final request = ModelMutations.update(grocery);
final result = await runMutation(request, (error) {
    safePrint(error);
});

if (result != null && mounted) {
    Navigator.of(context).pop();
    widget.onFinalized();
}  
  • Remove previousGroceries list on the previous_groceries_page.dart. Because you will use the data from your backend.
  • Change every TemporaryGroceryItem with GroceryItem and TemporaryPreviousGrocery with Grocery

Now you should have three types of errors left:

  • groceryId is required error
  • currentGrocery is required error
  • previousGrocery is undefined error

Start off by fixing the previousGrocery error. For that, you need to fetch the submitted grocery lists. Go to previous_groceries_page.dart and add the below function to bottom of the class:

// Import the following for the function
// import 'package:amplify_api/amplify_api.dart';
// import 'package:amplify_flutter/amplify_flutter.dart';
// import 'package:amplify_grocery_list/models/Grocery.dart';
// import 'package:collection/collection.dart';

Future<List<Grocery>> fetchPreviousGroceries() async {
  final queryPredicate = Grocery.FINALIZATIONDATE.ne(null);
  final request =
      ModelQueries.list<Grocery>(Grocery.classType, where: queryPredicate);
  final response = await Amplify.API.query(request: request).response;
  return response.data?.items.whereNotNull().toList(growable: false) ??
      <Grocery>[];
}
  • This function checks the submission date and brings the ones that are not null. This way we only fetch the submitted grocery lists.

Update the body property of the Scaffold with the following to call the groceries function:

FutureBuilder<List<Grocery>>(
    future: fetchPreviousGroceries(),
    builder: (context, snapshot) {
     if (snapshot.connectionState == ConnectionState.waiting) {
        return const Center(child: CircularProgressIndicator());
      } else if (snapshot.hasData) {
        final previousGroceries = snapshot.data!;
        return /** Previous List Implementation **/
      } else {
      return const Center(
          child: Text('No previous groceries in the list yet'),
      );
     }
  },
),
  • This implementation gets the previous groceries and shows the list with the received items.

Now it is time to fix the groceryId and currentGrocery errors. For fixing them, you need to either create a grocery list or use the current grocery list.

Also, GraphQL API allows user to subscribe to the changes on items. Once the item is added, you would want to show it on all devices immediately.

For implementing these behaviors, add the following to the current_grocery_list_page.dart at the end of the class:

// Import the following items for the function
// import 'package:amplify_api/amplify_api.dart';
// import 'package:amplify_grocery_list/models/Grocery.dart';
// import 'package:collection/collection.dart';

Future<Grocery> fetchCurrentGrocery() async {
  // (1) Get the available grocery list for the current item
  final queryPredicate = Grocery.FINALIZATIONDATE.eq(null);
  final groceryRequest =
      ModelQueries.list<Grocery>(Grocery.classType, where: queryPredicate);
  final groceryResponse =
      await Amplify.API.query(request: groceryRequest).response;
  final grocery = groceryResponse.data?.items.whereNotNull().singleOrNull;
  // (2) Grocery items needs to be shown as well for the current item, that item is processed with grocery
  if (grocery != null) {
    final id = grocery.id;
    final queryPredicate = GroceryItem.GROCERYID.eq(id);
    final groceryItemRequest = ModelQueries.list<GroceryItem>(
      GroceryItem.classType,
      where: queryPredicate,
    );
    final groceryItemResponse =
        await Amplify.API.query(request: groceryItemRequest).response;
    final fetchedItems = groceryItemResponse.data?.items
        .whereNotNull()
        .toList(growable: false);
    items
      ..clear()
      ..addAll(fetchedItems ?? []);
    return grocery.copyWith(
      groceryItems: fetchedItems,
    );
  } else {
    final grocery = Grocery(groceryItems: const <GroceryItem>[]);
    final request = ModelMutations.create(grocery);
    await runMutations(request, (error) {
       safePrint('The creationg has failed. Try it again or check the errors: $error');
    });
    return grocery;
  }
}

In the code above you:

  • Created a new grocery item addition with ModelMutations
  • Sent the mutation to the API to create the grocery item entry in the backend

Lastly, update the build function to fetch the grocery information.

return FutureBuilder<Grocery>(
  future: fetchCurrentGrocery(),
  builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.waiting) {
      return const Center(child: CircularProgressIndicator());
    } else {
      // (1) Define the current grocery list.
      final grocery = snapshot.data!;
      return /** Previous Scaffold **/ 
    }
  },
);
  • Also, be sure to update AddGroceryItemView and FinalizeGroceryView as shown:
// Update them by copying and pasting individually

AddGroceryItemView(
  onItemAdded: onItemAdded,
  groceryId: grocery.id,
),
// Update them by copying and pasting individually
FinalizeGroceryView(
  items: List.from(items),
  currentGrocery: grocery,
  onFinalized: () {
      setState(() {
        items.clear();
      });
  },
),

Now it is time to add the comments to the previous grocery’s detail page. Go to previous_grocery_detail.dart. Add the following function:

// Import the following items for the function
// import 'package:amplify_api/amplify_api.dart';
// import 'package:amplify_flutter/amplify_flutter.dart';
// import 'package:amplify_grocery_list/models/GroceryItem.dart';
// import 'package:collection/collection.dart';

Future<List<GroceryItem>> fetchCurrentGroceryItem() async {
  final queryPredicate = GroceryItem.GROCERYID.eq(item.id);
  final groceryItemRequest = ModelQueries.list<GroceryItem>(
    GroceryItem.classType,
    where: queryPredicate,
  );
  final groceryItemResponse =
      await Amplify.API.query(request: groceryItemRequest).response;
  return groceryItemResponse.data?.items
          .whereNotNull()
          .toList(growable: false) ??
      <GroceryItem>[];
}
  • This function will fetch the grocery items for the current grocery id.

Lastly, update the Expanded widget as follows, this way you can fetch the changes.

Expanded(
  child: FutureBuilder<List<GroceryItem>>(
    future: fetchCurrentGroceryItem(),
    builder: (context, snapshot) {
      if (snapshot.connectionState == ConnectionState.waiting) {
        return const Center(child: CircularProgressIndicator());
      } else {
        final comments = snapshot.data!;
        return ListView.builder(
          padding: const EdgeInsets.all(8),
          itemCount: comments.length,
          itemBuilder: (context, index) {
            final groceryItem = comments[index];
            return ListTile(
              title: Text(groceryItem.name),
              subtitle:
                  Text('You bought ${groceryItem.count} of these'),
            );
          },
        );
      }
    },
  ),
),

Added new item

Adding Amplify Storage

To upload your invoices to the cloud, add Amplify Storage to the project.

For adding storage to your project, type amplify add storage in your terminal and:

  • Select Content (Images, audio, video, etc.) option
  • Provide a friendly name or accept provided label
  • Provide a bucket name or accept provided name
  • Select Auth and guest users for the access
  • Auth users could create, delete and read
  • Guests could read
  • No need to add a lambda function
? Select from one of the below mentioned services: Content (Images, audio, video, etc.)
✔ Provide a friendly name for your resource that will be used to label this category in the project: · s3b7337f64
✔ Provide bucket name: · amplifygrocerylistf27a0c0ae69741058a66e144b129d
✔ Who should have access: · Auth and guest users
✔ What kind of access do you want for Authenticated users? · create/update, read, delete
✔ What kind of access do you want for Guest users? · read
✔ Do you want to add a Lambda Trigger for your S3 Bucket? (y/N) · no

Now you can run amplify push to push your changes to the cloud.

    
┌──────────┬────────────────────────────┬───────────┬───────────────────┐
│ Category │ Resource name              │ Operation │ Provider plugin   │
├──────────┼────────────────────────────┼───────────┼───────────────────┤
│ Storage  │ s3b7337f64                 │ Create    │ awscloudformation │
├──────────┼────────────────────────────┼───────────┼───────────────────┤
│ Auth     │ amplifygrocerylistad5c6f9f │ Update    │ awscloudformation │
├──────────┼────────────────────────────┼───────────┼───────────────────┤
│ Api      │ amplifygrocerylist         │ No Change │ awscloudformation │
└──────────┴────────────────────────────┴───────────┴───────────────────┘
? Are you sure you want to continue? (Y/n) 

Once it is pushed, add the storage library to the pubspec.yaml file and run flutter pub get:

amplify_storage_s3: ^1.0.0-next.1

Now, go to main.dart to update _configureAmplify:

Future<void> _configureAmplify() async {
  try {
    await Amplify.addPlugins([
      AmplifyAuthCognito(),
      AmplifyAPI(modelProvider: ModelProvider.instance),
      AmplifyStorageS3(),
    ]);

    await Amplify.configure(amplifyconfig);
    safePrint('Successfully configured');
  } on Exception catch (e) {
    safePrint('Error configuring Amplify: $e');
  }
}

Now go to the finalize_grocery_view.dart and update the TODO(11) with:

// (1) Upload the selected file
await Amplify.Storage.uploadFile(
   localFile: AWSFile.fromPath(platformFile.path!),
  key: widget.currentGrocery.id,
  onProgress: (progress) {
    safePrint(
      'Fraction completed: ${progress.fractionCompleted}',
    );
  },
).result;

// (2) Upload the grocery list
final grocery = widget.currentGrocery.copyWith(
  totalAmount: double.parse(amountController.text),
  fileKey: widget.currentGrocery.id,
  finalizationDate: TemporalDate(DateTime.now()),
  title: titleController.text,
);

Now if you run your application you should be able to see that the app can upload a receipt and finalize the groceries. Let’s add a code to show the image.

Now go to previous_grocery_details.dart file and add the following method to the end of the class to generate a URL to show the images or download them:

Future<String> getDownloadUrl({
    required String key,
    required StorageAccessLevel accessLevel,
  }) async {
  try {
    final result = await Amplify.Storage.getUrl(
      key: key,
      options: S3GetUrlOptions(
        accessLevel: accessLevel,
        checkObjectExistence: true,
        expiresIn: const Duration(hours: 1),
      ),
    ).result;
    return result.url.toString();
  } on StorageException catch (e) {     
    safePrint(e.message);
    rethrow;
  }
}

Afterwards, go to TODO(12) to show the image by using the retrieved URL:

FutureBuilder<String>(
  future: getDownloadUrl(
      key: item.fileKey!,
      accessLevel: StorageAccessLevel.guest,
  ),
  builder: (context, snapshot) {
      if (snapshot.connectionState == ConnectionState.waiting) {
        return const Center(child: CircularProgressIndicator());
      } else {
        return Image.network(snapshot.data!);
      }
  },
),

Right now the users can see the uploaded image on the detail page.

Adding an invoice to a grocery list.

Conclusion

AWS Amplify’s update to its API, Storage and Analytics libraries will give developers a chance to expand their Flutter application’s support on web and desktop. This update will allow you to create Flutter application using the AWS Amplify libraries on iOS, Android, Web, and Desktop. As you build with AWS Amplify, please give us feedback over GitHub or Discord.

Resources