はじめに
ここ最近はもっぱらFlutter推しな早川です。初めて触ったのは去年の夏ごろですが、こんなに簡単にモバイルアプリが作れるのかー、と感動したものです。Flutter2がリリースされて、よりイケイケになったFlutterをエバンジェリストとして社内に広めていきたいな、と思っています。
今回はまだFlutter Webには対応してないんかい!とツッコミを入れつつも、AWS Amplifyを組み合わせて雑談アプリを作っていきたいと思います。なぜFirebaseじゃなくてAmplify?という声も聞こえてきそうですが、いいんです!やりたいからやるんですw
少しロングコンテンツですが、お付き合いいただければ幸いです。
考える
雑談アプリの構成はいたってシンプルです。まぁ、よくある構成ですね。モバイルアプリだとMacで開発されている方が多いかもしれませんが、ここではWindowsでAndroid Emulatorを使って動かすところまでやっていきます。
画面もDataStoreにフォーカスするために入室画面(認証なし!)と雑談画面の2画面です。
![]() |
![]() |
ハンドル名を入力して入室し、メッセージを送信すると相手側にもリアルタイムにメッセージが表示されます。
準備する
まず環境を整えます。環境の作り方についてはそこら中に書かれている方がいらっしゃると思いますので、ここでは深くは触れませんが、ざっくり以下のような手順を踏みます。
-
- Flutterをインストールする(ダウンロードしてパスを通すだけ)
- Android Studioをインストールし、Virtual Deviceを作る
- VSCodeをインストールし、Flutterの拡張機能を有効にする
- Node.jsをインストールする(nvmで複数バージョン管理すると便利ですね)
- Amplify CLIをインストールする
作る
それでは早速作っていきましょう!
プロジェクトの作成
VSCodeを起動し、Ctrl+Shift+Pでコマンドパレットを開き、「Flutter: New Project」から「Application」を選択します。
![]() |
![]() |
プロジェクトを保存するフォルダを選択し、プロジェクト名(ここではmiso_app)を入力します。
![]() |
![]() |
スケルトンではコメントがいっぱい記述されているので、正規表現で「//.*」を空置換しておきます。また、MyAppやMyHomePageも好きな名前に置換しておきましょう。今回はMisoAppとMisoHomePageに置換しました。
ここでプロジェクトのルートにあるpubspec.yamlを編集して、今回必要なライブラリを追加しておきます。雑談メッセージをかわいく吹き出し風に表示するためにchat_bubblesというライブラリも使っています。欲しいUIなどをサクッとpub.devで検索して組み込めるのもFlutterのいいところ。
1 2 3 4 5 6 7 |
dependencies: flutter_localizations: sdk: flutter amplify_flutter: ^0.2.10 amplify_datastore: ^0.2.10 amplify_api: ^0.2.10 chat_bubbles: ^1.1.0 |
本来あればpubspec.yamlを編集したらflutter pub getコマンドを実行しますが、VSCode上で編集すると自動で実行してくれるので便利ですね!
ここで一旦デフォルトのカウンタアプリが動くか確認しておくといいと思います。Flutter Amplifyあるあるですが、AmplifyはAndroidのSDKバージョン21以上を必要とするためandroid\app\build.gradleを編集しておくのを忘れずに!
1 2 3 |
defaultConfig { minSdkVersion 21 } |
モデルとリソースの作成
続いて雑談アプリをやり取りするためのモデルを作成するとともにAWS上にリソースを作っていきます。
1.Amplifyの初期化
VSCodeでコマンドプロンプトのターミナルを開き、以下のAmplifyの設定コマンドを実行します。
AWSコンソールが表示されるので、サインインしたらターミナルでEnterを押します。
1 2 3 4 5 6 |
> amplify configure Follow these steps to set up access to your AWS account: Sign in to your AWS administrator account: https://console.aws.amazon.com/ Press Enter to continue |
リージョン(? region)と作成するIAMユーザー名(? user name)を入力します。
1 2 3 4 5 6 7 |
Specify the AWS Region ? region: ap-northeast-1 Specify the username of the new IAM user: ? user name: (amplify-XXXXX) Complete the user creation using the AWS console https://console.aws.amazon.com/iam/home?region=ap-northeast-1#/users$new?step=final&accessKey&userNames=amplify-XXXXX&permissionType=policies&policies=arn:aws:iam::aws:policy%2FAdministratorAccess Press Enter to continue |
再びAWSコンソールの画面が表示されるので、そのまま「次のステップ:アクセス権限」ボタンをクリックします。
「AdministratorAccess-Amplify」にチェックを入れ、「次のステップ:タグ」ボタンをクリックします。「AdministratorAccess」にチェックが入っていたら外しましょう。
必要であればタグを入力し、「次のステップ:確認」ボタンをクリックします。
入力内容を確認して「ユーザーの作成」ボタンをクリックします。
後で使用するので、「.csvのダウンロード」ボタンをクリックしておきます。
ダウンロードしたCSVファイルからアクセスキーID(? accessKeyId)とシークレットアクセスキー(? secretAccessKey)、プロファイル名(? Profile Name)を入力すると設定完了です。
1 2 3 4 5 6 7 |
Enter the access key of the newly created user: ? accessKeyId: ******************** ? secretAccessKey: **************************************** This would update/create the AWS Profile in your local machine ? Profile Name: miso Successfully set up the new user. |
次にAmplifyの初期化コマンドを実行します。プロジェクト名(? Enter a name for the project)を入力して設定を確認したら「Y」を入力します。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
> amplify init Note: It is recommended to run this command from the root of your app directory ? Enter a name for the project misoapp The following configuration will be applied: Project information | Name: misoapp | Environment: dev | Default editor: Visual Studio Code | App type: flutter | Configuration file location: ./lib/ ? Initialize the project with the above configuration? (Y/n) Y |
使用する認証方法(? Select the authentication method you want to use:)で「AWS profile」を選択し、先ほど入力したプロファイル名(? Please choose the profile you want to use)を選択すると、初期化処理のCloudFormationが実行されます。”Your project has been successfully initialized and connected to the cloud!”というメッセージが表示されたら無事に初期化完了です。
1 2 3 4 5 6 |
Using default provider awscloudformation ? Select the authentication method you want to use: AWS profile For more information on AWS Profiles, see: https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html ? Please choose the profile you want to use miso |
2.GraphQL APIの作成
GraphQLのスキーマ情報を追加していきます。以下のコマンドを実行し、情報を入力します。
1 2 3 4 5 6 7 |
> amplify add 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 Conflict detection (required for DataStore): Disabled ? Enable conflict detection? Yes ? Select the default resolution strategy Auto Merge ? Here is the GraphQL API that we will create. Select a setting to edit or continue Continue ? Choose a schema template: Single object with fields (e.g., “Todo” with ID, name, description) |
GraphQLのスキーマが作成されると “Do you want to edit the schema now? (Y/n)”と聞かれますが、一旦ここでは「n」と入力しておきます。
3.モデルの作成
スキーマ作成時のメッセージでschema.graphqlへのパスが表示されているはずです。これがGraphQLのスキーマ情報となり、プロジェクトのルート\amplify\backend\api\初期化したときに入力したプロジェクト名、となっていると思います。デフォルトではTodoというスキーマが作成されているかと思いますので、こちらを今回用に編集します。
1 2 3 4 5 6 7 |
type SmallTalk @model { id: ID! room: String! @index(name: "sortByCreatedAt", sortKeyFields: ["createdAt"]) name: String! message: String createdAt: AWSDateTime! } |
雑談メッセージは投稿したタイムスタンプ順にソートしたいので、@indexでインデックスをつけておきます。
編集が完了したら、以下のコマンドを実行しモデルを作成します。
1 |
> amplify codegen models |
4.リソースの作成
AWS上にGraphQL APIのためのAppSyncとデータが保管されるDynamoDBのリソースを作成していきます。以下のコマンドを実行します。
1 2 |
> amplify push ? Are you sure you want to continue? Yes |
CloudFormationが実行され、自動的にリソースが作成されます。
UIの作成
それでは今回のUIを作成していきましょう。FlutterでUIを組み立てていくのってなんだか楽しい、と思うのは私だけでしょうか??
1.入室画面のUI作成
こちらはデフォルトで作成されたmain.dartを編集していきます。MisoAppクラスの内容は以下のようになります。そのままだと中国語フォントっぽくなったり、デバッグ時のバナーが出たりするのを変更しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
class MisoApp extends StatelessWidget { const MisoApp({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return MaterialApp( title: '雑談の小部屋', theme: ThemeData( primarySwatch: Colors.red, ), home: const MisoHomePage(title: '雑談の小部屋'), // 日本語フォントで表示 locale: const Locale("ja", "JP"), localizationsDelegates: const [ GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], supportedLocales: const [Locale("ja", "JP")], // デバッグ時バナー非表示 debugShowCheckedModeBanner: false, ); } } |
_MisoHomePageStateクラスの内容は以下のようになります。ハンドル名未入力のチェックを入れてあります。入室ボタン押下時の処理は後で書きます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
class _MisoHomePageState extends State<MisoHomePage> { final _formKey = GlobalKey<FormState>(); final TextEditingController _handleNameController = TextEditingController(); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Form( key: _formKey, child: Container( alignment: Alignment.center, padding: const EdgeInsets.all(30.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ TextFormField( decoration: const InputDecoration( icon: Icon(Icons.person), labelText: "ハンドル名", hintText: "MISO", ), controller: _handleNameController, maxLength: 20, autovalidateMode: AutovalidateMode.disabled, validator: (String? value) { if (value == null || value.isEmpty) { return "ハンドル名を入力してください。"; } }, ), Container( padding: const EdgeInsets.all(30.0), width: double.infinity, child: ElevatedButton( onPressed: () {}, child: const Text('入室'), ), ), ], ), ), ), ); } } |
2.雑談画面のUI作成
libの下にsmall_talk_page.dartというファイルを作成します。私はVSCodeにAwesome Flutter Snippetsの拡張機能を入れているので、「statefulW」を補完することで一気に作っています。
補完されたらnameのところにSmallTalkPageと入力するだけでベースが完成します。まずは入室画面からハンドル名を受け取るためにSmallTalkPageクラスの内容を編集していきます。
1 2 3 4 5 6 7 8 |
class SmallTalkPage extends StatefulWidget { const SmallTalkPage({Key? key, required this.handleName}) : super(key: key); final String handleName; @override _SmallTalkPageState createState() => _SmallTalkPageState(); } |
続いて_SmallTalkPageStateクラスを編集します。自分の投稿(簡易的に入室した際のハンドル名と一致した場合を条件としています)は右側に来るようにし、スワイプで削除できるようにしてあります。フィールドにSmallTalkクラスのリストがありますが、これはGraphQLのスキーマから自動でモデル生成されたものです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 |
class _SmallTalkPageState extends State<SmallTalkPage> { List<SmallTalk> smallTalks = <SmallTalk>[]; final TextEditingController _messageController = TextEditingController(); Widget _buildMessage(int index) { if (widget.handleName == smallTalks[index].name) { return Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text(smallTalks[index].name), Dismissible( key: Key(smallTalks[index].id.toString()), direction: DismissDirection.endToStart, child: BubbleSpecialOne( text: smallTalks[index].message!, isSender: true, ), background: Container( padding: const EdgeInsets.only(right: 10), alignment: AlignmentDirectional.centerEnd, color: Colors.red, child: const Icon(Icons.delete, color: Colors.white), ), onDismissed: (direction) {}, ), ], ); } else { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(smallTalks[index].name), BubbleSpecialOne( text: smallTalks[index].message!, isSender: false, ), ], ); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text(''), ), body: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ Expanded( child: Padding( padding: const EdgeInsets.all(10.0), child: ListView.builder( itemCount: smallTalks.length, itemBuilder: (BuildContext context, int index) { return _buildMessage(index); }, ), ), ), Container( color: Colors.white, padding: const EdgeInsets.all(10.0), child: Form( child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Flexible( child: TextFormField( decoration: const InputDecoration( border: OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(10.0)), ), ), controller: _messageController, keyboardType: TextInputType.multiline, minLines: 1, maxLines: 5, ), ), IconButton( icon: const Icon(Icons.send, color: Colors.red), onPressed: () {}, ), ], ), ), ) ], ), backgroundColor: Colors.red.shade100, ); } } |
3.入室画面から雑談画面への遷移
雑談画面のUIが完成したところで入室画面から遷移できるようにしておきます。入室画面(main.dart)の入室ボタンのonPressedを以下のようにしましょう。入力チェックを通過した場合に雑談画面に遷移します。
1 2 3 4 5 6 7 8 9 10 |
onPressed: () { if (_formKey.currentState!.validate()) { Navigator.push(context, MaterialPageRoute(builder: (context) { return SmallTalkPage( handleName: _handleNameController.text, ); })); } }, |
処理の追加
処理は_SmallTalkPageStateクラスのほうに追加していきます。
まずは初期処理を追加します。GraphQLスキーマで定義した中にroomという項目がありましたが、ここでは”1″固定ということを想定しています(本当なら雑談部屋ごとにルームIDを振る感じになるかと思います)。DataStore.observeQuery()を実行する際、room=”1″というWhere条件をつけ、createdAtで降順ソートしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
@override void initState() { super.initState(); _configureAmplify(); } void _configureAmplify() async { // Amplifyの初期化処理 try { if (!Amplify.isConfigured) { AmplifyDataStore datastorePlugin = AmplifyDataStore(modelProvider: ModelProvider.instance); await Amplify.addPlugin(datastorePlugin); await Amplify.addPlugin(AmplifyAPI()); await Amplify.configure(amplifyconfig); } } on AmplifyAlreadyConfiguredException catch (e) { debugPrint('Amplify Configure failed: $e'); } // 入室時にDataStoreの内容をクリア await Amplify.DataStore.clear(); // Streamをlistenし、リアルタイムに雑談内容を受け取る Stream<QuerySnapshot<SmallTalk>> stream = Amplify.DataStore.observeQuery( SmallTalk.classType, where: SmallTalk.ROOM.eq("1"), sortBy: [SmallTalk.CREATEDAT.ascending()], ); stream.listen((QuerySnapshot<SmallTalk> snapshot) { if (mounted) { setState(() { smallTalks = snapshot.items; }); } }); } |
次にメッセージ送信時の処理を_postMessage()というメソッドで実装します。メッセージが入力されていたら、SmallTalkのオブジェクトを作ってDataStore.save()を実行するだけです。
1 2 3 4 5 6 7 8 9 10 11 12 |
void _postMessage() async { if (_messageController.text.trim().isNotEmpty) { SmallTalk smallTalk = SmallTalk( room: "1", name: widget.handleName, message: _messageController.text, createdAt: TemporalDateTime(DateTime.now()), ); await Amplify.DataStore.save(smallTalk); } _messageController.text = ''; } |
これをIconButtonのonPressedから呼び出せば、メッセージを送信できるようになります。
1 2 3 4 5 6 |
IconButton( icon: const Icon(Icons.send, color: Colors.red), onPressed: () { _postMessage(); }, ), |
さらに自分のメッセージをフリックで削除する機能を作っていきましょう。まず_deleteMessage()というメソッドを実装します。IDが一致したレコードを検索してDateStore.delete()で削除しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
void _deleteMessage(int index) async { List<SmallTalk> _smallTalks = await Amplify.DataStore.query( SmallTalk.classType, where: SmallTalk.ID.eq(smallTalks[index].id), ); for (SmallTalk _smallTalk in _smallTalks) { try { await Amplify.DataStore.delete(_smallTalk); } on DataStoreException catch (e) { debugPrint('Delete failed: $e'); } } } |
これを_buildMessage()内のDismissibleのonDismissedから呼び出すようにすれば、削除したいメッセージをフリックして削除できるようになります。
1 2 3 4 5 6 |
Dismissible( (中略) onDismissed: (direction) { _deleteMessage(index); }, ), |
動かす
さあ、これで完成したはずです!動作確認してみましょう!
misoさんが送ったメッセージがsoupさんの画面にリアルタイムに表示されていて、フリックで削除したら表示が消えています。
DymamoDBのほうも見てみると_deletedという属性が「true」になっているのが確認できます。物理削除ではなく論理削除なんですね。ちなみにAmplify Studioで確認してみたところ、論理削除されたデータも表示されませんでした。
せっかくDataStoreで作成したので、オフライン時の動きも確認したところ以下のような挙動になりました。
misoのエミュレータの機内モードをONに設定変更し、メッセージを送信する
→miso上は送信したように見えるが、 soup側には送信したメッセージは表示されない
misoのエミュレータの機内モードをOFFに設定変更する
→soup側には送信したメッセージは表示されない
misoから別のメッセージを送信する
→soup側には送信したメッセージがまとめて表示される
本当は機内モードから復帰したときに自動的に同期されてほしかったんですけどね。
作ってみて
爆速で作ると言っておきながら、いろいろ細かいことが気になって割と時間がかかってしまいました。とはいえ特に大きくハマることなく作ることができました。この記事を読んだ方はこの通りやればきっと爆速で完成すると思います!
それでは、いいFlutterライフを!!
執筆者プロフィール

- tdi デジタルイノベーション技術部
-
昔も今も新しいものが大好き!
インフラからアプリまで縦横無尽にトータルサポートや新技術の探求を行っています。
週末はときどきキャンプ場に出没します。