Flutterアプリケーション開発概論

【タスク作成】タイトル・説明・担当者・優先度・ステータスを保存する

30LINE風チームタスク管理アプリを作りながら、ログイン・データベース・権限管理を学ぶ
FlutteriOSAndroidMacOSWindows基礎から学ぶ開発アプリ開発

このページでやること

このページでは、タスクを新しく作成して、Firestoreに保存します。

保存場所はここです。

teams/{teamId}/tasks/{taskId}

タスク作成では、次の項目を保存します。

項目内容
titleタスク名
description説明
assigneeEmail担当者メール
priority優先度
status進行状況
createdBy作成者
createdAt作成日時
updatedAt更新日時

今日のゴール

右下の + ボタンを押して、タスクを作成できるようにします。

タスク一覧画面
↓
右下の+を押す
↓
タスク作成ボトムシートを表示
↓
タイトル・説明・担当者・優先度を入力
↓
Firestoreに保存
↓
タスク一覧に表示

npmや環境変数は必要?

このページでは、npmは使いません。

環境変数も設定しません。

すでに使っているFirestoreだけで進めます。

必要なimportはこちらです。

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';

Step 1:main.dartを開く

次のファイルを開きます。

lib/main.dart

ターミナルから開く場合はこちらです。

code lib/main.dart

Step 2:TaskListPageをStatefulWidgetにする

タスク作成では、入力欄を使います。

そのため、TaskListPageStatefulWidget にします。

今の TaskListPage を、次の形に差し替えます。

class TaskListPage extends StatefulWidget {
  const TaskListPage({
    super.key,
    required this.teamId,
    required this.teamName,
  });

  final String teamId;
  final String teamName;

  @override
  State<TaskListPage> createState() => _TaskListPageState();
}

class _TaskListPageState extends State<TaskListPage> {
  final titleController = TextEditingController();
  final descriptionController = TextEditingController();
  final assigneeEmailController = TextEditingController();

  String selectedPriority = 'normal';
  String selectedStatus = 'todo';

  bool isCreating = false;
  String? errorText;

  @override
  void dispose() {
    titleController.dispose();
    descriptionController.dispose();
    assigneeEmailController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: AppColors.chatBg,
      appBar: AppBar(
        title: Text(widget.teamName),
      ),
      body: StreamBuilder<QuerySnapshot<Map<String, dynamic>>>(
        stream: FirebaseFirestore.instance
            .collection('teams')
            .doc(widget.teamId)
            .collection('tasks')
            .snapshots(),
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const LoadingPage(message: 'タスクを読み込んでいます...');
          }

          if (snapshot.hasError) {
            return Center(
              child: Padding(
                padding: const EdgeInsets.all(20),
                child: ErrorBox(
                  message: 'タスク一覧の取得に失敗しました。',
                ),
              ),
            );
          }

          final tasks = snapshot.data?.docs ?? [];

          if (tasks.isEmpty) {
            return const Center(
              child: Text(
                'まだタスクがありません。\n右下の+から作成できます。',
                textAlign: TextAlign.center,
                style: TextStyle(
                  color: AppColors.subText,
                  fontWeight: FontWeight.w700,
                ),
              ),
            );
          }

          return ListView.separated(
            padding: const EdgeInsets.all(16),
            itemCount: tasks.length,
            separatorBuilder: (_, __) => const SizedBox(height: 10),
            itemBuilder: (context, index) {
              final doc = tasks[index];
              final data = doc.data();

              final title = (data['title'] ?? '無題のタスク').toString();
              final description = (data['description'] ?? '').toString();
              final status = (data['status'] ?? 'todo').toString();
              final priority = (data['priority'] ?? 'normal').toString();
              final assigneeEmail = (data['assigneeEmail'] ?? '').toString();

              return TaskCard(
                title: title,
                description: description,
                status: status,
                priority: priority,
                assigneeEmail: assigneeEmail,
              );
            },
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: showCreateTaskSheet,
        child: const Icon(Icons.add),
      ),
    );
  }
}

ここでは、まだ showCreateTaskSheet を作っていないので、次のStepで追加します。


Step 3:タスク作成ボトムシートを作る

