使用 AWS Amplify 构建 Flutter 移动应用程序 - 第 1 部分

创建适用于 iOS 和 Android 的行程规划器应用程序

模块 3:添加 API

概述

在本模块中,您将使用 Amplify CLI 向应用程序中添加一个用于检索行程数据和将此类数据持久化的 API。您将创建一种 GraphQL API,该 API 使用由 Amazon DynamoDB(一种 NoSQL 数据库)提供支持的 AWS AppSync(一种 GraphQL 托管服务)。

要完成的目标

  • 向应用程序中添加 Amplify API
  • 向应用程序中添加行程数据模型
  • 对行程功能执行 CRUD 操作和工作流
  • 实现行程列表 UI

 最短完成时间

20 分钟

操作步骤

向应用程序中添加 Amplify API

步骤 1:前往应用程序的根文件夹,然后在终端运行以下命令来预配 Amplify API 资源。

amplify add api

步骤 2:若要创建 GraphQL API,请在系统提示时输入以下内容:

? 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 A
uthorization modes: API key (default, expiration time: 7 days from now)
? 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 C
ontinue
? Choose a schema template: Blank Schema
✅ GraphQL schema compiled successfully.

Amplify CLI 将为该 API 添加一个新文件夹,其中包含 schema.graphql 文件,您可以在该文件中定义应用程序模型。

步骤 3:打开 schema.graphql 文件并使用以下代码更新该文件,以定义行程模型。

type Trip @model @auth(rules: [{ allow: owner }]) {
  id: ID!
  tripName: String!
  destination: String!
  startDate: AWSDate!
  endDate: AWSDate!
  tripImageUrl: String
  tripImageKey: String
}

向应用程序中添加行程数据模型

步骤 1:在应用程序的根文件夹中运行以下命令以生成行程模型文件。

amplify codegen models

Amplify CLI 将在 lib/models 文件夹中生成 dart 文件。

步骤 2:运行 amplify push 命令,在云中创建资源。 

步骤 3:按下 Enter 键。Amplify CLI 将部署资源并显示确认消息,如屏幕截图中所示。

步骤 4:打开 main.dart 文件并更新 _configureAmplify() 函数(如代码中所示),以添加 Amplify DataStore 和 API 插件。

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

main.dart 文件现在看上去应当与下面的代码段相似。

import 'package:amplify_api/amplify_api.dart';
import 'package:amplify_auth_cognito/amplify_auth_cognito.dart';
import 'package:amplify_flutter/amplify_flutter.dart';
import 'package:amplify_trips_planner/models/ModelProvider.dart';
import 'package:amplify_trips_planner/trips_planner_app.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'amplifyconfiguration.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  try {
    await _configureAmplify();
  } on AmplifyAlreadyConfiguredException {
    debugPrint('Amplify configuration failed.');
  }

  runApp(
    const ProviderScope(
      child: TripsPlannerApp(),
    ),
  );
}

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

对行程功能执行 CRUD 操作和工作流

步骤 1:在 lib/features/trip/services 文件夹中新建一个 dart 文件并将其命名为 trips_api_service.dart

步骤 2:打开 trips_datastore_service.dart 文件并使用以下代码段更新该文件,以创建包含如下函数的 TripsAPIService

  • getTrips - 此函数将在 Amplify API 中查询正在进行和即将发生的行程并返回这些行程的列表。
  • getPastTrips - 此函数将在 Amplify API 中查询过去的行程并返回这些行程的列表。
  • getTrip - 此函数将在 Amplify API 中查询特定行程。
  • addTripdeleteTrip updateTrip 分别用于在 Amplify API 中添加、删除和更新行程。
import 'dart:async';

import 'package:amplify_api/amplify_api.dart';
import 'package:amplify_flutter/amplify_flutter.dart';
import 'package:amplify_trips_planner/models/ModelProvider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final tripsAPIServiceProvider = Provider<TripsAPIService>((ref) {
  final service = TripsAPIService();
  return service;
});

class TripsAPIService {
  TripsAPIService();

  Future<List<Trip>> getTrips() async {
    try {
      final request = ModelQueries.list(Trip.classType);
      final response = await Amplify.API.query(request: request).response;

      final trips = response.data?.items;
      if (trips == null) {
        safePrint('getTrips errors: ${response.errors}');
        return const [];
      }
      trips.sort(
        (a, b) =>
            a!.startDate.getDateTime().compareTo(b!.startDate.getDateTime()),
      );
      return trips
          .map((e) => e as Trip)
          .where(
            (element) => element.endDate.getDateTime().isAfter(DateTime.now()),
          )
          .toList();
    } on Exception catch (error) {
      safePrint('getTrips failed: $error');

      return const [];
    }
  }

