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

【タスク一覧】チーム内のタスクをFirestoreから取得する

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

このページでやること

このページでは、選んだチームの中にあるタスクをFirestoreから取得して、画面に表示します。

前のページまでで、チーム一覧は表示できるようになりました。

今回は、チームをタップしたあとに開く TaskListPage を作ります。

タスクの保存場所はここです。

teams/{teamId}/tasks/{taskId}

短く言うと、こうです。

チームを選ぶ
↓
そのチームのtasksを見る
↓
タスク一覧として表示する

今日のゴール

チームカードをタップすると、そのチーム専用のタスク一覧画面を開けるようにします。

トーク一覧
↓
開発チームをタップ
↓
開発チームのタスク一覧を表示

まだタスクがない場合は、次のように表示します。

まだタスクがありません。
右下の+から作成できます。

このページでは、タスク作成はまだ行いません。

まずは、Firestoreから取得して表示するところまで進めます。


このページで出てくる単語

単語一言説明
TaskListPageチーム内のタスク一覧画面
teamIdチームごとのID
teamNameチーム名
tasksチーム内のタスク一覧
StreamBuilderFirestoreの変更に合わせて画面を更新するWidget
QuerySnapshotFirestoreから取得した一覧データ
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を変更する

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

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 definedTaskListPageを作っていないclass TaskListPage を追加する
TaskCard isn't definedTaskCardを作っていないclass TaskCard を追加する
_Label isn't defined_Labelを作っていない_Label を追加する
FirebaseFirestore isn't definedimportがないcloud_firestore.dart をimportする
タップしても移動しないTeamCardのonTapが空Navigator.of(context).push(...) を書く
タスクが表示されないtasksがまだ存在しない次ページで作成、または手動でテスト作成
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;
    }
  }
}

これは学習用です。

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

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


タップしても画面が変わらないとき

TeamCardonTap が、次のようになっているか確認します。

return TeamCard(
  name: name,
  onTap: () {
    Navigator.of(context).push(
      MaterialPageRoute(
        builder: (_) => TaskListPage(
          teamId: doc.id,
          teamName: name,
        ),
      ),
    );
  },
);

この中の TaskListPage に、teamIdteamName を渡していることが大事です。


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} に保存する。
  • チーム一覧から teamIdteamName を渡して TaskListPage を開く。
  • TaskListPage では、teams/{teamId}/tasks を取得する。
  • StreamBuildersnapshots() で、Firestoreの変更をリアルタイムに反映する。
  • タスクがないときは、空状態メッセージを表示する。
  • TaskCard でタスク1件分を見やすく表示する。
  • 最初は orderBy を入れず、取得できることを優先する。
  • このページではnpmや環境変数は不要。

次のページでやること

次のページでは、タスク作成UIを作ります。

右下の + ボタンからボトムシートを開き、タスク名・説明・優先度・担当者メールを入力できるようにします。

教材トップへ戻る