_TaskListPageState の中に、次の関数を追加します。

Future<void> showCreateTaskSheet() async {
  titleController.clear();
  descriptionController.clear();
  assigneeEmailController.clear();

  selectedPriority = 'normal';
  selectedStatus = 'todo';

  errorText = null;

  await showModalBottomSheet<void>(
    context: context,
    isScrollControlled: true,
    backgroundColor: Colors.white,
    shape: const RoundedRectangleBorder(
      borderRadius: BorderRadius.vertical(
        top: Radius.circular(20),
      ),
    ),
    builder: (context) {
      return StatefulBuilder(
        builder: (context, setSheetState) {
          return Padding(
            padding: EdgeInsets.only(
              left: 20,
              right: 20,
              top: 20,
              bottom: MediaQuery.of(context).viewInsets.bottom + 20,
            ),
            child: SingleChildScrollView(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: [
                  const Text(
                    '新しいタスクを作成',
                    style: TextStyle(
                      fontSize: 20,
                      fontWeight: FontWeight.w900,
                      color: AppColors.text,
                    ),
                  ),
                  const SizedBox(height: 12),
                  AppTextField(
                    controller: titleController,
                    label: 'タスク名',
                  ),
                  const SizedBox(height: 12),
                  AppTextField(
                    controller: descriptionController,
                    label: '説明',
                    maxLines: 3,
                  ),
                  const SizedBox(height: 12),
                  AppTextField(
                    controller: assigneeEmailController,
                    label: '担当者メール',
                    keyboardType: TextInputType.emailAddress,
                  ),
                  const SizedBox(height: 12),
                  DropdownButtonFormField<String>(
                    value: selectedPriority,
                    decoration: const InputDecoration(
                      labelText: '優先度',
                      border: OutlineInputBorder(),
                    ),
                    items: const [
                      DropdownMenuItem(
                        value: 'low',
                        child: Text('低'),
                      ),
                      DropdownMenuItem(
                        value: 'normal',
                        child: Text('中'),
                      ),
                      DropdownMenuItem(
                        value: 'high',
                        child: Text('高'),
                      ),
                    ],
                    onChanged: (value) {
                      if (value == null) return;

                      setSheetState(() {
                        selectedPriority = value;
                      });
                    },
                  ),
                  const SizedBox(height: 12),
                  DropdownButtonFormField<String>(
                    value: selectedStatus,
                    decoration: const InputDecoration(
                      labelText: 'ステータス',
                      border: OutlineInputBorder(),
                    ),
                    items: const [
                      DropdownMenuItem(
                        value: 'todo',
                        child: Text('未対応'),
                      ),
                      DropdownMenuItem(
                        value: 'doing',
                        child: Text('進行中'),
                      ),
                      DropdownMenuItem(
                        value: 'done',
                        child: Text('完了'),
                      ),
                    ],
                    onChanged: (value) {
                      if (value == null) return;

                      setSheetState(() {
                        selectedStatus = value;
                      });
                    },
                  ),
                  if (errorText != null) ...[
                    const SizedBox(height: 12),
                    ErrorBox(message: errorText!),
                  ],
                  const SizedBox(height: 16),
                  FilledButton(
                    onPressed: isCreating
                        ? null
                        : () async {
                            await createTask(setSheetState);
                          },
                    child: Text(isCreating ? '作成中...' : '作成する'),
                  ),
                ],
              ),
            ),
          );
        },
      );
    },
  );
}

これで、右下の + からタスク作成画面を表示できます。


Step 4:保存処理 createTask() を作る

次に、Firestoreにタスクを保存する処理を作ります。

_TaskListPageState の中に、次の関数を追加します。