  Future<List<Trip>> getPastTrips() async {
    try {
      final request = ModelQueries.list(Trip.classType);
      final response = await Amplify.API.query(request: request).response;

      final trips = response.data?.items;
      if (trips == null) {
        safePrint('getPastTrips errors: ${response.errors}');
        return const [];
      }
      trips.sort(
        (a, b) =>
            a!.startDate.getDateTime().compareTo(b!.startDate.getDateTime()),
      );
      return trips
          .map((e) => e as Trip)
          .where(
            (element) => element.endDate.getDateTime().isBefore(DateTime.now()),
          )
          .toList();
    } on Exception catch (error) {
      safePrint('getPastTrips failed: $error');

      return const [];
    }
  }

  Future<void> addTrip(Trip trip) async {
    try {
      final request = ModelMutations.create(trip);
      final response = await Amplify.API.mutate(request: request).response;

      final createdTrip = response.data;
      if (createdTrip == null) {
        safePrint('addTrip errors: ${response.errors}');
        return;
      }
    } on Exception catch (error) {
      safePrint('addTrip failed: $error');
    }
  }

  Future<void> deleteTrip(Trip trip) async {
    try {
      await Amplify.API
          .mutate(
            request: ModelMutations.delete(trip),
          )
          .response;
    } on Exception catch (error) {
      safePrint('deleteTrip failed: $error');
    }
  }

  Future<void> updateTrip(Trip updatedTrip) async {
    try {
      await Amplify.API
          .mutate(
            request: ModelMutations.update(updatedTrip),
          )
          .response;
    } on Exception catch (error) {
      safePrint('updateTrip failed: $error');
    }
  }

  Future<Trip> getTrip(String tripId) async {
    try {
      final request = ModelQueries.get(
        Trip.classType,
        TripModelIdentifier(id: tripId),
      );
      final response = await Amplify.API.query(request: request).response;

      final trip = response.data!;
      return trip;
    } on Exception catch (error) {
      safePrint('getTrip failed: $error');
      rethrow;
    }
  }
}

步骤 3:在 lib/features/trip/data 文件夹内部创建一个新的 dart 文件并将其命名为 trips_repository.dart

步骤 4:打开 trips_repository.dart 文件并使用以下代码更新该文件:

import 'package:amplify_trips_planner/features/trip/service/trips_api_service.dart';
import 'package:amplify_trips_planner/models/Trip.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final tripsRepositoryProvider = Provider<TripsRepository>((ref) {
  final tripsAPIService = ref.read(tripsAPIServiceProvider);
  return TripsRepository(tripsAPIService);
});

class TripsRepository {
  TripsRepository(this.tripsAPIService);

  final TripsAPIService tripsAPIService;

  Future<List<Trip>> getTrips() {
    return tripsAPIService.getTrips();
  }

  Future<List<Trip>> getPastTrips() {
    return tripsAPIService.getPastTrips();
  }

  Future<void> add(Trip trip) async {
    return tripsAPIService.addTrip(trip);
  }

  Future<void> update(Trip updatedTrip) async {
    return tripsAPIService.updateTrip(updatedTrip);
  }

  Future<void> delete(Trip deletedTrip) async {
    return tripsAPIService.deleteTrip(deletedTrip);
  }

  Future<Trip> getTrip(String tripId) async {
    return tripsAPIService.getTrip(tripId);
  }
}

步骤 5: lib/feature/trip/controller 文件夹中新建一个 dart 文件并将其命名为 trips_list_controller.dart

步骤 6:打开 trips_list_controller.dart 文件并使用以下代码更新该文件。该 UI 将使用控制器添加新行程,方法是创建行程条目并将其作为参数传递给 tripsRepository.add(trip) 函数。

 

注意:VSCode 会因缺少 trips_list_controller.g.dart 文件而报错。
您将在下一步中修复此错误。

import 'dart:async';

import 'package:amplify_flutter/amplify_flutter.dart';
import 'package:amplify_trips_planner/features/trip/data/trips_repository.dart';
import 'package:amplify_trips_planner/models/ModelProvider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'trips_list_controller.g.dart';

@riverpod
class TripsListController extends _$TripsListController {
  Future<List<Trip>> _fetchTrips() async {
    final tripsRepository = ref.read(tripsRepositoryProvider);
    final trips = await tripsRepository.getTrips();
    return trips;
  }

  @override
  FutureOr<List<Trip>> build() async {
    return _fetchTrips();
  }

