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

【チーム作成】teams/{teamId} と members/{uid} を同時に作成する

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

このページでやること

このページでは、ログイン中のユーザーが新しいチームを作れるようにします。

チームを作るときは、Firestoreに次の2つを同時に作ります。

teams/{teamId}
teams/{teamId}/members/{uid}

短く言うと、こうです。

チーム情報を作る
↓
作った人を、そのチームのownerとして登録する

owner とは、チームの所有者です。

チームを作った人は、最初の管理者になります。


今日のゴール

チーム作成ボタンを押すと、次の流れで動くようにします。

チーム名を入力する
↓
作成ボタンを押す
↓
Firestoreにteams/{teamId}を作る
↓
Firestoreにteams/{teamId}/members/{uid}を作る
↓
チーム一覧に表示される

保存する形は、次のようになります。

teams/{teamId}
  name: 開発チーム
  ownerId: ログイン中ユーザーのuid
  memberIds: [ログイン中ユーザーのuid]
  createdAt: 作成日時
  updatedAt: 更新日時

teams/{teamId}/members/{uid}
  uid: ログイン中ユーザーのuid
  email: メールアドレス
  displayName: 表示名
  role: owner
  joinedAt: 参加日時

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

単語一言説明
teamIdチームごとに作られるID
uidユーザーごとに作られるID
ownerチームを作った人。所有者
membersチームに参加している人の情報
memberIdsチーム参加者のuid一覧
add()Firestoreに新しいドキュメントを作る命令
set()指定した場所にデータを保存する命令
FieldValue.serverTimestamp()Firestore側の現在時刻を保存する命令

ドキュメントとは、Firestoreの1件分のデータです。


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

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

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

必要なのは、すでに追加した次の2つです。

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

このページでは、main.dart にチーム作成処理を追加していきます。


Step 1:main.dartを開く

Flutterプロジェクトの中で、次のファイルを開きます。

lib/main.dart

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

code lib/main.dart

code が使えない場合は、VS 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 はログイン中のユーザー情報を取得するために使います。


Step 3:TeamListPageをStatefulWidgetにする

チーム作成では、入力欄やエラー表示を使うので、TeamListPageStatefulWidget にします。

今の TeamListPage が仮の StatelessWidget の場合は、次の形に差し替えます。

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();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: AppColors.bg,
      appBar: AppBar(
        title: const Text('トーク'),
        actions: [
          IconButton(
            tooltip: 'ログアウト',
            onPressed: logout,
            icon: const Icon(Icons.logout),
          ),
        ],
      ),
      body: const Center(
        child: Text('ここにチーム一覧を表示します'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {},
        child: const Icon(Icons.add),
      ),
    );
  }
}

StatefulWidget は、画面の中で変わる値を持てるWidgetです。

今回は、チーム名の入力、作成中かどうか、エラー文が変わるので使います。


Step 4:チーム作成画面を開く処理を作る

右下の + ボタンを押したら、チーム名を入力する画面を出します。

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

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 ? '作成中...' : '作成する'),
                ),
              ],
            ),
          );
        },
      );
    },
  );
}

showModalBottomSheet は、画面下から出てくる入力画面を表示する部品です。

StatefulBuilder は、ボトムシートの中だけを更新するために使います。

ボトムシートとは、画面下から出てくる小さな画面のことです。


Step 5:+ボタンとつなげる

FloatingActionButton を探してください。

今はこうなっているはずです。

floatingActionButton: FloatingActionButton(
  onPressed: () {},
  child: const Icon(Icons.add),
),

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

floatingActionButton: FloatingActionButton(
  onPressed: showCreateTeamSheet,
  child: const Icon(Icons.add),
),

これで、右下の + ボタンを押すと、チーム作成画面が開きます。


Step 6:createTeam()を作る

次に、Firestoreにチームを作る処理を書きます。

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

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 = String.fromCharCodes(
      (userData?['displayName'] ?? user.email ?? '名無し').toString().runes,
    );

    final email = String.fromCharCodes(
      (userData?['email'] ?? user.email ?? '').toString().runes,
    );

    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;
      });
    }
  }
}

