【タスク作成】タイトル・説明・担当者・優先度・ステータスを保存する
このページでやること
このページでは、タスクを新しく作成して、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にする
タスク作成では、入力欄を使います。
そのため、TaskListPage を StatefulWidget にします。
今の 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:ステータスの初期値
最初は、selectedStatus を todo にしています。
String selectedStatus = 'todo';
todo は未対応です。
つまり、新しく作るタスクは、基本的に「未対応」から始まります。
ボトムシートでは、必要に応じて doing や done も選べるようにしています。
Step 9:優先度の初期値
最初は、selectedPriority を normal にしています。
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 defined | Controllerがない | Controllerを追加 |
widget.teamId が使えない | StatefulWidgetになっていない | TaskListPage をStatefulWidgetにする |
FirebaseAuth isn't defined | importがない | firebase_auth.dart をimport |
FieldValue isn't defined | importがない | cloud_firestore.dart をimport |
permission-denied | Firestore 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を入れる。createdAtとupdatedAtにはFieldValue.serverTimestamp()を入れる。- タスク名だけは必須にする。
assigneeIdは、今回は空文字で保存する。- このページではnpmや環境変数は不要。
次のページでやること
次のページでは、タスクをタップしてステータスを変更します。
todo → doing → done の順に切り替えられるようにします。