  Future<void> addTrip({
    required String name,
    required String destination,
    required String startDate,
    required String endDate,
  }) async {
    final trip = Trip(
      tripName: name,
      destination: destination,
      startDate: TemporalDate(DateTime.parse(startDate)),
      endDate: TemporalDate(DateTime.parse(endDate)),
    );

    state = const AsyncValue.loading();

    state = await AsyncValue.guard(() async {
      final tripsRepository = ref.read(tripsRepositoryProvider);
      await tripsRepository.add(trip);
      return _fetchTrips();
    });
  }

  Future<void> removeTrip(Trip trip) async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() async {
      final tripsRepository = ref.read(tripsRepositoryProvider);
      await tripsRepository.delete(trip);

      return _fetchTrips();
    });
  }
}

步骤 7:前往应用程序的根文件夹,然后在终端运行以下命令。 

dart run build_runner build -d

这将在 lib/feature/trip/controller 文件夹中生成 trips_list_controller.g.dart 文件。

实现行程列表 UI

步骤 1:在 lib/common/ui 文件夹中新建一个 dart 文件并将其命名为 bottomsheet_text_form_field.dart

步骤 2:打开 bottomsheet_text_form_field.dart 文件并使用以下代码段更新该文件,以创建 BottomSheetTextFormField 窗口部件,通过该窗口部件可以构建 TextFormField,该应用程序将在表单中使用 TextFormField 创建新行程。

import 'package:flutter/material.dart';

class BottomSheetTextFormField extends StatelessWidget {
  const BottomSheetTextFormField({
    required this.labelText,
    required this.controller,
    required this.keyboardType,
    this.onTap,
    super.key,
  });

  final String labelText;
  final TextEditingController controller;
  final TextInputType keyboardType;
  final void Function()? onTap;

  @override
  Widget build(BuildContext context) {
    return TextFormField(
      controller: controller,
      keyboardType: keyboardType,
      autofocus: true,
      autocorrect: false,
      textInputAction: TextInputAction.next,
      validator: (value) {
        if (value == null || value.isEmpty) {
          return 'Please enter a value';
        }

        return null;
      },
      decoration: InputDecoration(
        labelText: labelText,
      ),
      onTap: onTap,
    );
  }
}

步骤 3:在 lib/common/utils 文件夹中新建一个 dart 文件并将其命名为 date_time_formatter.dart

步骤 4:打开 date_time_formatter.dart 文件并使用以下代码更新该文件,以创建 DateTimeFormatter 扩展来格式化 DateTime 值。

import 'package:intl/intl.dart';

extension DateTimeFormatter on DateTime {
  String format(String format) {
    return DateFormat(format).format(this);
  }
}

步骤 5:在 lib/features/trip/ui/trips_list 文件夹中新建一个 dart 文件并将其命名为 add_trip_bottomsheet.dart

步骤 6:打开 add_trip_bottomsheet.dart 文件并使用以下代码更新该文件。这样,我们就可以向用户提供一张表单,供用户提交创建新行程所需的详细信息。

现在,我们在表单中使用 BottomSheetTextFormField 窗口部件和 DateTimeFormatter 扩展。

import 'package:amplify_trips_planner/common/ui/bottomsheet_text_form_field.dart';
import 'package:amplify_trips_planner/common/utils/date_time_formatter.dart';
import 'package:amplify_trips_planner/features/trip/controller/trips_list_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';

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

  @override
  AddTripBottomSheetState createState() => AddTripBottomSheetState();
}

class AddTripBottomSheetState extends ConsumerState<AddTripBottomSheet> {
  final formGlobalKey = GlobalKey<FormState>();