これで、チーム作成処理ができます。


Step 7:createTeam()の流れを見る

コード全体を短く見ると、こうです。

ログイン中ユーザーを取得する
↓
チーム名を取得する
↓
空欄チェックをする
↓
users/{uid} から名前とメールを取得する
↓
teams/{teamId} を作る
↓
teams/{teamId}/members/{uid} を作る
↓
ボトムシートを閉じる

この流れだけ覚えればOKです。


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

この部分です。

final user = FirebaseAuth.instance.currentUser;

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

ログインしていれば、uidemail が取れます。

ログインしていない場合は null になります。

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

return は、ここで処理を止めるという意味です。


Step 9:チーム名を確認する

この部分で、入力されたチーム名を取り出します。

final teamName = teamNameController.text.trim();

trim() は、前後の空白を消す処理です。

次に、空欄かどうかを確認します。

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

isEmpty は、文字が空かどうかを確認するものです。


Step 10:users/{uid} からプロフィールを取る

この部分です。

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

final userData = userDoc.data();

ここでは、前のページで保存した users/{uid} のプロフィールを取得しています。

そこから、名前とメールを取り出します。

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

final email = String.fromCharCodes(
  (userData?['email'] ?? user.email ?? '').toString().runes,
);

?? は、左側がなければ右側を使うという意味です。

たとえば、displayName がなければ、メールアドレスを使います。

それもなければ、名無し を使います。


Step 11:teams/{teamId} を作る

この部分が、チーム情報を作る処理です。

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

add() は、Firestoreに新しいドキュメントを作る命令です。

ここでは、teams コレクションの中に、新しいチームを作っています。

Firestoreが自動で teamId を作ります。

保存される形はこうです。

teams/{teamId}
  name: 入力したチーム名
  ownerId: 作成者のuid
  memberIds: [作成者のuid]
  createdAt: 作成日時
  updatedAt: 更新日時

memberIds に自分の uid を入れることで、自分が参加しているチームとして検索できるようになります。


Step 12:members/{uid} を作る

次に、作成者をチームメンバーとして登録します。

await teamRef.collection('members').doc(user.uid).set({
  'uid': user.uid,
  'email': email,
  'displayName': displayName,
  'role': 'owner',
  'joinedAt': FieldValue.serverTimestamp(),
});

teamRef は、さっき作ったチームの場所です。

その中に、members コレクションを作ります。

保存される形はこうです。

teams/{teamId}/members/{uid}
  uid: 作成者のuid
  email: 作成者のメール
  displayName: 作成者の名前
  role: owner
  joinedAt: 参加日時

チームを作った人なので、roleowner にします。


Step 13:なぜ2つ同時に作るのか

チーム作成では、teams/{teamId} だけでは足りません。

理由は、チームの情報とメンバーの情報は役割が違うからです。

保存先役割
teams/{teamId}チーム名、作成者、参加者ID一覧を保存する
teams/{teamId}/members/{uid}チーム内での名前、メール、権限を保存する

つまり、チームを作った瞬間に、

チームそのもの
チームに参加している自分

の両方を保存します。


Step 14:保存する

main.dart を保存します。

Macの場合:

command + S

Windowsの場合:

Ctrl + S

Step 15:実行する

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

flutter run

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

r

動きがおかしい場合は、R でホットリスタートしてください。

R

Step 16:チーム作成を試す

アプリを起動します。

ログイン後、チーム一覧画面に進みます。

右下の + ボタンを押します。

+ ボタン
↓
新しいチームを作成
↓
チーム名を入力
↓
作成する

例として、次のように入力します。

開発チーム

作成できると、ボトムシートが閉じます。


Step 17:Firestoreで確認する

Firebase Consoleを開きます。

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

次の場所を確認します。

Firestore Database
↓
データ
↓
teams

