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

【トーク一覧】where arrayContainsで参加中チームだけを表示する

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

このページでやること

このページでは、ログイン中のユーザーが参加しているチームだけを一覧表示します。

前のページで、チーム作成時に memberIds を保存しました。

memberIds: [ログイン中ユーザーのuid]

今回は、この memberIds を使って、Firestoreから自分のチームだけを取得します。

使う検索条件はこれです。

.where('memberIds', arrayContains: uid)

今日のゴール

ログイン後の画面に、自分が参加しているチームだけを表示します。

ログイン中ユーザーのuidを取得
↓
teamsを検索
↓
memberIdsに自分のuidが入っているチームだけ取得
↓
トーク一覧として表示

LINEのトーク一覧のように、チーム名がカードで並ぶイメージです。


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

単語一言説明
whereFirestoreで条件を指定して探す命令
arrayContains配列の中に指定した値があるか調べる条件
StreamBuilderデータの変化に合わせて画面を更新するWidget
QuerySnapshotFirestore検索結果のまとまり
docs取得したドキュメント一覧
uidユーザーごとのID
memberIdsチーム参加者のuid一覧

配列とは、複数の値をまとめたリストです。


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

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

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

必要なのは、すでに使っている次の2つです。

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

このページでは、Firestoreからチーム一覧を取得するコードを追加します。


Step 1:main.dartを開く

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

lib/main.dart

VS Codeを使っている場合は、ターミナルで次を実行してもOKです。

code lib/main.dart

Step 2:importを確認する

main.dart の上に、次の2つがあるか確認します。

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

cloud_firestore は、Firestoreからチームを取得するために使います。

firebase_auth は、ログイン中ユーザーの uid を取得するために使います。


Step 3:TeamListPageを探す

main.dart の中で、次を検索します。

class TeamListPage

VS Codeでは、次で検索できます。

command + F

この画面に、チーム一覧を表示していきます。


Step 4:今のbodyを確認する

前のページまでの TeamListPage では、body が仮の表示になっているかもしれません。

body: const Center(
  child: Text('ここにチーム一覧を表示します'),
),

今回は、この body をFirestoreから取得した一覧表示に変更します。


Step 5:ログイン中ユーザーを取得する

build() の中で、ログイン中ユーザーを取得します。

final user = FirebaseAuth.instance.currentUser;

currentUser は、今ログインしているユーザーです。

もし user がない場合は、ログイン状態を確認できていないので、エラー表示にします。

if (user == null) {
  return const Scaffold(
    body: Center(
      child: Text('ログイン状態を確認できません。'),
    ),
  );
}

Step 6:teamsをwhereで検索する

今回の中心コードはこれです。

FirebaseFirestore.instance
    .collection('teams')
    .where('memberIds', arrayContains: user.uid)
    .snapshots()

意味はこうです。

teamsコレクションを見る
↓
memberIdsに自分のuidが入っているものだけ探す
↓
変更があれば自動で画面に反映する

snapshots() は、Firestoreの変更をリアルタイムで受け取る命令です。

リアルタイムとは、データが変わったら画面も自動で変わることです。


Step 7:bodyをStreamBuilderに差し替える

TeamListPagebody を、次のコードに差し替えます。

body: StreamBuilder<QuerySnapshot<Map<String, dynamic>>>(
  stream: FirebaseFirestore.instance
      .collection('teams')
      .where('memberIds', arrayContains: user.uid)
      .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 teams = snapshot.data?.docs ?? [];

    if (teams.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: teams.length,
      separatorBuilder: (_, __) => const SizedBox(height: 10),
      itemBuilder: (context, index) {
        final doc = teams[index];
        final data = doc.data();

        final name = (data['name'] ?? '無題のチーム').toString();

        return AppCard(
          onTap: () {
            // 次のページでタスク一覧画面へ移動します
          },
          child: Row(
            children: [
              CircleAvatar(
                backgroundColor: AppColors.lineGreen,
                child: Text(
                  name.isNotEmpty ? name.characters.first : '?',
                  style: const TextStyle(
                    color: Colors.white,
                    fontWeight: FontWeight.w900,
                  ),
                ),
              ),
              const SizedBox(width: 12),
              Expanded(
                child: Text(
                  name,
                  style: const TextStyle(
                    color: AppColors.text,
                    fontSize: 16,
                    fontWeight: FontWeight.w900,
                  ),
                ),
              ),
              const Icon(
                Icons.chevron_right,
                color: AppColors.subText,
              ),
            ],
          ),
        );
      },
    );
  },
),

これで、自分が参加しているチームだけが表示されます。


Step 8:完成形のTeamListPage

TeamListPage は、次の形になっていればOKです。

class TeamListPage extends StatefulWidget {
  const TeamListPage({super.key});

  @override
  State<TeamListPage> createState() => _TeamListPageState();
}

class _TeamListPageState extends State<TeamListPage> {
  final teamNameController = TextEditingController();

  bool isCreating = false;
  String? errorText;

