このチャプターについて
このチャプターでは、ハンズオンで作成するスケジュール管理アプリの、Flutter による UI と、スケジュール一覧の取得・スケジュールの作成機能の解説を行います。
前提
このチャプターで説明する Flutter による実装は、前章までの LIFF アプリとしての起動に必要な設定や実装が前提となっています。
また、次章で説明する GAS およびスプレッドシートの準備、GAS によるスケジュール一覧、スケジュール作成 API の実装およびデプロイが済んでいるものとして解説を行いますので、必要に応じて次章も参考にしながらお読みください。
前提として、サンプルリポジトリの env.sample
を参考に、プロジェクトルートに次のような .env
ファイルを作成してください。
LIFFID=<あなたの LIFF ID>
GAS_URL=<あなたの GAS URL。例:https://script.google.com/macros/s/.../exec>
LIFF アプリとして LINE アプリのトーク画面内などから起動する以外には、通常の Flutter Web アプリを開発するのと違いは特にありません。
Flutter Web アプリの起動と main 関数
README.md に記述している通り、次のコマンドで Flutter Web アプリを Web サーバとして起動します。
flutter run -d web-server --web-port 8080
# FVM を使用している場合は fvm flutter とします。
fvm flutter run -d web-server --web-port 8080
Flutter が起動すると、通常 lib/main.dart
の main
関数が実行されます。
main
関数で実行されるべき最小限の内容は、runApp
関数を実行することです。runApp
関数の引数には、ウィジェットツリーのルートとして、はじめに描画すべきウィジェットクラスのインスタンスを指定します。ここでは App()
インスタンス(実装内容は後述)を指定しました。
void main() {
runApp(const App());
}
サンプルアプリでは、runApp()
を実行する前に
-
.env
からの環境変数の読み込み - JS の LIFF SDK を呼び出し、LIFF アプリの初期化、ログイン中のユーザー ID の取得
を行っています。
/// LIFF アプリとして起動すると、main() の中で 上書きされるユーザー ID。
String userId = '';
Future<void> main() async {
await dotenv.load(fileName: '.env');
final liffId = dotenv.get('LIFFID', fallback: '.env に LIFFID を指定してください。');
// JS の Promise を Dart の Future に変換するために promiseToFuture() でラップする。
await promiseToFuture(
liff.init(
liff.Config(
liffId: liffId,
// JS に関数を渡すために allowInterop() でラップする。
successCallback: allowInterop(() => log('LIFF の初期化に成功しました。')),
errorCallback: allowInterop((e) => log('LIFF の初期化に失敗しました。$e')),
),
),
);
// デバッグモードではユーザー ID 取得のエラーを無視する。
userId = await promiseToFuture(liff.getUserId(kDebugMode));
runApp(const App());
}
runApp()
に渡した App
クラスは StatelessWidget
を継承しており、build()
メソッド内
で MaterialApp()
インスタンスを返しています。その home
属性に SchedulesPage
クラス(実装内容は後述)のインスタンスを指定しているので、アプリの起動後ははじめに SchedulesPage
が表示されるようになります。
/// main() の runApp() の引数に指定するウィジェット。
/// 中で MaterialApp を返す。
class App extends StatelessWidget {
const App({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter LIFF Scheduler',
scrollBehavior: const MaterialScrollBehavior().copyWith(
dragDevices: {
PointerDeviceKind.mouse,
PointerDeviceKind.touch,
PointerDeviceKind.stylus,
PointerDeviceKind.unknown,
},
),
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const SchedulesPage(),
);
}
}
StatefulWidget の基本
スケジュール一覧ページの UI の実装について説明する前に、Flutter の StatefulWidget
による UI 構築と状態管理・画面の再描画について簡単に説明します。
Flutter の StatefulWidget
は、下記のようなスニペットの 2 つのクラスで記述します。
- 前者は
StatefulWidget
クラスを継承したCounterPage
クラス - 後者は
State<CounterPage>
クラスを継承したCounterPageState
クラス
です。
class CounterPage extends StatefulWidget {
const CounterPage({super.key});
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('ボタンを押した回数'),
Text('$_counter'),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
画面に表示されるのは CounterPageState
クラスの build()
メソッドで返したウィジェットです。
CounterPageState
クラスのメンバとして定義した変数(ここでは int
型の _counter
)は、そのウィジェットのいわゆる「状態」であり、setState(() {})
を行うことで更新され、画面が再描画されます。
Scaffold
は、appBar
, body
, floatingActionButton
などを与えることができる、アプリの UI の骨格を構成するようなウィジェットです。
つまり上記の例は、画面右下の FloatingActionButton
をタップする度に、_counter
の値が 1 つずつ増えて画面が再描画され、画面(Scaffold
の body
)の中央に「ボタンを押した回数」という文字列と、_counter
の値が縦並びの TextWidget
として表示されるような画面であるということです。
スケジュール一覧ページ
それでは上記を踏まえて、スケジュール一覧ページの UI の実装について説明します。
次のアコーディオン(トグル)の中に、スケジュール一覧画面の完成形のソースコードを記述しているので、必要に応じて展開し、中身を確認しながら読み進めてください。
【完成形】スケジュール一覧ページ (ui/schedule.dart)
/// スケジュール一覧ページ。
class SchedulesPage extends StatefulWidget {
const SchedulesPage({super.key});
SchedulesPageState createState() => SchedulesPageState();
}
class SchedulesPageState extends State<SchedulesPage> {
Future<List<Schedule>> _schedules = fetchSchedules();
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('スケジュール一覧')),
body: FutureBuilder<List<Schedule>>(
future: _schedules,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
final schedules = snapshot.data ?? [];
return RefreshIndicator(
onRefresh: () async {
_schedules = fetchSchedules();
await _schedules;
setState(() {});
},
child: Column(
children: [
if (userId.isEmpty)
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.yellow[100],
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'⚠️ ユーザー ID を取得できていないため '
'LIFF アプリとして正常に機能しない可能性があります',
),
),
Expanded(
child: ListView.builder(
itemCount: schedules.length,
itemBuilder: (context, index) {
final schedule = schedules[index];
return ListTile(
leading: Container(
margin: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: schedule.isNotified ? Colors.grey : Colors.blue,
borderRadius: BorderRadius.circular(4),
),
child: Text(
schedule.isNotified ? '通知済み' : '通知予定',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
),
title: Text(
schedule.title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(schedule.dueDateTime.toJapaneseFormat),
);
},
),
),
],
),
);
}
return const Center(child: CircularProgressIndicator());
},
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const CreateSchedulePage(),
fullscreenDialog: true,
),
);
// 元の画面に戻った際にスケジュール一覧を取得・描画し直す。
_schedules = fetchSchedules();
setState(() {});
},
),
);
}
}
スケジュール一覧を表示するページとして、StatefulWidget
を継承した SchedulesPage
を定義します。
SchedulesPageState
クラスの build
メソッドでは、FutureBuilder<T>
ウィジェットを返します。
class SchedulesPage extends StatefulWidget {
const SchedulesPage({super.key});
SchedulesPageState createState() => SchedulesPageState();
}
class SchedulesPageState extends State<SchedulesPage> {
Future<List<Schedule>> _schedules = fetchSchedules();
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('スケジュール一覧')),
body: FutureBuilder<List<Schedule>>(
future: _schedules,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
// future 属性に指定したスケジュール取得が解決した場合は、
// その内容を List<Schedule> 型として取得し、ListView.builder で描画する。
final schedules = snapshot.data ?? [];
return ListView.builder(
itemCount: schedules.length,
itemBuilder: (context, index) {
final schedule = schedules[index];
return ListTile(title: Text(schedule.title));
},
);
} else {
// future 属性に指定したスケジュール取得の解決を待つ間は、
// 画面中央にローディングウィジェットを表示する。
return const Center(child: CircularProgressIndicator());
}
},
),
);
}
}
FutureBuilder<T>
ウィジェットは、その future
属性にジェネリクスの <T>
型と一致する Future<T>
型のインスタンスを指定することで、その Future
型の非同期処理の解決(接続)状況に応じた処理、つまり表示する画面を分岐することができます。
つまり、上記の例は、スケジュール一覧を取得して Future<List<Schedule>>
型を返す fetchSchedules()
メソッド(詳細は後述)の結果を FutureBuilder<List<Schedule>>
の future
属性に指定することで、スケジュール一覧が済むまでの間は、画面中央にローディングウィジェットを、スケジュール一覧が済んだ後は、ListView.builder
によってスケジュール一覧を複数の ListTile
で並べた画面を表示するような実装内容です。
スケジュール一覧の取得
スケジュール一覧の取得メソッドである fetchSchedules()
は、http_request.dart
に記述しています。
【完成形】スケジュール一覧の取得を含む http リクエスト (http_request.dart)
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import 'main.dart';
import 'schedule.dart';
/// Get Schedules API (GAS) をコールして、指定したユーザーのスケジュール一覧を取得する。
Future<List<Schedule>> fetchSchedules() async {
final response = await http.get(
_gasUri(addUserIdToQueryParameter: true),
headers: <String, String>{
'Accept': 'application/json',
},
);
_log(response);
final schedules =
(jsonDecode(response.body) as List).map((json) => Schedule.fromJson(json)).toList();
return schedules;
}
/// Create Schedule API (GAS) をコールして、スケジュールを作成する。
Future<void> createSchedule({
required String title,
required DateTime dueDateTime,
}) async {
final response = await http.post(
_gasUri(),
body: <String, dynamic>{
'userId': userId,
'title': title,
'dueDateTime': dueDateTime.toIso8601String(),
},
headers: <String, String>{
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
);
_log(response);
}
/// dotenv に記述した GAS の WEB App のエンドポイントの Uri を返す。
/// addUserIdToQueryParameter: true とすると、Uri に userId={userId} の
/// クエリパラメータを付加する。
Uri _gasUri({bool addUserIdToQueryParameter = false}) {
final gasUrl = dotenv.get('GAS_URL');
if (gasUrl.isEmpty) {
throw UnimplementedError('.env に GAS_URL を指定してください。');
}
return Uri.parse('$gasUrl${addUserIdToQueryParameter ? '?userId=$userId' : ''}');
}
/// デバッグ用に HTTP リクエスト・レスポンスの概要をコンソールに出力する。
void _log(http.Response response) {
final stringBuffer = StringBuffer();
stringBuffer.write('********************\n');
final request = response.request;
if (request != null) {
stringBuffer.write('[Request]\n');
stringBuffer.write('${request.method} ${request.url.toString()}\n');
}
stringBuffer.write('[Response]\n');
stringBuffer.write('${response.statusCode} ${response.body}\n');
stringBuffer.write('********************');
debugPrint(stringBuffer.toString());
}
このサンプルでは、スケジュール一覧の取得は GAS で実装した API エンドポイントに GET リクエストすることで行っています。
ここでは http
パッケージを用いて HTTP リクエストを行っています。
http.get
の第一引数には API のエンドポイント URL 文字列から Uri.parse()
で生成した Uri
インスタンスを指定することで、GET リクエストを行います。
第二引数には、任意でリクエストヘッダを Map<String, String>
型で指定することができます。
/// Get Schedules API (GAS) をコールして、指定したユーザーのスケジュール一覧を取得する。
Future<List<Schedule>> fetchSchedules() async {
final response = await http.get(
Uri.parse('https://script.google.com/macros/s/.../exec?userId=<あなたのユーザーID>'),
headers: <String, String>{
'Accept': 'application/json',
},
);
final schedules =
(jsonDecode(response.body) as List).map((json) => Schedule.fromJson(json)).toList();
return schedules;
}
【完成形】スケジュールクラス (schedule.dart)
/// アプリで管理・操作するスケジュール。
class Schedule {
Schedule({
required this.userId,
required this.title,
required this.dueDateTime,
this.isNotified = false,
});
/// LIFF のユーザー ID。
final String userId;
/// スケジュールのタイトル。
final String title;
/// スケジュールの締め切り日時。
final DateTime dueDateTime;
/// スケジュールを LINE Messaging API で通知済みかどうか。
final bool isNotified;
factory Schedule.fromJson(Map<String, dynamic> json) => Schedule(
userId: (json['userId'] ?? '') as String,
title: (json['title'] ?? '') as String,
dueDateTime: DateTime.fromMillisecondsSinceEpoch(json['dueDateTime']),
isNotified: json['isNotified'] ?? false,
);
}
Schedule
クラスは上の完成形のように実装しているので、その factory コンストラクタによって Schedule.fromJson(json)
とすることで、レスポンスボディから得られる Map<String, dynamic>
型(いわゆる JSON 形式)のデータから、スケジュールインスタンスを生成しています。
factory Schedule.fromJson(Map<String, dynamic> json) => Schedule(
userId: (json['userId'] ?? '') as String,
title: (json['title'] ?? '') as String,
dueDateTime: DateTime.fromMillisecondsSinceEpoch(json['dueDateTime']),
isNotified: json['isNotified'] ?? false,
);
スケジュール作成ページ
スケジュール作成ページの完成形は次の通りです。
【完成形】スケジュール作成ページ (ui/schedule.dart)
/// スケジュールを作成するページ。
class CreateSchedulePage extends StatefulWidget {
const CreateSchedulePage({super.key});
CreateSchedulePageState createState() => CreateSchedulePageState();
}
class CreateSchedulePageState extends State<CreateSchedulePage> {
late TextEditingController titleController;
late TextEditingController dueDateTimeController;
DateTime? dueDateTime;
bool submitting = false;
void initState() {
titleController = TextEditingController();
dueDateTimeController = TextEditingController();
super.initState();
}
void dispose() {
titleController.dispose();
dueDateTimeController.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('スケジュール登録')),
body: Center(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(
labelText: 'スケジュール名',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 32),
TextField(
controller: dueDateTimeController,
readOnly: true,
onTap: () async {
final now = DateTime.now();
final oneHourLater = DateTime(
now.year,
now.month,
now.day,
now.hour + 1,
);
await DatePicker.showDateTimePicker(
context,
currentTime: dueDateTime ?? oneHourLater,
minTime: oneHourLater,
onConfirm: (dateTime) => setState(() {
dueDateTime = dateTime;
dueDateTimeController.text = dateTime.toJapaneseFormat;
}),
);
},
decoration: const InputDecoration(
labelText: '日時',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: () => submitting ? null : _createSchedule(),
child: Text(submitting ? '通信中...' : 'スケジュールを登録'),
),
],
),
),
),
),
);
}
/// スケジュールを登録する。
Future<void> _createSchedule() async {
final navigator = Navigator.of(context);
final title = titleController.value.text;
if (title.isEmpty) {
_showSnackBar('タイトルを入力してください。');
return;
}
if (dueDateTime == null) {
_showSnackBar('日時を入力してください。');
return;
}
setState(() => submitting = true);
try {
await createSchedule(title: title, dueDateTime: dueDateTime!);
} finally {
setState(() => submitting = false);
}
navigator.pop();
}
/// SnackBar を表示する。
void _showSnackBar(String text) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
}
スケジュール一覧ページに続いて、スケジュール作成ページの実装について説明していきます。
ここでも StatefulWidget
を用いて画面を構成することは共通しています。
スケジュール作成ページは、ユーザーからスケジュールのタイトル (title
) と締め切り日時 (dueDateTime
) の入力を受け付けて、スケジュール作成 API の POST リクエストを行うためのページです。
ユーザーからの文字列の入力を受け付けるために TextEditingController
と TextField
ウィジェットを使用します。
TextEditingController()
インスタンスを TextField
ウィジェットの controller
属性に指定することで、TextEditingController
を TextField
に紐付けることができます。
final titleController = TextEditingController();
TextField(
controller: titleController,
decoration: const InputDecoration(
labelText: 'スケジュール名',
border: OutlineInputBorder(),
),
),
こうすることで、ユーザーが TextField
に入力した文字列を、titleController.value.text
のようにして取得して POST リクエストをする際に使用することができます。
Future<void> _createSchedule() async {
final title = titleController.value.text;
await createSchedule(title: title, dueDateTime: dueDateTime);
}
締め切り日時のユーザー入力は、flutter_datetime_picker という外部パッケージを使用することにしました。
pubspec.yaml
の dependencies
にパッケージ名とバージョン番号を所定の方法で記述し、flutter pub get
(FVM を使用している場合は fvm flutter pub get
します。
dependencies:
flutter_datetime_picker: ^1.5.1
fvm flutter pub get
flutter_datetime_picker の使用は使用方法の詳細の説明は省きますが、数のように画面下部からドラムロール式で日時を選択することができる UI が現れます。
選択した日時を対応する StatefulWidget
の状態の変数として保存し、スケジュールの作成 POST リクエストをする前に少しのバリデーションと、バリデーションの結果が不正な場合にスナックバーを表示する処理、送信処理中の二度押し防止の処理、リクエスト終了後に元のスケジュール一覧画面に戻る処理を書き加えたものが下記の通りです。
class CreateSchedulePageState extends State<CreateSchedulePage> {
// ... 省略
/// スケジュールを登録する。
Future<void> _createSchedule() async {
final navigator = Navigator.of(context);
final title = titleController.value.text;
if (title.isEmpty) {
_showSnackBar('タイトルを入力してください。');
return;
}
if (dueDateTime == null) {
_showSnackBar('日時を入力してください。');
return;
}
setState(() => submitting = true);
try {
await createSchedule(title: title, dueDateTime: dueDateTime!);
} finally {
setState(() => submitting = false);
}
navigator.pop();
}
/// SnackBar を表示する。
void _showSnackBar(String text) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
}
}
まとめ
最後に、スケジュール一覧を表示・作成するための、Flutter 実装の中心的なコードをまとめて再掲します。
その他のファイルを含む実装全体は、flutter_liff_scheduler のリポジトリの lib
以下のディレクトリ・ファイルをご覧ください。
import 'package:flutter/material.dart';
import 'package:flutter_datetime_picker/flutter_datetime_picker.dart';
import 'package:flutter_liff_scheduler/main.dart';
import 'package:flutter_liff_scheduler/utils/date_time.dart';
import '../http_request.dart';
import '../schedule.dart';
/// スケジュール一覧ページ。
class SchedulesPage extends StatefulWidget {
const SchedulesPage({super.key});
SchedulesPageState createState() => SchedulesPageState();
}
class SchedulesPageState extends State<SchedulesPage> {
Future<List<Schedule>> _schedules = fetchSchedules();
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('スケジュール一覧')),
body: FutureBuilder<List<Schedule>>(
future: _schedules,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
final schedules = snapshot.data ?? [];
return RefreshIndicator(
onRefresh: () async {
_schedules = fetchSchedules();
await _schedules;
setState(() {});
},
child: Column(
children: [
if (userId.isEmpty)
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.yellow[100],
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'⚠️ ユーザー ID を取得できていないため '
'LIFF アプリとして正常に機能しない可能性があります',
),
),
Expanded(
child: ListView.builder(
itemCount: schedules.length,
itemBuilder: (context, index) {
final schedule = schedules[index];
return ListTile(
leading: Container(
margin: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: schedule.isNotified ? Colors.grey : Colors.blue,
borderRadius: BorderRadius.circular(4),
),
child: Text(
schedule.isNotified ? '通知済み' : '通知予定',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
),
title: Text(
schedule.title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(schedule.dueDateTime.toJapaneseFormat),
);
},
),
),
],
),
);
}
return const Center(child: CircularProgressIndicator());
},
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const CreateSchedulePage(),
fullscreenDialog: true,
),
);
// 元の画面に戻った際にスケジュール一覧を取得・描画し直す。
_schedules = fetchSchedules();
setState(() {});
},
),
);
}
}
/// スケジュールを作成するページ。
class CreateSchedulePage extends StatefulWidget {
const CreateSchedulePage({super.key});
CreateSchedulePageState createState() => CreateSchedulePageState();
}
class CreateSchedulePageState extends State<CreateSchedulePage> {
late TextEditingController titleController;
late TextEditingController dueDateTimeController;
DateTime? dueDateTime;
bool submitting = false;
void initState() {
titleController = TextEditingController();
dueDateTimeController = TextEditingController();
super.initState();
}
void dispose() {
titleController.dispose();
dueDateTimeController.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('スケジュール登録')),
body: Center(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(
labelText: 'スケジュール名',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 32),
TextField(
controller: dueDateTimeController,
readOnly: true,
onTap: () async {
final now = DateTime.now();
final oneHourLater = DateTime(
now.year,
now.month,
now.day,
now.hour + 1,
);
await DatePicker.showDateTimePicker(
context,
currentTime: dueDateTime ?? oneHourLater,
minTime: oneHourLater,
onConfirm: (dateTime) => setState(() {
dueDateTime = dateTime;
dueDateTimeController.text = dateTime.toJapaneseFormat;
}),
);
},
decoration: const InputDecoration(
labelText: '日時',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: () => submitting ? null : _createSchedule(),
child: Text(submitting ? '通信中...' : 'スケジュールを登録'),
),
],
),
),
),
),
);
}
/// スケジュールを登録する。
Future<void> _createSchedule() async {
final navigator = Navigator.of(context);
final title = titleController.value.text;
if (title.isEmpty) {
_showSnackBar('タイトルを入力してください。');
return;
}
if (dueDateTime == null) {
_showSnackBar('日時を入力してください。');
return;
}
setState(() => submitting = true);
try {
await createSchedule(title: title, dueDateTime: dueDateTime!);
} finally {
setState(() => submitting = false);
}
navigator.pop();
}
/// SnackBar を表示する。
void _showSnackBar(String text) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
}