teams の中に新しいドキュメントができていれば成功です。

中身に、次があるか確認します。

name
ownerId
memberIds
createdAt
updatedAt

さらに、そのチームの中に members があるか確認します。

teams
  └ teamId
      └ members
          └ uid

members/{uid} の中に、次があればOKです。

uid
email
displayName
role
joinedAt

よくあるエラーと直し方

エラー原因直し方
FirebaseFirestore isn't definedimportがないcloud_firestore.dart をimportする
FirebaseAuth isn't definedimportがないfirebase_auth.dart をimportする
StateSetter isn't definedFlutter Materialのimportがないmaterial.dart を確認する
permission-deniedFirestore Rulesで拒否されている開発用Rulesを確認する
ボトムシートが開かないonPressed が空のままonPressed: showCreateTeamSheet にする
チーム名エラーが出ないsetSheetState を使っていないボトムシート内では setSheetState を使う
作成後に閉じないNavigator.pop() がない成功後に Navigator.of(context).pop() を確認する

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

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

rules_version = '2';

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

これは開発用です。

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

本番では、ログインユーザーとチームメンバーだけが読み書きできるようにします。


setSheetState** を使う理由**

ボトムシートの中でエラー表示や「作成中…」を変えるには、setSheetState を使います。

通常の setState では、ボトムシート内の表示がすぐ変わらないことがあります。

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

ここは、今は深く理解しなくて大丈夫です。

ボトムシートの中を更新するときは setSheetState と覚えてください。


最短作業まとめ

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

1. importを確認

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

2. +ボタンでボトムシートを開く

floatingActionButton: FloatingActionButton(
  onPressed: showCreateTeamSheet,
  child: const Icon(Icons.add),
),

3. teamsを作る

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

4. membersを作る

await teamRef.collection('members').doc(user.uid).set({
  'uid': user.uid,
  'email': email,
  'displayName': displayName,
  'role': 'owner',
  'joinedAt': FieldValue.serverTimestamp(),
});

5. 保存して実行

flutter run

チェックリスト

□ main.dartを開いた
□ cloud_firestoreをimportした
□ firebase_authをimportした
□ TeamListPageをStatefulWidgetにした
□ teamNameControllerを作った
□ showCreateTeamSheet()を作った
□ createTeam()を作った
□ +ボタンとshowCreateTeamSheetをつなげた
□ チーム名の空欄チェックを書いた
□ teams/{teamId} を作った
□ teams/{teamId}/members/{uid} を作った
□ roleをownerにした
□ 保存した
□ flutter runで起動した
□ Firestoreでteamsを確認した
□ Firestoreでmembersを確認した

ミニ確認問題

Q1. チーム作成時に作るFirestoreの場所はどこですか?

回答

次の2つです。

teams/{teamId}
teams/{teamId}/members/{uid}

Q2. チームを作った人の role は何にしますか?

回答

owner にします。

チームを作った人は、そのチームの所有者だからです。


Q3. memberIds は何のために使いますか?

回答

ログイン中のユーザーが参加しているチームを検索するために使います。

あとで、次のような検索に使います。

.where('memberIds', arrayContains: uid)

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

回答

必要ありません。

このページでは、Firestoreにチーム情報とメンバー情報を作るコードを追加するだけです。


このページのまとめ

  • チーム作成では、teams/{teamId}teams/{teamId}/members/{uid} を作る。
  • teams/{teamId} には、チーム名、作成者、参加者ID一覧を保存する。
  • members/{uid} には、チーム内でのユーザー情報と権限を保存する。
  • チームを作った人は owner として登録する。
  • memberIds に自分の uid を入れる。
  • memberIds は、自分が参加しているチームを表示するために使う。
  • ボトムシート内の更新には setSheetState を使う。
  • このページではnpmや環境変数は不要。

次のページでやること

次のページでは、作成したチームを一覧表示します。

where('memberIds', arrayContains: uid) を使って、自分が参加しているチームだけを表示します。

教材トップへ戻る