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

【LINE風追加UI】ボトムシートでメンバー追加画面を作る

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

このページでやること

このページでは、メンバー追加画面をLINE風のボトムシートUIに整えます。

前のページでは、メールアドレスから登録済みユーザーを検索して、チームに追加する処理を作りました。

今回は、その入力画面を見やすくします。

メンバー一覧画面
↓
右下の+を押す
↓
下からLINE風の追加画面が出る
↓
メールアドレスを入力
↓
追加する

今日のゴール

右下の + ボタンを押すと、下からこのような画面が出るようにします。

メンバーを追加

登録済みユーザーのメールアドレスを入力してください。

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

[追加する]

LINE風に、白背景・上だけ角丸・シンプルな入力欄で作ります。


npmや環境変数は必要?

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

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

必要なのは、すでにあるFlutterのコードだけです。

main.dartを開く
↓
MemberListPageを探す
↓
showAddMemberSheet()を作る
↓
右下の+ボタンにつなげる
↓
保存して確認

Step 1:main.dartを開く

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

lib/main.dart

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

code lib/main.dart

code が使えない場合は、VS Codeの左側から lib/main.dart を開いてください。


Step 2:MemberListPageを探す

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

class MemberListPage

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

command + F

この MemberListPage が、メンバー一覧画面です。

ここに、メンバー追加用のボトムシートを作ります。


Step 3:入力用Controllerを確認する

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

final emailController = TextEditingController();

これは、メールアドレス入力欄の文字を管理するための道具です。

まだない場合は、次のように追加します。

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

  bool isAdding = false;
  String? errorText;

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

  // この下に処理を書いていきます
}

dispose() は、使い終わった入力欄を片付ける処理です。


Step 4:ボトムシートを開く関数を作る

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

Future<void> showAddMemberSheet() async {
  emailController.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: 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),
                    ),
                    alignment: Alignment.center,
                  ),
                  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,
                  ),
                  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 ? '追加中...' : '追加する'),
                  ),
                ],
              ),
            ),
          );
        },
      );
    },
  );
}

これで、下からLINE風のメンバー追加画面が出ます。


Step 5:LINE風に見せるポイント

LINE風に見せるポイントは、次の4つです。

場所内容
背景
上だけ角丸
余白広めに取る
入力欄シンプルにする

中心は、この部分です。

backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
  borderRadius: BorderRadius.vertical(
    top: Radius.circular(20),
  ),
),

上だけ角丸にすることで、下から出てくる画面らしくなります。


Step 6:上の小さなバーを作る

この部分です。

Container(
  width: 44,
  height: 5,
  margin: const EdgeInsets.only(bottom: 18),
  decoration: BoxDecoration(
    color: AppColors.border,
    borderRadius: BorderRadius.circular(999),
  ),
  alignment: Alignment.center,
),

これは、ボトムシートの上にある小さなバーです。

────

このバーがあると、「下から出てきた画面」という雰囲気が出ます。


Step 7:キーボードに隠れないようにする

この部分です。

bottom: MediaQuery.of(context).viewInsets.bottom + 20,

スマホでキーボードが出たとき、入力欄が隠れないようにする設定です。

メール入力欄をタップ
↓
キーボードが出る
↓
ボトムシートが上にずれる

初心者の方は、このまま使えばOKです。


Step 8:SingleChildScrollViewを入れる理由

この部分です。

SingleChildScrollView(
  child: Column(
    ...
  ),
)

スマホ画面が小さいと、ボトムシートの中身が入りきらないことがあります。

SingleChildScrollView を使うと、縦にスクロールできるようになります。

画面が小さい
↓
スクロールできる
↓
入力欄やボタンが見える

Step 9:入力欄を作る

メールアドレス入力欄は、この部分です。

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

keyboardType: TextInputType.emailAddress にすると、スマホでメール入力向けのキーボードになります。

@ が入力しやすい
メールアドレス入力に向いている

Step 10:エラー表示を作る

この部分です。

if (errorText != null) ...[
  const SizedBox(height: 12),
  ErrorBox(message: errorText!),
],

エラーがあるときだけ、赤いエラー表示を出します。

たとえば、メールアドレスが空のときです。

メールアドレスを入力してください。

登録済みユーザーが見つからないときも、ここに表示します。

このメールアドレスのユーザーは見つかりません。

Step 11:追加ボタンを作る

この部分です。

FilledButton(
  onPressed: isAdding
      ? null
      : () async {
          await addMemberByEmail(setSheetState);
        },
  child: Text(isAdding ? '追加中...' : '追加する'),
),

意味はこうです。

追加中ではない
↓
ボタンを押せる
↓
addMemberByEmailが動く

追加中
↓
ボタンを押せない
↓
「追加中...」と表示

二重クリックで何度も追加されるのを防ぎます。


Step 12:追加処理があるか確認する

前のページで作った addMemberByEmail() があるか確認します。

