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

【タスク編集】既存タスクをタップして内容を更新できるようにする

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

このページでやること

このページでは、すでに作成したタスクをタップして、内容を編集できるようにします。

編集する項目は、次の5つです。

項目内容
titleタスク名
description説明
assigneeEmail担当者メール
priority優先度
statusステータス

保存場所はここです。

teams/{teamId}/tasks/{taskId}

今日のゴール

タスクをタップすると、編集用のボトムシートが開くようにします。

タスク一覧
↓
タスクをタップ
↓
編集ボトムシートを表示
↓
内容を変更
↓
更新する
↓
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:TaskCardにonTapを追加する

今の TaskCard は表示だけになっています。

タップできるように、onTap を受け取れる形にします。

TaskCard の最初を、次のように変更します。

class TaskCard extends StatelessWidget {
  const TaskCard({
    super.key,
    required this.title,
    required this.description,
    required this.status,
    required this.priority,
    required this.assigneeEmail,
    required this.onTap,
  });

  final String title;
  final String description;
  final String status;
  final String priority;
  final String assigneeEmail;
  final VoidCallback onTap;

追加したのは、ここです。

required this.onTap,
final VoidCallback onTap;

VoidCallback は、タップしたときに動かす処理です。


Step 3:TaskCard全体をタップできるようにする

TaskCardbuild() の中で、今はこのような形になっています。

return Align(
  alignment: Alignment.centerLeft,
  child: Container(
    ...
  ),
);

これを、InkWell で包みます。

return Align(
  alignment: Alignment.centerLeft,
  child: Material(
    color: Colors.transparent,
    child: InkWell(
      onTap: onTap,
      borderRadius: BorderRadius.circular(18),
      child: Container(
        width: double.infinity,
        decoration: BoxDecoration(
          color: Colors.white,
          borderRadius: const BorderRadius.only(
            topLeft: Radius.circular(4),
            topRight: Radius.circular(18),
            bottomLeft: Radius.circular(18),
            bottomRight: Radius.circular(18),
          ),
          border: Border.all(color: AppColors.border),
          boxShadow: [
            BoxShadow(
              color: Colors.black.withValues(alpha: 0.04),
              blurRadius: 10,
              offset: const Offset(0, 4),
            ),
          ],
        ),
        padding: const EdgeInsets.all(14),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                _Label(text: statusLabel(status)),
                _Label(text: priorityLabel(priority)),
              ],
            ),
            const SizedBox(height: 10),
            Text(
              title,
              style: const TextStyle(
                color: AppColors.text,
                fontSize: 16,
                fontWeight: FontWeight.w900,
              ),
            ),
            if (description.isNotEmpty) ...[
              const SizedBox(height: 6),
              Text(
                description,
                style: const TextStyle(
                  color: AppColors.subText,
                  height: 1.5,
                  fontWeight: FontWeight.w600,
                ),
              ),
            ],
            if (assigneeEmail.isNotEmpty) ...[
              const SizedBox(height: 12),
              Container(
                decoration: BoxDecoration(
                  color: AppColors.bg,
                  borderRadius: BorderRadius.circular(999),
                ),
                padding: const EdgeInsets.symmetric(
                  horizontal: 10,
                  vertical: 6,
                ),
                child: Text(
                  '担当:$assigneeEmail',
                  style: const TextStyle(
                    color: AppColors.subText,
                    fontSize: 12,
                    fontWeight: FontWeight.w800,
                  ),
                ),
              ),
            ],
          ],
        ),
      ),
    ),
  ),
);

もし withValues でエラーが出る場合は、次に変えてください。

color: Colors.black.withOpacity(0.04),

Step 4:TaskCardを呼び出す場所を修正する

TaskListPage の中で、TaskCard を呼び出している場所を探します。

今はこのようになっています。

return TaskCard(
  title: title,
  description: description,
  status: status,
  priority: priority,
  assigneeEmail: assigneeEmail,
);

これを、次のように変更します。

return TaskCard(
  title: title,
  description: description,
  status: status,
  priority: priority,
  assigneeEmail: assigneeEmail,
  onTap: () {
    showEditTaskSheet(
      taskId: doc.id,
      currentTitle: title,
      currentDescription: description,
      currentAssigneeEmail: assigneeEmail,
      currentPriority: priority,
      currentStatus: status,
    );
  },
);

ここで、タップしたタスクの情報を編集画面に渡します。


Step 5:編集用ボトムシートを作る

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

Future<void> showEditTaskSheet({
  required String taskId,
  required String currentTitle,
  required String currentDescription,
  required String currentAssigneeEmail,
  required String currentPriority,
  required String currentStatus,
}) async {
  titleController.text = currentTitle;
  descriptionController.text = currentDescription;
  assigneeEmailController.text = currentAssigneeEmail;

  selectedPriority = currentPriority;
  selectedStatus = currentStatus;

  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 updateTask(
                              setSheetState: setSheetState,
                              taskId: taskId,
                            );
                          },
                    child: Text(isCreating ? '更新中...' : '更新する'),
                  ),
                ],
              ),
            ),
          );
        },
      );
    },
  );
}

これで、タスク編集用のボトムシートができます。


Step 6:更新処理 updateTask() を作る

次に、Firestoreのタスクを更新する処理を作ります。

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

