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

【権限チップ】ChoiceChipでadmin・member・viewerを選択できるようにする

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

このページでやること

このページでは、メンバー追加画面で権限を選べるようにします。

前のページでは、追加したメンバーは自動で member になっていました。

今回は、追加時に次の3つから選べるようにします。

admin
member
viewer

画面では、日本語で表示します。

管理者
メンバー
閲覧者

今日のゴール

メンバー追加ボトムシートに、権限を選ぶチップを表示します。

メンバーを追加

メールアドレス
[ sato@example.com ]

権限
[ 管理者 ] [ メンバー ] [ 閲覧者 ]

[追加する]

最初は member を選択状態にします。

初期選択:メンバー

npmや環境変数は必要?

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

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

Flutterの標準Widgetである ChoiceChip を使います。

main.dartを開く
↓
selectedMemberRoleを作る
↓
ChoiceChipを表示する
↓
保存時のroleに使う

Step 1:main.dartを開く

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

lib/main.dart

ターミナルから開く場合はこちらです。

code lib/main.dart

Step 2:MemberListPageを探す

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

class MemberListPage

VS Codeなら、次で検索できます。

command + F

今回は、_MemberListPageState の中を編集します。


Step 3:選択中の権限を保存する変数を作る

_MemberListPageState の中に、次を追加します。

String selectedMemberRole = 'member';

完成イメージです。

class _MemberListPageState extends State<MemberListPage> {
  final emailController = TextEditingController();

  String selectedMemberRole = 'member';