  final tripNameController = TextEditingController();
  final destinationController = TextEditingController();
  final startDateController = TextEditingController();
  final endDateController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Form(
      key: formGlobalKey,
      child: Container(
        padding: EdgeInsets.only(
          top: 15,
          left: 15,
          right: 15,
          bottom: MediaQuery.of(context).viewInsets.bottom + 15,
        ),
        width: double.infinity,
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            BottomSheetTextFormField(
              labelText: 'Trip Name',
              controller: tripNameController,
              keyboardType: TextInputType.name,
            ),
            const SizedBox(
              height: 20,
            ),
            BottomSheetTextFormField(
              labelText: 'Trip Destination',
              controller: destinationController,
              keyboardType: TextInputType.name,
            ),
            const SizedBox(
              height: 20,
            ),
            BottomSheetTextFormField(
              labelText: 'Start Date',
              controller: startDateController,
              keyboardType: TextInputType.datetime,
              onTap: () async {
                final pickedDate = await showDatePicker(
                  context: context,
                  initialDate: DateTime.now(),
                  firstDate: DateTime(2000),
                  lastDate: DateTime(2101),
                );

                if (pickedDate != null) {
                  print(pickedDate.format('yyyy-MM-dd'));
                  startDateController.text = pickedDate.format('yyyy-MM-dd');
                  print(startDateController.text);
                }
              },
            ),
            const SizedBox(
              height: 20,
            ),
            BottomSheetTextFormField(
              labelText: 'End Date',
              controller: endDateController,
              keyboardType: TextInputType.datetime,
              onTap: () async {
                if (startDateController.text.isNotEmpty) {
                  final pickedDate = await showDatePicker(
                    context: context,
                    initialDate: DateTime.parse(startDateController.text),
                    firstDate: DateTime.parse(startDateController.text),
                    lastDate: DateTime(2101),
                  );

                  if (pickedDate != null) {
                    print(pickedDate.format('yyyy-MM-dd'));
                    endDateController.text = pickedDate.format('yyyy-MM-dd');
                  }
                }
              },
            ),
            const SizedBox(
              height: 20,
            ),
            TextButton(
              child: const Text('OK'),
              onPressed: () async {
                final currentState = formGlobalKey.currentState;
                if (currentState == null) {
                  return;
                }
                if (currentState.validate()) {
                  await ref.watch(tripsListControllerProvider.notifier).addTrip(
                        name: tripNameController.text,
                        destination: destinationController.text,
                        startDate: startDateController.text,
                        endDate: endDateController.text,
                      );

                  if (context.mounted) {
                    context.pop();
                  }
                }
              }, //,
            ),
          ],
        ),
      ),
    );
  }
}

步骤 7:lib/features/trip/ui 文件夹中新建一个文件夹并将其命名为 trips_gridview,然后在这个文件夹中创建 trip_gridview_item_card.dart 文件。

步骤 8:打开 trip_gridview_item_card.dart 文件并使用以下代码更新该文件。这将创建一个 Card 窗口部件,用于在行程列表页面上显示行程详细信息。

import 'package:amplify_trips_planner/common/utils/colors.dart' as constants;
import 'package:amplify_trips_planner/common/utils/date_time_formatter.dart';
import 'package:amplify_trips_planner/models/ModelProvider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';

class TripGridViewItemCard extends StatelessWidget {
  const TripGridViewItemCard({
    required this.trip,
    super.key,
  });

  final Trip trip;