Future<void> addMemberByEmail(StateSetter setSheetState) async {
  final email = emailController.text.trim();

  if (email.isEmpty) {
    setSheetState(() {
      errorText = 'メールアドレスを入力してください。';
    });
    return;
  }

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

  try {
    final userQuery = await FirebaseFirestore.instance
        .collection('users')
        .where('email', isEqualTo: email)
        .limit(1)
        .get();

    if (userQuery.docs.isEmpty) {
      setSheetState(() {
        errorText = 'このメールアドレスのユーザーは見つかりません。';
      });
      return;
    }

    final userDoc = userQuery.docs.first;
    final userData = userDoc.data();

    final uid = userDoc.id;
    final displayName =
        (userData['displayName'] ?? '名前未設定').toString();
    final userEmail = (userData['email'] ?? email).toString();

    final memberRef = FirebaseFirestore.instance
        .collection('teams')
        .doc(widget.teamId)
        .collection('members')
        .doc(uid);

    final memberDoc = await memberRef.get();

    if (memberDoc.exists) {
      setSheetState(() {
        errorText = 'このユーザーはすでにメンバーです。';
      });
      return;
    }

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

    await FirebaseFirestore.instance
        .collection('teams')
        .doc(widget.teamId)
        .update({
      'memberIds': FieldValue.arrayUnion([uid]),
      'updatedAt': FieldValue.serverTimestamp(),
    });

    if (mounted) {
      Navigator.of(context).pop();
    }
  } on FirebaseException catch (e) {
    setSheetState(() {
      errorText = e.message ?? 'メンバー追加に失敗しました。';
    });
  } catch (_) {
    setSheetState(() {
      errorText = 'メンバー追加に失敗しました。';
    });
  } finally {
    if (mounted) {
      setSheetState(() {
        isAdding = false;
      });
    }
  }
}

この関数が、実際にメンバーを追加する処理です。


Step 13:右下の+ボタンにつなげる

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

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

これで、右下の + を押すと、メンバー追加ボトムシートが開きます。


Step 14:保存する

main.dart を保存します。

Macの場合:

command + S

Windowsの場合:

Ctrl + S

Step 15:実行する

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

flutter run

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

r

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

R

Step 16:画面を確認する

アプリを開きます。

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

下から、メンバー追加画面が出れば成功です。

メンバーを追加
登録済みユーザーのメールアドレスを入力してください。
メールアドレス
追加する

Step 17:空欄エラーを確認する

何も入力せずに「追加する」を押します。

次のエラーが表示されればOKです。

メールアドレスを入力してください。

Step 18:未登録ユーザーのエラーを確認する

存在しないメールアドレスを入力します。

notfound@example.com

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

次のエラーが表示されればOKです。

このメールアドレスのユーザーは見つかりません。

Step 19:登録済みユーザーを追加する

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

sato@example.com

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

ボトムシートが閉じて、メンバー一覧に表示されれば成功です。

佐藤 花子
sato@example.com
メンバー

よくあるエラーと直し方

エラー原因直し方
showAddMemberSheet isn't defined関数がないshowAddMemberSheet() を追加
emailController isn't definedControllerがないemailController を追加
addMemberByEmail isn't defined追加処理がないaddMemberByEmail() を追加
ボトムシートが開かないFABにつながっていないonPressed: showAddMemberSheet にする
キーボードで隠れる余白設定がないviewInsets.bottom + 20 を入れる
追加ボタンが反応しないisAdding がtrueのままfinally でfalseに戻す
ユーザーが見つからないusersに登録されていない先に新規登録する

permission-denied が出たとき

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

rules_version = '2';

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

これは学習用です。

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

本番では、owner または admin だけがメンバー追加できるようにします。


最短作業まとめ

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

1. ボトムシートを作る

Future<void> showAddMemberSheet() async {
  emailController.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: SingleChildScrollView(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: [
                  const Text(
                    'メンバーを追加',
                    style: TextStyle(
                      fontSize: 20,
                      fontWeight: FontWeight.w900,
                    ),
                  ),
                  const SizedBox(height: 12),
                  AppTextField(
                    controller: emailController,
                    label: 'メールアドレス',
                    keyboardType: TextInputType.emailAddress,
                  ),
                  const SizedBox(height: 16),
                  FilledButton(
                    onPressed: isAdding
                        ? null
                        : () async {
                            await addMemberByEmail(setSheetState);
                          },
                    child: Text(isAdding ? '追加中...' : '追加する'),
                  ),
                ],
              ),
            ),
          );
        },
      );
    },
  );
}

2. 右下の+につなげる

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

3. 保存して実行

flutter run

チェックリスト

□ main.dartを開いた
□ MemberListPageを探した
□ emailControllerを確認した
□ showAddMemberSheet()を作った
□ showModalBottomSheetを書いた
□ 上だけ角丸にした
□ キーボード対策を入れた
□ SingleChildScrollViewを入れた
□ メールアドレス入力欄を作った
□ エラー表示を入れた
□ 追加ボタンを作った
□ addMemberByEmail()につなげた
□ FloatingActionButtonにつなげた
□ 保存した
□ flutter runで確認した
□ 右下の+からボトムシートが出た

ミニ確認問題

Q1. ボトムシートを表示する命令は何ですか?

回答

showModalBottomSheet() です。


Q2. メールアドレス入力欄の文字を管理するものは何ですか?

回答

TextEditingController です。

今回なら emailController を使います。


Q3. キーボードで入力欄が隠れにくくする設定は何ですか?

回答

次の設定です。

bottom: MediaQuery.of(context).viewInsets.bottom + 20

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

回答

必要ありません。

FlutterのUIを整えるだけです。


このページのまとめ

  • メンバー追加画面はボトムシートで作る。
  • showModalBottomSheet() で下から画面を出す。
  • backgroundColor: Colors.white で白背景にする。
  • shape で上だけ角丸にする。
  • MediaQuery.of(context).viewInsets.bottom でキーボード対策をする。
  • emailController で入力されたメールアドレスを管理する。
  • FilledButton から addMemberByEmail() を呼び出す。
  • このページではnpmや環境変数は不要。

次のページでやること

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

memberadmin にしたり、viewer に変更したりできる画面を作ります。

教材トップへ戻る