  bool isAdding = false;
  String? errorText;

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

selectedMemberRole は、現在選ばれている権限を覚えるための変数です。


Step 4:ボトムシートを開くときに初期化する

showAddMemberSheet() の最初を確認します。

Future<void> showAddMemberSheet() async {
  emailController.clear();
  errorText = null;

ここに、権限の初期値を追加します。

Future<void> showAddMemberSheet() async {
  emailController.clear();
  selectedMemberRole = 'member';
  errorText = null;

これで、ボトムシートを開くたびに「メンバー」が選ばれた状態になります。


Step 5:ChoiceChipとは?

ChoiceChip は、複数の選択肢から1つを選ぶためのWidgetです。

今回は、3つのチップを出します。

管理者
メンバー
閲覧者

内部では、次の値として保存します。

admin
member
viewer
画面表示は日本語
Firestore保存は英語

Step 6:権限チップUIを追加する

showAddMemberSheet() の中で、メールアドレス入力欄の下に追加します。

この部分を探します。

AppTextField(
  controller: emailController,
  label: 'メールアドレス',
  keyboardType: TextInputType.emailAddress,
),

その下に、次を追加します。

const SizedBox(height: 16),
const Text(
  '権限',
  style: TextStyle(
    color: AppColors.text,
    fontWeight: FontWeight.w900,
  ),
),
const SizedBox(height: 8),
Wrap(
  spacing: 8,
  runSpacing: 8,
  children: [
    ChoiceChip(
      label: const Text('管理者'),
      selected: selectedMemberRole == 'admin',
      onSelected: (_) {
        setSheetState(() {
          selectedMemberRole = 'admin';
        });
      },
    ),
    ChoiceChip(
      label: const Text('メンバー'),
      selected: selectedMemberRole == 'member',
      onSelected: (_) {
        setSheetState(() {
          selectedMemberRole = 'member';
        });
      },
    ),
    ChoiceChip(
      label: const Text('閲覧者'),
      selected: selectedMemberRole == 'viewer',
      onSelected: (_) {
        setSheetState(() {
          selectedMemberRole = 'viewer';
        });
      },
    ),
  ],
),

これで、ボトムシート内で権限を選べます。


Step 7:ChoiceChipの意味を見る

1つ分を見ると、こうです。

ChoiceChip(
  label: const Text('メンバー'),
  selected: selectedMemberRole == 'member',
  onSelected: (_) {
    setSheetState(() {
      selectedMemberRole = 'member';
    });
  },
),

意味はこうです。

表示名:メンバー
保存値:member
今memberなら選択中にする
押されたらmemberに変更する

大事なのは、この2つです。

selected: selectedMemberRole == 'member',
selectedMemberRole = 'member';

Step 8:Wrapを使う理由

チップは Wrap の中に置きます。

Wrap(
  spacing: 8,
  runSpacing: 8,
  children: [
    ...
  ],
)

Wrap を使うと、画面が狭いときに自動で折り返されます。

横幅が広い
↓
管理者 メンバー 閲覧者

横幅が狭い
↓
管理者 メンバー
閲覧者

スマホ向けに安全です。


Step 9:保存処理で選択した権限を使う

前のページでは、メンバー追加時に固定で member を保存していました。

'role': 'member',

これを変更します。

'role': selectedMemberRole,

addMemberByEmail() の中を探して、次のようにします。

await memberRef.set({
  'uid': uid,
  'email': userEmail,
  'displayName': displayName,
  'role': selectedMemberRole,
  'joinedAt': FieldValue.serverTimestamp(),
});

これで、選んだ権限がFirestoreに保存されます。


Step 10:完成形のshowAddMemberSheet

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

Future<void> showAddMemberSheet() async {
  emailController.clear();
  selectedMemberRole = 'member';
  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: [
                  Container(
                    width: 44,
                    height: 5,
                    margin: const EdgeInsets.only(bottom: 18),
                    decoration: BoxDecoration(
                      color: AppColors.border,
                      borderRadius: BorderRadius.circular(999),
                    ),
                  ),
                  const Text(
                    'メンバーを追加',
                    style: TextStyle(
                      fontSize: 20,
                      fontWeight: FontWeight.w900,
                      color: AppColors.text,
                    ),
                  ),
                  const SizedBox(height: 8),
                  const Text(
                    '登録済みユーザーのメールアドレスを入力してください。',
                    style: TextStyle(
                      color: AppColors.subText,
                      height: 1.5,
                      fontWeight: FontWeight.w600,
                    ),
                  ),
                  const SizedBox(height: 16),
                  AppTextField(
                    controller: emailController,
                    label: 'メールアドレス',
                    keyboardType: TextInputType.emailAddress,
                  ),
                  const SizedBox(height: 16),
                  const Text(
                    '権限',
                    style: TextStyle(
                      color: AppColors.text,
                      fontWeight: FontWeight.w900,
                    ),
                  ),
                  const SizedBox(height: 8),
                  Wrap(
                    spacing: 8,
                    runSpacing: 8,
                    children: [
                      ChoiceChip(
                        label: const Text('管理者'),
                        selected: selectedMemberRole == 'admin',
                        onSelected: (_) {
                          setSheetState(() {
                            selectedMemberRole = 'admin';
                          });
                        },
                      ),
                      ChoiceChip(
                        label: const Text('メンバー'),
                        selected: selectedMemberRole == 'member',
                        onSelected: (_) {
                          setSheetState(() {
                            selectedMemberRole = 'member';
                          });
                        },
                      ),
                      ChoiceChip(
                        label: const Text('閲覧者'),
                        selected: selectedMemberRole == 'viewer',
                        onSelected: (_) {
                          setSheetState(() {
                            selectedMemberRole = 'viewer';
                          });
                        },
                      ),
                    ],
                  ),
                  if (errorText != null) ...[
                    const SizedBox(height: 12),
                    ErrorBox(message: errorText!),
                  ],
                  const SizedBox(height: 16),
                  FilledButton(
                    onPressed: isAdding
                        ? null
                        : () async {
                            await addMemberByEmail(setSheetState);
                          },
                    child: Text(isAdding ? '追加中...' : '追加する'),
                  ),
                ],
              ),
            ),
          );
        },
      );
    },
  );
}

Step 11:完成形の保存処理

addMemberByEmail() の保存部分は、次のようにします。

await memberRef.set({
  'uid': uid,
  'email': userEmail,
  'displayName': displayName,
  'role': selectedMemberRole,
  'joinedAt': FieldValue.serverTimestamp(),
});

固定の member ではなく、選択した値を保存します。

管理者を選択 → role: admin
メンバーを選択 → role: member
閲覧者を選択 → role: viewer

Step 12:保存する

main.dart を保存します。

Macの場合:

command + S

Windowsの場合:

Ctrl + S

Step 13:実行する

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

flutter run

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

r

うまく反映されない場合は、R を押します。

R

Step 14:画面を確認する

アプリを開きます。

ログイン
↓
トーク一覧
↓
チームをタップ
↓
右上のメンバーアイコン
↓
右下の+

メンバー追加ボトムシートに、次のチップが表示されればOKです。

管理者 メンバー 閲覧者

最初は「メンバー」が選択されていれば成功です。