  @override
  Widget build(BuildContext context) {
    return Card(
      clipBehavior: Clip.antiAlias,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(15),
      ),
      elevation: 5,
      child: Column(
        children: [
          Expanded(
            child: Container(
              height: 500,
              alignment: Alignment.center,
              color: const Color(constants.primaryColorDark),
              child: Stack(
                children: [
                  Positioned.fill(
                    child: trip.tripImageUrl != null
                        ? Stack(
                            children: [
                              const Center(child: CircularProgressIndicator()),
                              CachedNetworkImage(
                                errorWidget: (context, url, dynamic error) =>
                                    const Icon(Icons.error_outline_outlined),
                                imageUrl: trip.tripImageUrl!,
                                cacheKey: trip.tripImageKey,
                                width: double.maxFinite,
                                height: 500,
                                alignment: Alignment.topCenter,
                                fit: BoxFit.fill,
                              ),
                            ],
                          )
                        : Image.asset(
                            'images/amplify.png',
                            fit: BoxFit.contain,
                          ),
                  ),
                  Positioned(
                    bottom: 16,
                    left: 16,
                    right: 16,
                    child: FittedBox(
                      fit: BoxFit.scaleDown,
                      alignment: Alignment.centerLeft,
                      child: Text(
                        trip.destination,
                        style: Theme.of(context)
                            .textTheme
                            .headlineSmall!
                            .copyWith(color: Colors.white),
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
          Padding(
            padding: const EdgeInsets.fromLTRB(2, 8, 8, 4),
            child: DefaultTextStyle(
              softWrap: false,
              overflow: TextOverflow.ellipsis,
              style: Theme.of(context).textTheme.titleMedium!,
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Padding(
                    padding: const EdgeInsets.only(bottom: 8),
                    child: Text(
                      trip.tripName,
                      style: Theme.of(context)
                          .textTheme
                          .titleMedium!
                          .copyWith(color: Colors.black54),
                    ),
                  ),
                  Text(
                    trip.startDate.getDateTime().format('MMMM dd, yyyy'),
                    style: const TextStyle(fontSize: 12),
                  ),
                  Text(
                    trip.endDate.getDateTime().format('MMMM dd, yyyy'),
                    style: const TextStyle(fontSize: 12),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

步骤 9:在 lib/features/trip/ui/trips_gridview 文件夹中创建 trip_gridview_item.dart 文件。

步骤 10:打开 trip_gridview_item.dart 文件并使用以下代码更新该文件。该应用程序将使用该文件来显示行程列表网格。

import 'package:amplify_trips_planner/common/navigation/router/routes.dart';
import 'package:amplify_trips_planner/features/trip/ui/trips_gridview/trip_gridview_item_card.dart';
import 'package:amplify_trips_planner/models/ModelProvider.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class TripGridViewItem extends StatelessWidget {
  const TripGridViewItem({
    required this.trip,
    super.key,
  });

  final Trip trip;

  @override
  Widget build(BuildContext context) {
    return InkWell(
      splashColor: Theme.of(context).primaryColor,
      borderRadius: BorderRadius.circular(15),
      onTap: () {
        context.goNamed(
          AppRoute.trip.name,
          pathParameters: {'id': trip.id},
          extra: trip,
        );
      },
      child: TripGridViewItemCard(
        trip: trip,
      ),
    );
  }
}

步骤 11:在 lib/features/trip/ui/trips_gridview 文件夹中创建 trips_list_gridview.dart 文件。 

步骤 12:打开 trips_list_gridview.dart 文件并使用以下代码更新该文件。这样网格视图组件会显示行程列表。

import 'package:amplify_trips_planner/features/trip/ui/trips_gridview/trip_gridview_item.dart';
import 'package:amplify_trips_planner/models/ModelProvider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class TripsListGridView extends StatelessWidget {
  const TripsListGridView({
    required this.tripsList,
    super.key,
  });

  final AsyncValue<List<Trip>> tripsList;

  @override
  Widget build(BuildContext context) {
    switch (tripsList) {
      case AsyncData(:final value):
        return value.isEmpty
            ? const Center(
                child: Text('No Trips'),
              )
            : OrientationBuilder(
                builder: (context, orientation) {
                  return GridView.count(
                    crossAxisCount:
                        (orientation == Orientation.portrait) ? 2 : 3,
                    mainAxisSpacing: 4,
                    crossAxisSpacing: 4,
                    padding: const EdgeInsets.all(4),
                    childAspectRatio:
                        (orientation == Orientation.portrait) ? 0.9 : 1.4,
                    children: value.map((tripData) {
                      return TripGridViewItem(
                        trip: tripData,
                      );
                    }).toList(growable: false),
                  );
                },
              );

      case AsyncError():
        return const Center(
          child: Text('Error'),
        );
      case AsyncLoading():
        return const Center(
          child: CircularProgressIndicator(),
        );

      case _:
        return const Center(
          child: Text('Error'),
        );
    }
  }
}

步骤 13:打开 trips_list_page.dart 文件并使用以下代码更新该文件,以使用上面创建的 TripsListGridView 窗口部件显示行程。

import 'package:amplify_trips_planner/common/utils/colors.dart' as constants;
import 'package:amplify_trips_planner/features/trip/controller/trips_list_controller.dart';
import 'package:amplify_trips_planner/features/trip/ui/trips_gridview/trips_list_gridview.dart';
import 'package:amplify_trips_planner/features/trip/ui/trips_list/add_trip_bottomsheet.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class TripsListPage extends ConsumerWidget {
  const TripsListPage({
    super.key,
  });

  Future<void> showAddTripDialog(BuildContext context) =>
      showModalBottomSheet<void>(
        isScrollControlled: true,
        elevation: 5,
        context: context,
        builder: (sheetContext) {
          return const AddTripBottomSheet();
        },
      );

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final tripsListValue = ref.watch(tripsListControllerProvider);
    return Scaffold(
      appBar: AppBar(
        centerTitle: true,
        title: const Text(
          'Amplify Trips Planner',
        ),
        backgroundColor: const Color(constants.primaryColorDark),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          showAddTripDialog(context);
        },
        backgroundColor: const Color(constants.primaryColorDark),
        child: const Icon(Icons.add),
      ),
      body: TripsListGridView(
        tripsList: tripsListValue,
      ),
    );
  }
}

步骤 14:在模拟器中运行该应用程序并创建一个行程。下面是一个使用 iPhone 模拟器的示例。 

结果

在本模块中,您已经使用 Amplify 添加了一个用于行程的 GraphQL API,并在您的应用程序中配置了用来创建、读取、更新和删除行程的功能。

添加 Amplify 存储

此页内容对您是否有帮助?