【タスク一覧】チーム内のタスクをFirestoreから取得する
このページでやること
このページでは、選んだチームの中にあるタスクをFirestoreから取得して、画面に表示します。
前のページまでで、チーム一覧は表示できるようになりました。
今回は、チームをタップしたあとに開く TaskListPage を作ります。
タスクの保存場所はここです。
teams/{teamId}/tasks/{taskId}
短く言うと、こうです。
チームを選ぶ
↓
そのチームのtasksを見る
↓
タスク一覧として表示する
今日のゴール
チームカードをタップすると、そのチーム専用のタスク一覧画面を開けるようにします。
トーク一覧
↓
開発チームをタップ
↓
開発チームのタスク一覧を表示
まだタスクがない場合は、次のように表示します。
まだタスクがありません。
右下の+から作成できます。
このページでは、タスク作成はまだ行いません。
まずは、Firestoreから取得して表示するところまで進めます。
このページで出てくる単語
| 単語 | 一言説明 |
|---|---|
TaskListPage | チーム内のタスク一覧画面 |
teamId | チームごとのID |
teamName | チーム名 |
tasks | チーム内のタスク一覧 |
StreamBuilder | Firestoreの変更に合わせて画面を更新するWidget |
QuerySnapshot | Firestoreから取得した一覧データ |
doc.data() | ドキュメントの中身を取り出す処理 |
snapshots() | Firestoreの変更をリアルタイムで受け取る処理 |
リアルタイムとは、Firestoreのデータが変わったら、画面も自動で変わることです。
npmや環境変数はこのページで必要?
このページでは、npmは使いません。
環境変数も設定しません。
必要なのは、すでに使っているFirestoreです。
import 'package:cloud_firestore/cloud_firestore.dart';
このページでやることは、TaskListPage を作って、teams/{teamId}/tasks を読み込むことです。
Step 1:main.dartを開く
次のファイルを開きます。
lib/main.dart
ターミナルから開く場合は、次を実行します。
code lib/main.dart
code が使えない場合は、VS Codeの左側から lib/main.dart を開いてください。
Step 2:TeamCardのonTapを探す
TeamListPage の中にある TeamCard を探します。
前のページでは、次のようになっていました。
return TeamCard(
name: name,
onTap: () {
// 次のページでタスク一覧画面へ移動します
},
);
今回は、この onTap に画面移動を追加します。
Step 3:teamIdを取り出す
ListView の中で、Firestoreのドキュメントを取り出している部分を確認します。
final doc = teams[index];
final data = doc.data();
final name = (data['name'] ?? '無題のチーム').toString();
ここで大事なのは、doc.id です。
final teamId = doc.id;
doc.id は、そのチームのFirestoreドキュメントIDです。
つまり、保存場所でいう {teamId} です。
teams/{teamId}
Step 4:TeamCardのonTapを変更する
TeamCard の onTap を、次のように変更します。
return TeamCard(
name: name,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => TaskListPage(
teamId: doc.id,
teamName: name,
),
),
);
},
);
Navigator は、画面を移動するための仕組みです。
push は、新しい画面を開く命令です。
ここでは、TaskListPage に次の2つを渡しています。
teamId
teamName
teamId はFirestoreからタスクを取得するために使います。
teamName は画面タイトルに使います。
Step 5:TaskListPageを作る
TeamListPage の下あたりに、次のコードを追加します。
class TaskListPage extends StatelessWidget {
const TaskListPage({
super.key,
required this.teamId,
required this.teamName,
});
final String teamId;
final String teamName;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.chatBg,
appBar: AppBar(
title: Text(teamName),
),
body: StreamBuilder<QuerySnapshot<Map<String, dynamic>>>(
stream: FirebaseFirestore.instance
.collection('teams')
.doc(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: () {
// 次のページでタスク作成画面を開きます
},
child: const Icon(Icons.add),
),
);
}
}
これで、チーム内のタスク一覧画面ができます。
Step 6:Firestoreからtasksを取得する部分を見る
今回の中心はここです。
FirebaseFirestore.instance
.collection('teams')
.doc(teamId)
.collection('tasks')
.snapshots()
意味はこうです。
teams を見る
↓
選んだ teamId のチームを見る
↓
その中の tasks を見る
↓
変更があれば自動で受け取る
つまり、次の場所からタスクを取っています。
teams/{teamId}/tasks
Step 7:なぜteamIdが必要なのか
タスクはチームごとに保存しています。
そのため、どのチームのタスクを見るのかを指定する必要があります。
開発チームのteamId
↓
開発チームのtasksを取得
営業チームのteamId
↓
営業チームのtasksを取得
teamId がないと、どのチームのタスクを表示すればよいか分かりません。
Step 8:TaskCardを作る
次に、タスク1件分を表示する TaskCard を作ります。
TeamCard の近くに、次のコードを追加します。
class TaskCard extends StatelessWidget {
const TaskCard({
super.key,
required this.title,
required this.description,
required this.status,
required this.priority,
required this.assigneeEmail,
});
final String title;
final String description;
final String status;
final String priority;
final String assigneeEmail;
@override
Widget build(BuildContext context) {
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_Label(text: statusLabel(status)),
const SizedBox(width: 8),
_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,
fontWeight: FontWeight.w600,
),
),
],
if (assigneeEmail.isNotEmpty) ...[
const SizedBox(height: 10),
Text(
'担当:$assigneeEmail',
style: const TextStyle(
color: AppColors.subText,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
],
],
),
);
}
String statusLabel(String value) {
switch (value) {
case 'doing':
return '進行中';
case 'done':
return '完了';
case 'todo':
default:
return '未対応';
}
}
String priorityLabel(String value) {
switch (value) {
case 'high':
return '優先度:高';
case 'low':
return '優先度:低';
case 'normal':
default:
return '優先度:中';
}
}
}
TaskCard は、タスク1件分の見た目を作るWidgetです。
Step 9:ラベル用の小さい部品を作る
TaskCard の中で _Label を使っているので、次のコードも追加します。
class _Label extends StatelessWidget {
const _Label({
required this.text,
});
final String text;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.bg,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: AppColors.border),
),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
child: Text(
text,
style: const TextStyle(
color: AppColors.subText,
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
);
}
}
_Label は、ステータスや優先度を小さく表示する部品です。
名前の最初に _ がつくと、そのファイルの中だけで使う部品という意味になります。
Step 10:保存する
main.dart を保存します。
Macの場合:
command + S
Windowsの場合:
Ctrl + S
Step 11:実行する
ターミナルで実行します。
flutter run
すでに起動している場合は、ターミナルで r を押します。
r
うまく反映されない場合は、R でホットリスタートします。
R
Step 12:画面を確認する
ログイン後、トーク一覧画面を開きます。
チームをタップします。
トーク一覧
↓
開発チームをタップ
↓
開発チームのタスク一覧画面
まだタスクを作っていない場合は、次の表示でOKです。
まだタスクがありません。
右下の+から作成できます。
この表示が出れば、TaskListPage は開けています。
Step 13:Firestoreで場所を確認する
Firebase Consoleを開きます。
https://console.firebase.google.com/
次の順番で確認します。
Firestore Database
↓
データ
↓
teams
↓
作成したteamId
↓
tasks
まだタスクを作っていなければ、tasks が表示されていない場合があります。
それで大丈夫です。
次のページでタスク作成機能を作ると、ここに表示されます。
Step 14:テスト用タスクを手動で作って確認する
画面表示を先に確認したい場合は、Firebase Consoleで手動でタスクを作れます。
Firestoreで、次の場所を開きます。
teams
↓
作成したteamId
その中に、サブコレクションを作ります。
tasks
ドキュメントIDは自動IDでOKです。
項目は次のように入れます。
title: テストタスク
description: 画面表示の確認
status: todo
priority: normal
assigneeEmail: test@example.com
assigneeId:
createdBy: 自分のuid
作成後、アプリに戻ると、タスクが表示されます。
よくあるエラーと直し方
| エラー | 原因 | 直し方 |
|---|---|---|
TaskListPage isn't defined | TaskListPageを作っていない | class TaskListPage を追加する |
TaskCard isn't defined | TaskCardを作っていない | class TaskCard を追加する |
_Label isn't defined | _Labelを作っていない | _Label を追加する |
FirebaseFirestore isn't defined | importがない | cloud_firestore.dart をimportする |
| タップしても移動しない | TeamCardのonTapが空 | Navigator.of(context).push(...) を書く |
| タスクが表示されない | tasksがまだ存在しない | 次ページで作成、または手動でテスト作成 |
permission-denied | Firestore Rulesで拒否 | 開発用Rulesを確認する |
permission-denied** が出たとき**
開発中だけ、Firestore Rulesを次のようにして確認できます。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if true;
}
}
}
これは学習用です。
本番公開では使わないでください。
本番では、チームメンバーだけが、そのチームのタスクを読めるようにします。
タップしても画面が変わらないとき
TeamCard の onTap が、次のようになっているか確認します。
return TeamCard(
name: name,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => TaskListPage(
teamId: doc.id,
teamName: name,
),
),
);
},
);
この中の TaskListPage に、teamId と teamName を渡していることが大事です。
orderByを入れない理由
この教材では、最初は orderBy を入れません。
.collection('tasks')
.snapshots()
だけで取得します。
理由は、Firestoreでインデックス作成が必要になる場合があるからです。
初心者の段階では、まず取得できることを優先します。
あとで必要になったら、createdAt で並び替えます。
最短作業まとめ
読むのが大変な人は、ここだけ見てください。
1. TeamCardのonTapを変更
return TeamCard(
name: name,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => TaskListPage(
teamId: doc.id,
teamName: name,
),
),
);
},
);
2. TaskListPageでtasksを取得
stream: FirebaseFirestore.instance
.collection('teams')
.doc(teamId)
.collection('tasks')
.snapshots(),
3. タスク一覧を表示
final tasks = snapshot.data?.docs ?? [];
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: tasks.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final data = tasks[index].data();
final title = (data['title'] ?? '無題のタスク').toString();
return Text(title);
},
);
4. 保存して実行
flutter run
チェックリスト
□ main.dartを開いた
□ TeamCardのonTapを変更した
□ TaskListPageを作った
□ teamIdを受け取るようにした
□ teamNameを受け取るようにした
□ AppBarにteamNameを表示した
□ teams/{teamId}/tasks を指定した
□ StreamBuilderでtasksを取得した
□ タスクが空のときの表示を書いた
□ TaskCardを作った
□ _Labelを作った
□ 保存した
□ flutter runで確認した
□ チームをタップしてTaskListPageを開いた
ミニ確認問題
Q1. タスク一覧はFirestoreのどこから取得しますか?
回答
次の場所から取得します。
teams/{teamId}/tasks
Q2. なぜ teamId が必要ですか?
回答
どのチームのタスクを取得するかを指定するためです。
チームごとにタスクが分かれているので、teamId が必要です。
Q3. snapshots() は何をしますか?
回答
Firestoreのデータ変更をリアルタイムで受け取ります。
タスクが追加・更新されると、画面も自動で更新されます。
Q4. このページでnpmや環境変数は必要ですか?
回答
必要ありません。
このページでは、Firestoreからチーム内のタスクを取得して表示するだけです。
このページのまとめ
- タスクは
teams/{teamId}/tasks/{taskId}に保存する。 - チーム一覧から
teamIdとteamNameを渡してTaskListPageを開く。 TaskListPageでは、teams/{teamId}/tasksを取得する。StreamBuilderとsnapshots()で、Firestoreの変更をリアルタイムに反映する。- タスクがないときは、空状態メッセージを表示する。
TaskCardでタスク1件分を見やすく表示する。- 最初は
orderByを入れず、取得できることを優先する。 - このページではnpmや環境変数は不要。
次のページでやること
次のページでは、タスク作成UIを作ります。
右下の + ボタンからボトムシートを開き、タスク名・説明・優先度・担当者メールを入力できるようにします。
