Chapter 09

【コード解説:Flutter】UI と、スケジュール一覧の取得・スケジュールの作成機能の実装

tokku5552
tokku5552
2022.11.23に更新

このチャプターについて

このチャプターでは、ハンズオンで作成するスケジュール管理アプリの、Flutter による UI と、スケジュール一覧の取得・スケジュールの作成機能の解説を行います。

前提

このチャプターで説明する Flutter による実装は、前章までの LIFF アプリとしての起動に必要な設定や実装が前提となっています。

また、次章で説明する GAS およびスプレッドシートの準備、GAS によるスケジュール一覧、スケジュール作成 API の実装およびデプロイが済んでいるものとして解説を行いますので、必要に応じて次章も参考にしながらお読みください。

前提として、サンプルリポジトリenv.sample を参考に、プロジェクトルートに次のような .env ファイルを作成してください。

.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.dartmain 関数が実行されます。

main 関数で実行されるべき最小限の内容は、runApp 関数を実行することです。runApp 関数の引数には、ウィジェットツリーのルートとして、はじめに描画すべきウィジェットクラスのインスタンスを指定します。ここでは App() インスタンス(実装内容は後述)を指定しました。

main.dart
void main() {
  runApp(const App());
}

サンプルアプリでは、runApp() を実行する前に

  • .env からの環境変数の読み込み
  • JS の LIFF SDK を呼び出し、LIFF アプリの初期化、ログイン中のユーザー ID の取得

を行っています。

main.dart
/// 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.dart
/// 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 クラス

です。

StatefulWidgetでのページ実装の例
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),
      ),
    );
  }
}

counter

画面に表示されるのは CounterPageState クラスの build() メソッドで返したウィジェットです。

CounterPageState クラスのメンバとして定義した変数(ここでは int 型の _counter)は、そのウィジェットのいわゆる「状態」であり、setState(() {}) を行うことで更新され、画面が再描画されます。

Scaffold は、appBar, body, floatingActionButton などを与えることができる、アプリの UI の骨格を構成するようなウィジェットです。

つまり上記の例は、画面右下の FloatingActionButton をタップする度に、_counter の値が 1 つずつ増えて画面が再描画され、画面(Scaffoldbody)の中央に「ボタンを押した回数」という文字列と、_counter の値が縦並びの TextWidget として表示されるような画面であるということです。

スケジュール一覧ページ

それでは上記を踏まえて、スケジュール一覧ページの UI の実装について説明します。

次のアコーディオン(トグル)の中に、スケジュール一覧画面の完成形のソースコードを記述しているので、必要に応じて展開し、中身を確認しながら読み進めてください。

【完成形】スケジュール一覧ページ (ui/schedule.dart)
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(() {});
        },
      ),
    );
  }
}

schedules

スケジュール一覧を表示するページとして、StatefulWidget を継承した SchedulesPage を定義します。

SchedulesPageState クラスの build メソッドでは、FutureBuilder<T> ウィジェットを返します。

lib/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) {
            // 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)
lib/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)
lib/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 形式)のデータから、スケジュールインスタンスを生成しています。

schedule.dart
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)
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)));
}

create schedules

スケジュール一覧ページに続いて、スケジュール作成ページの実装について説明していきます。

ここでも StatefulWidget を用いて画面を構成することは共通しています。

スケジュール作成ページは、ユーザーからスケジュールのタイトル (title) と締め切り日時 (dueDateTime) の入力を受け付けて、スケジュール作成 API の POST リクエストを行うためのページです。

ユーザーからの文字列の入力を受け付けるために TextEditingControllerTextField ウィジェットを使用します。

TextEditingController() インスタンスを TextField ウィジェットの controller 属性に指定することで、TextEditingControllerTextField に紐付けることができます。

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.yamldependencies にパッケージ名とバージョン番号を所定の方法で記述し、flutter pub get(FVM を使用している場合は fvm flutter pub get します。

pubspec.yaml
dependencies:
  flutter_datetime_picker: ^1.5.1
fvm flutter pub get

flutter_datetime_picker の使用は使用方法の詳細の説明は省きますが、数のように画面下部からドラムロール式で日時を選択することができる UI が現れます。

datetime_picker

選択した日時を対応する 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 以下のディレクトリ・ファイルをご覧ください。

ui/schedule.dart
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)));
}