Future<void> createTask(StateSetter setSheetState) async {
  final user = FirebaseAuth.instance.currentUser;

  if (user == null) {
    setSheetState(() {
      errorText = 'ログイン状態を確認できません。';
    });
    return;
  }

  final title = titleController.text.trim();
  final description = descriptionController.text.trim();
  final assigneeEmail = assigneeEmailController.text.trim();

  if (title.isEmpty) {
    setSheetState(() {
      errorText = 'タスク名を入力してください。';
    });
    return;
  }

  setSheetState(() {
    isCreating = true;
    errorText = null;
  });

  try {
    await FirebaseFirestore.instance
        .collection('teams')
        .doc(widget.teamId)
        .collection('tasks')
        .add({
      'title': title,
      'description': description,
      'assigneeEmail': assigneeEmail,
      'assigneeId': '',
      'priority': selectedPriority,
      'status': selectedStatus,
      'createdBy': user.uid,
      'createdAt': FieldValue.serverTimestamp(),
      'updatedAt': FieldValue.serverTimestamp(),
    });

    if (mounted) {
      Navigator.of(context).pop();
    }
  } on FirebaseException catch (e) {
    setSheetState(() {
      errorText = e.message ?? 'タスク作成に失敗しました。';
    });
  } catch (_) {
    setSheetState(() {
      errorText = 'タスク作成に失敗しました。';
    });
  } finally {
    if (mounted) {
      setSheetState(() {
        isCreating = false;
      });
    }
  }
}

これで、タスクをFirestoreに保存できます。


Step 5:Firestoreに保存する場所を見る

今回の中心はここです。

await FirebaseFirestore.instance
    .collection('teams')
    .doc(widget.teamId)
    .collection('tasks')
    .add({
  ...
});

意味はこうです。

teamsを見る
↓
選んだチームを見る
↓
その中のtasksに保存する

保存場所はここです。

teams/{teamId}/tasks/{taskId}

add() を使うので、taskId はFirestoreが自動で作ります。


Step 6:保存する項目を見る

この部分で、タスクの中身を保存しています。

{
  'title': title,
  'description': description,
  'assigneeEmail': assigneeEmail,
  'assigneeId': '',
  'priority': selectedPriority,
  'status': selectedStatus,
  'createdBy': user.uid,
  'createdAt': FieldValue.serverTimestamp(),
  'updatedAt': FieldValue.serverTimestamp(),
}

それぞれの意味はこちらです。

項目内容
titleタスク名
description説明
assigneeEmail担当者メール
assigneeId担当者uid。今回は空
priority優先度
statusステータス
createdBy作成者uid
createdAt作成日時
updatedAt更新日時

Step 7:なぜ assigneeId は空なのか

今回は、担当者メールを入力するだけにします。

'assigneeEmail': assigneeEmail,
'assigneeId': '',

assigneeId は、担当者のユーザーIDです。

ただし、今の段階では「メールからuidを探す処理」をまだ作っていません。

そのため、いったん空文字で保存します。

担当者メールは保存する
uidはあとで対応する

Step 8:ステータスの初期値

最初は、selectedStatustodo にしています。

String selectedStatus = 'todo';

todo は未対応です。

つまり、新しく作るタスクは、基本的に「未対応」から始まります。

ボトムシートでは、必要に応じて doingdone も選べるようにしています。


Step 9:優先度の初期値

最初は、selectedPrioritynormal にしています。

String selectedPriority = 'normal';

normal は優先度「中」です。

迷ったときは、通常の優先度として保存します。


Step 10:タスク名は必須にする

タスク名が空だと、何のタスクか分かりません。

そのため、タスク名だけは必須にします。

if (title.isEmpty) {
  setSheetState(() {
    errorText = 'タスク名を入力してください。';
  });
  return;
}

説明や担当者メールは、空でもOKにしています。


Step 11:保存する

main.dart を保存します。

Macの場合:

command + S

Windowsの場合:

Ctrl + S

Step 12:実行する

ターミナルで実行します。

flutter run

すでに起動している場合は、ターミナルで r を押します。

r

うまく反映されない場合は、R を押します。

R

Step 13:タスクを作成してみる

アプリを開きます。

ログイン
↓
トーク一覧
↓
チームをタップ
↓
タスク一覧画面
↓
右下の+

次のように入力してみます。

タスク名:
ログイン画面を作る

説明:
メールアドレスとパスワードの入力欄を作る

担当者メール:
test@example.com

優先度:
中

ステータス:
未対応

「作成する」を押します。

ボトムシートが閉じて、タスク一覧に表示されれば成功です。


Step 14:Firestoreで確認する