Future<void> updateTask({
  required StateSetter setSheetState,
  required String taskId,
}) async {
  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')
        .doc(taskId)
        .update({
      'title': title,
      'description': description,
      'assigneeEmail': assigneeEmail,
      'priority': selectedPriority,
      'status': selectedStatus,
      '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;
      });
    }
  }
}

これで、既存タスクを更新できます。


Step 7:更新する場所を確認する

今回の中心はここです。

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

これは、次の場所を更新しています。

teams/{teamId}/tasks/{taskId}

teamId は、今開いているチームのIDです。

taskId は、タップしたタスクのIDです。


Step 8:add() と update() の違い

タスク作成では add() を使いました。

.collection('tasks').add({...})

タスク編集では update() を使います。

.collection('tasks').doc(taskId).update({...})

違いはこうです。

命令役割
add()新しいデータを作る
update()すでにあるデータを更新する

今回は既存タスクの編集なので、update() を使います。


Step 9:updatedAtを更新する

編集時には、必ず updatedAt も更新します。

'updatedAt': FieldValue.serverTimestamp(),

updatedAt は、最後に編集した日時です。

これを入れておくと、あとで「最近更新されたタスク順」に並べることができます。


Step 10:titleは必須にする

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

編集時も、タスク名は必須にします。

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:タスクを編集してみる

アプリを開きます。

ログイン
↓
トーク一覧
↓
チームをタップ
↓
タスク一覧画面
↓
編集したいタスクをタップ

編集ボトムシートが開きます。

次のように変更してみます。

タスク名:
ログイン画面を作る
↓
ログイン画面を完成させる

ステータス:
未対応
↓
進行中

優先度:
中
↓
高

「更新する」を押します。

タスク一覧の表示が変われば成功です。


Step 14:Firestoreで確認する

Firebase Consoleを開きます。

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

次の場所を確認します。

Firestore Database
↓
teams
↓
作成したteamId
↓
tasks
↓
編集したtaskId

次の項目が更新されていればOKです。

title
description
assigneeEmail
priority
status
updatedAt

よくあるエラーと直し方

エラー原因直し方
onTap がないTaskCardにonTapを追加していないfinal VoidCallback onTap; を追加
showEditTaskSheet isn't defined編集用関数がない_TaskListPageState に追加
updateTask isn't defined更新処理がないupdateTask() を追加
taskId が取れないdoc.id を渡していないtaskId: doc.id にする
widget.teamId が使えないTaskListPageがStatefulWidgetでないStatefulWidgetにする
update() で失敗するtaskIdが間違っているFirestoreのtaskIdを確認
permission-deniedFirestore Rulesで拒否開発用Rulesを確認

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

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

rules_version = '2';

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

これは学習用です。

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

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


最短作業まとめ

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

1. TaskCardにonTapを追加

final VoidCallback onTap;

2. TaskCardをタップできるようにする

InkWell(
  onTap: onTap,
  child: Container(...),
)

3. TaskCard呼び出しで編集画面を開く

return TaskCard(
  title: title,
  description: description,
  status: status,
  priority: priority,
  assigneeEmail: assigneeEmail,
  onTap: () {
    showEditTaskSheet(
      taskId: doc.id,
      currentTitle: title,
      currentDescription: description,
      currentAssigneeEmail: assigneeEmail,
      currentPriority: priority,
      currentStatus: status,
    );
  },
);

4. Firestoreを更新する

await FirebaseFirestore.instance
    .collection('teams')
    .doc(widget.teamId)
    .collection('tasks')
    .doc(taskId)
    .update({
  'title': title,
  'description': description,
  'assigneeEmail': assigneeEmail,
  'priority': selectedPriority,
  'status': selectedStatus,
  'updatedAt': FieldValue.serverTimestamp(),
});

5. 保存して実行

flutter run

チェックリスト

□ main.dartを開いた
□ TaskCardにonTapを追加した
□ TaskCardをInkWellで包んだ
□ TaskCard呼び出しにonTapを追加した
□ doc.idをtaskIdとして渡した
□ showEditTaskSheet()を作った
□ 現在の値をControllerに入れた
□ updateTask()を作った
□ teams/{teamId}/tasks/{taskId} を更新した
□ titleを更新した
□ descriptionを更新した
□ assigneeEmailを更新した
□ priorityを更新した
□ statusを更新した
□ updatedAtを更新した
□ 保存した
□ flutter runで確認した
□ タスクをタップして編集できた

ミニ確認問題

Q1. 既存タスクの更新には add()update() のどちらを使いますか?

回答

update() を使います。

add() は新規作成、update() は既存データの更新です。


Q2. 編集するタスクを特定するために必要なIDは何ですか?

回答

taskId です。

Firestoreの doc.id を使います。


Q3. 編集時に必ず更新する日時項目は何ですか?

回答

updatedAt です。

最後に編集した日時を保存します。


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

回答

必要ありません。

このページでは、既存のFirestoreデータを update() で更新するだけです。


このページのまとめ

  • タスクをタップして編集ボトムシートを開く。
  • TaskCardonTap を追加する。
  • 編集前の値をControllerに入れて表示する。
  • 既存タスクの更新には update() を使う。
  • 更新場所は teams/{teamId}/tasks/{taskId}
  • titledescriptionassigneeEmailprioritystatus を更新する。
  • 編集時は updatedAt も更新する。
  • このページではnpmや環境変数は不要。

次のページでやること

次のページでは、タスクを左スワイプして削除できるようにします。

Dismissible を使って、Firestoreから tasks/{taskId} を削除します。

教材トップへ戻る