  @override
  void dispose() {
    teamNameController.dispose();
    super.dispose();
  }

  Future<void> logout() async {
    await FirebaseAuth.instance.signOut();
  }

  Future<void> showCreateTeamSheet() async {
    teamNameController.clear();
    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: 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: teamNameController,
                    label: 'チーム名',
                  ),
                  if (errorText != null) ...[
                    const SizedBox(height: 12),
                    ErrorBox(message: errorText!),
                  ],
                  const SizedBox(height: 16),
                  FilledButton(
                    onPressed: isCreating
                        ? null
                        : () async {
                            await createTeam(setSheetState);
                          },
                    child: Text(isCreating ? '作成中...' : '作成する'),
                  ),
                ],
              ),
            );
          },
        );
      },
    );
  }

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

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

    final teamName = teamNameController.text.trim();

    if (teamName.isEmpty) {
      setSheetState(() {
        errorText = 'チーム名を入力してください。';
      });
      return;
    }

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

    try {
      final userDoc = await FirebaseFirestore.instance
          .collection('users')
          .doc(user.uid)
          .get();

      final userData = userDoc.data();

      final displayName =
          (userData?['displayName'] ?? user.email ?? '名無し').toString();

      final email = (userData?['email'] ?? user.email ?? '').toString();

      final teamRef = await FirebaseFirestore.instance.collection('teams').add({
        'name': teamName,
        'ownerId': user.uid,
        'memberIds': [user.uid],
        'createdAt': FieldValue.serverTimestamp(),
        'updatedAt': FieldValue.serverTimestamp(),
      });

      await teamRef.collection('members').doc(user.uid).set({
        'uid': user.uid,
        'email': email,
        'displayName': displayName,
        'role': 'owner',
        'joinedAt': 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;
        });
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    final user = FirebaseAuth.instance.currentUser;

    if (user == null) {
      return const Scaffold(
        body: Center(
          child: Text('ログイン状態を確認できません。'),
        ),
      );
    }

    return Scaffold(
      backgroundColor: AppColors.bg,
      appBar: AppBar(
        title: const Text('トーク'),
        actions: [
          IconButton(
            tooltip: 'ログアウト',
            onPressed: logout,
            icon: const Icon(Icons.logout),
          ),
        ],
      ),
      body: StreamBuilder<QuerySnapshot<Map<String, dynamic>>>(
        stream: FirebaseFirestore.instance
            .collection('teams')
            .where('memberIds', arrayContains: user.uid)
            .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 teams = snapshot.data?.docs ?? [];

          if (teams.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: teams.length,
            separatorBuilder: (_, __) => const SizedBox(height: 10),
            itemBuilder: (context, index) {
              final doc = teams[index];
              final data = doc.data();

              final name = (data['name'] ?? '無題のチーム').toString();

              return AppCard(
                onTap: () {
                  // 次のページでタスク一覧画面へ移動します
                },
                child: Row(
                  children: [
                    CircleAvatar(
                      backgroundColor: AppColors.lineGreen,
                      child: Text(
                        name.isNotEmpty ? name.characters.first : '?',
                        style: const TextStyle(
                          color: Colors.white,
                          fontWeight: FontWeight.w900,
                        ),
                      ),
                    ),
                    const SizedBox(width: 12),
                    Expanded(
                      child: Text(
                        name,
                        style: const TextStyle(
                          color: AppColors.text,
                          fontSize: 16,
                          fontWeight: FontWeight.w900,
                        ),
                      ),
                    ),
                    const Icon(
                      Icons.chevron_right,
                      color: AppColors.subText,
                    ),
                  ],
                ),
              );
            },
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: showCreateTeamSheet,
        child: const Icon(Icons.add),
      ),
    );
  }
}

Step 9:保存する

main.dart を保存します。

Macの場合:

command + S

Windowsの場合:

Ctrl + S

Step 10:実行する

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

flutter run

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

r

うまく反映されない場合は、R でホットリスタートします。

R

Step 11:画面を確認する

ログイン後、トーク一覧画面を見ます。

まだチームがない場合は、次のように表示されます。

まだチームがありません。
右下の+から作成できます。

右下の + ボタンでチームを作ると、一覧に表示されます。

開発チーム
営業チーム

表示されれば成功です。


Step 12:Firestoreで確認する

Firebase Consoleを開きます。

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

次の順番で確認します。

Firestore Database
↓
データ
↓
teams
↓
作成したteamId

その中に、次があるか見ます。

memberIds

memberIds の中に自分の uid が入っているチームだけ、アプリの一覧に表示されます。


where arrayContains の意味

今回の一番大事な部分です。

.where('memberIds', arrayContains: user.uid)

これは、こういう意味です。

memberIdsの中に
ログイン中ユーザーのuidが含まれているチームだけ取得する

たとえば、Firestoreに次の3チームがあるとします。

チームA
memberIds: [abc123, xyz789]

チームB
memberIds: [zzz999]