Firebase Consoleを開きます。

https://console.firebase.google.com/

次の場所を確認します。

Firestore Database
↓
teams
↓
作成したteamId
↓
tasks

新しいタスクができていればOKです。

中身は、次のようになっています。

title
description
assigneeEmail
assigneeId
priority
status
createdBy
createdAt
updatedAt

よくあるエラーと直し方

エラー原因直し方
showCreateTaskSheet isn't defined関数がない_TaskListPageState の中に追加
createTask isn't defined保存処理がないcreateTask() を追加
titleController isn't definedControllerがないControllerを追加
widget.teamId が使えないStatefulWidgetになっていないTaskListPage をStatefulWidgetにする
FirebaseAuth isn't definedimportがないfirebase_auth.dart をimport
FieldValue isn't definedimportがないcloud_firestore.dart をimport
permission-deniedFirestore Rulesで拒否開発用Rulesを確認
タスクが表示されない保存先のteamIdが違うwidget.teamId を確認

permission-denied** が出たとき**

開発中だけ、Firestore Rulesを次のようにして確認できます。

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if true;
    }
  }
}

これは学習用です。

本番公開では使わないでください。

本番では、チームメンバーだけがタスクを作れるようにします。


最短作業まとめ

読むのが大変な人は、ここだけ見てください。

1. 入力用Controllerを用意

final titleController = TextEditingController();
final descriptionController = TextEditingController();
final assigneeEmailController = TextEditingController();

String selectedPriority = 'normal';
String selectedStatus = 'todo';

2. 右下の+につなげる

floatingActionButton: FloatingActionButton(
  onPressed: showCreateTaskSheet,
  child: const Icon(Icons.add),
),

3. Firestoreに保存する

await FirebaseFirestore.instance
    .collection('teams')
    .doc(widget.teamId)
    .collection('tasks')
    .add({
  'title': title,
  'description': description,
  'assigneeEmail': assigneeEmail,
  'assigneeId': '',
  'priority': selectedPriority,
  'status': selectedStatus,
  'createdBy': user.uid,
  'createdAt': FieldValue.serverTimestamp(),
  'updatedAt': FieldValue.serverTimestamp(),
});

4. 保存して実行

flutter run

チェックリスト

□ main.dartを開いた
□ TaskListPageをStatefulWidgetにした
□ titleControllerを作った
□ descriptionControllerを作った
□ assigneeEmailControllerを作った
□ selectedPriorityを作った
□ selectedStatusを作った
□ showCreateTaskSheet()を作った
□ createTask()を作った
□ タスク名の空欄チェックをした
□ teams/{teamId}/tasks に保存した
□ createdByにuser.uidを保存した
□ createdAtを保存した
□ updatedAtを保存した
□ 右下の+からボトムシートを開いた
□ タスクを作成した
□ Firestoreで確認した

ミニ確認問題

Q1. タスクはFirestoreのどこに保存しますか?

回答

次の場所に保存します。

teams/{teamId}/tasks/{taskId}

Q2. 新しく作るタスクの初期ステータスは何ですか?

回答

todo です。

画面では「未対応」と表示します。


Q3. 新しく作るタスクの初期優先度は何ですか?

回答

normal です。

画面では「中」と表示します。


Q4. createdBy には何を保存しますか?

回答

ログイン中ユーザーの uid を保存します。


Q5. このページでnpmや環境変数は必要ですか?

回答

必要ありません。

このページでは、FlutterとFirestoreの既存設定だけで進めます。


このページのまとめ

  • タスクは teams/{teamId}/tasks/{taskId} に保存する。
  • 右下の + からタスク作成ボトムシートを開く。
  • 保存する項目は、タイトル・説明・担当者メール・優先度・ステータス。
  • createdBy にはログイン中ユーザーの uid を入れる。
  • createdAtupdatedAt には FieldValue.serverTimestamp() を入れる。
  • タスク名だけは必須にする。
  • assigneeId は、今回は空文字で保存する。
  • このページではnpmや環境変数は不要。

次のページでやること

次のページでは、タスクをタップしてステータスを変更します。

todo → doing → done の順に切り替えられるようにします。

教材トップへ戻る