Step 15:権限を選んで追加してみる

登録済みユーザーのメールアドレスを入力します。

sato@example.com

権限を選びます。

管理者

「追加する」を押します。

メンバー一覧に表示され、権限が「管理者」になっていれば成功です。


Step 16:Firestoreで確認する

Firebase Consoleを開きます。

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

次を確認します。

Firestore Database
↓
teams
↓
作成したteamId
↓
members
↓
追加したuid

role が選んだ値になっていればOKです。

管理者を選んだ場合
↓
role: admin
閲覧者を選んだ場合
↓
role: viewer

よくあるエラーと直し方

エラー原因直し方
ChoiceChip isn't definedMaterialのimport不足import 'package:flutter/material.dart'; を確認
selectedMemberRole isn't defined変数がないString selectedMemberRole = 'member'; を追加
チップを押しても変わらないsetSheetState を使っていないsetSheetState(() { ... }) を使う
追加してもroleがmember固定保存時が 'role': 'member' のまま'role': selectedMemberRole に変更
ボトムシートを開くたび前回の権限が残る初期化していないselectedMemberRole = 'member'; を追加
role表示が不明になるrole文字列が違うadmin/member/viewer のどれかにする

ownerを選択肢に入れない理由

今回、追加時に選べるのはこの3つだけです。

admin
member
viewer

owner は入れません。

理由は、owner はチームの最上位権限だからです。

owner → チーム作成者
admin → 管理者として追加できる
member → 通常メンバー
viewer → 閲覧者

owner を簡単に増やせると危険なので、追加画面では選ばせません。


最短作業まとめ

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

1. 選択中の権限を作る

String selectedMemberRole = 'member';

2. ボトムシートを開くときに初期化

selectedMemberRole = 'member';

3. ChoiceChipを追加

Wrap(
  spacing: 8,
  runSpacing: 8,
  children: [
    ChoiceChip(
      label: const Text('管理者'),
      selected: selectedMemberRole == 'admin',
      onSelected: (_) {
        setSheetState(() {
          selectedMemberRole = 'admin';
        });
      },
    ),
    ChoiceChip(
      label: const Text('メンバー'),
      selected: selectedMemberRole == 'member',
      onSelected: (_) {
        setSheetState(() {
          selectedMemberRole = 'member';
        });
      },
    ),
    ChoiceChip(
      label: const Text('閲覧者'),
      selected: selectedMemberRole == 'viewer',
      onSelected: (_) {
        setSheetState(() {
          selectedMemberRole = 'viewer';
        });
      },
    ),
  ],
)

4. 保存時に使う

'role': selectedMemberRole,

チェックリスト

□ main.dartを開いた
□ MemberListPageを探した
□ selectedMemberRoleを追加した
□ 初期値をmemberにした
□ showAddMemberSheetで初期化した
□ ChoiceChipを3つ作った
□ 管理者チップを作った
□ メンバーチップを作った
□ 閲覧者チップを作った
□ setSheetStateで選択を変更した
□ role保存をselectedMemberRoleに変更した
□ 保存した
□ flutter runで確認した
□ チップを選択できた
□ 選んだroleでFirestoreに保存された

ミニ確認問題

Q1. ChoiceChipは何のために使いますか?

回答

複数の選択肢から、1つを選ぶために使います。

今回なら、メンバーの権限を選ぶために使います。


Q2. 追加時に選べるroleは何ですか?

回答

adminmemberviewer です。


Q3. なぜownerは選択肢に入れませんか?

回答

owner はチームの最上位権限で、簡単に増やすと危険だからです。


Q4. Firestoreに保存するroleはどの変数を使いますか?

回答

selectedMemberRole を使います。

'role': selectedMemberRole

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

回答

必要ありません。

Flutterの標準Widget ChoiceChip を使うだけです。


このページのまとめ

  • ChoiceChip で権限を選べるUIを作る。
  • 選択肢は adminmemberviewer
  • 表示は日本語、保存は英語にする。
  • 初期値は member にする。
  • チップを押したら selectedMemberRole を変更する。
  • 保存時は 'role': selectedMemberRole にする。
  • owner は追加画面では選ばせない。
  • このページではnpmや環境変数は不要。

次のページでやること

次のページでは、既存メンバーの権限を変更できるようにします。

メンバー一覧からユーザーをタップして、adminmemberviewer を変更する画面を作ります。

教材トップへ戻る