チームC
memberIds: [abc123]

ログイン中ユーザーが abc123 の場合、表示されるのは次の2つです。

チームA
チームC

チームBには abc123 が入っていないので表示されません。


orderByを入れない理由

この教材では、最初は orderBy を入れません。

.where('memberIds', arrayContains: user.uid)

だけにします。

理由は、whereorderBy を組み合わせると、Firestoreでインデックス作成が必要になる場合があるからです。

インデックスとは、Firestoreがデータを速く探すための設定です。

最初の学習では、エラーを減らすために、まずは where だけで取得します。

あとで必要になったら、createdAt などで並び替えます。


よくあるエラーと直し方

エラー原因直し方
チームが表示されないmemberIds に自分のuidがないFirestoreで memberIds を確認
arrayContains でエラー書き方が違う.where('memberIds', arrayContains: user.uid) にする
FirebaseFirestore isn't definedimportがないcloud_firestore.dart をimportする
FirebaseAuth isn't definedimportがないfirebase_auth.dart をimportする
QuerySnapshot が見つからないimport不足cloud_firestore.dart を確認
characters が見つからないFlutterの文字処理が読めていない下の修正版を使う
permission-deniedFirestore Rulesで拒否開発用Rulesを確認

characters** でエラーが出る場合**

この部分でエラーが出る場合があります。

name.isNotEmpty ? name.characters.first : '?'

その場合は、次のように簡単な形に変えてください。

name.isNotEmpty ? name.substring(0, 1) : '?'

ただし、日本語では characters.first の方が安全です。

substring(0, 1) は、日本語や絵文字の扱いで問題が出る場合があります。


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

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

rules_version = '2';

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

これは学習用です。

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

本番では、チームメンバーだけがチーム情報を読めるようにします。


チームが表示されないとき

まずFirestoreを確認します。

teams
↓
作成したteamId
↓
memberIds

memberIds が次の形になっているか見ます。

memberIds: ["自分のuid"]

これはNGです。

memberIds: "自分のuid"

文字列ではなく、配列になっている必要があります。

コードでは、必ずこう書きます。

'memberIds': [user.uid],

最短作業まとめ

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

1. ログイン中ユーザーを取得

final user = FirebaseAuth.instance.currentUser;

2. チーム一覧を取得

FirebaseFirestore.instance
    .collection('teams')
    .where('memberIds', arrayContains: user.uid)
    .snapshots()

3. StreamBuilderで表示

StreamBuilder<QuerySnapshot<Map<String, dynamic>>>(
  stream: FirebaseFirestore.instance
      .collection('teams')
      .where('memberIds', arrayContains: user.uid)
      .snapshots(),
  builder: (context, snapshot) {
    final teams = snapshot.data?.docs ?? [];

    return ListView.builder(
      itemCount: teams.length,
      itemBuilder: (context, index) {
        final data = teams[index].data();
        final name = (data['name'] ?? '無題のチーム').toString();

        return ListTile(
          title: Text(name),
        );
      },
    );
  },
)

4. 保存して実行

flutter run

チェックリスト

□ main.dartを開いた
□ TeamListPageを探した
□ FirebaseAuthでcurrentUserを取得した
□ teamsコレクションを指定した
□ whereでmemberIdsを指定した
□ arrayContainsにuser.uidを指定した
□ snapshots()を使った
□ StreamBuilderを使った
□ チームが空のときの表示を書いた
□ チーム一覧をListViewで表示した
□ 保存した
□ flutter runで確認した
□ 作成したチームが一覧に出た

ミニ確認問題

Q1. arrayContains は何をする条件ですか?

回答

配列の中に、指定した値が含まれているか調べる条件です。

今回なら、memberIds の中にログイン中ユーザーの uid があるかを調べます。


Q2. なぜ memberIds を使いますか?

回答

自分が参加しているチームだけを取得するためです。

memberIds に自分の uid が入っているチームだけを表示します。


Q3. なぜ最初は orderBy を入れないのですか?

回答

Firestoreでインデックス作成が必要になる場合があるからです。

初心者の段階ではエラーを減らすため、まずは where だけで取得します。


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

回答

必要ありません。

このページでは、Firestoreの wherearrayContains を使って、参加中チームだけを取得するだけです。


このページのまとめ

  • memberIds は、チーム参加者の uid 一覧。
  • where('memberIds', arrayContains: user.uid) で、自分が参加しているチームだけ取得できる。
  • snapshots() を使うと、Firestoreの変更がリアルタイムに画面へ反映される。
  • StreamBuilder は、Firestoreのデータ変化に合わせて画面を作り直す。
  • チームがないときは、空状態メッセージを表示する。
  • 最初は orderBy を入れず、エラーを減らして進める。
  • このページではnpmや環境変数は不要。

次のページでやること

次のページでは、一覧に表示したチームをタップして、チーム詳細画面へ移動します。

teamIdteamName を渡して、タスク一覧画面を開く流れを作ります。

教材トップへ戻る