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

【プロフィール編集機能】Dialogで名前・ステータス・自己紹介を編集する

この節で学ぶこと

前回の 5-11 では、プロフィール編集画面を作り、画像URLでプロフィールアイコンを変更できるようにしました。

画像URLを入力
↓
Saveを押す
↓
UserProfileのavatarUrlを更新
↓
プロフィールカードのアイコンが変わる

今回の 5-12 では、プロフィール編集機能をさらに発展させます。

画像URLだけではなく、次の情報もDialogから編集できるようにします。

表示名
ハンドル名
ステータスメッセージ
自己紹介
オンライン状態
アイコン色
画像URL

つまり、プロフィール編集用のモーダル画面を作ります。

この節で一番大切なのは、次の一文です。

プロフィール編集機能は、複数のTextEditingControllerで入力値を管理し、保存時にUserProfileを作り直して親Widgetへ返す。

今回作る機能

今回作るプロフィール編集機能は、次のような流れです。

プロフィールカードを表示する
↓
編集ボタンを押す
↓
Dialogでプロフィール編集画面を開く
↓
表示名・ハンドル名・画像URL・ステータス・自己紹介を編集する
↓
オンライン状態やアイコン色を選ぶ
↓
保存する
↓
プロフィールカードへ反映する

この機能を作ることで、Discord風アプリがかなり「アプリらしく」なります。

ただし、今回もDB保存はしません。

今回の保存
↓
アプリを開いている間だけ反映

リロード後
↓
初期状態に戻る

前回との違い

前回の 5-11 では、主に avatarUrl だけを編集しました。

前回
↓
画像URLだけ編集

今回は、複数の項目を編集します。

今回
↓
名前
ハンドル名
ステータス
自己紹介
画像URL
アイコン色
オンライン状態

そのため、使う TextEditingController も増えます。

late final TextEditingController nameController;
late final TextEditingController handleController;
late final TextEditingController statusController;
late final TextEditingController aboutController;
late final TextEditingController avatarUrlController;

それぞれの入力欄に、専用のControllerを用意します。

今回使う主な考え方

今回の教材では、次の考え方が重要です。

考え方内容
TextEditingController入力欄の文字を管理する
Dialog画面の上に編集画面を表示する
setState入力や選択に応じて画面を更新する
ValueChanged<UserProfile>編集後のプロフィールを親Widgetへ返す
Wrap色やステータスの選択肢を折り返し表示する
SingleChildScrollView編集項目が多い画面をスクロール可能にする

新しい言葉が多く見えますが、やっていることはシンプルです。

入力する
↓
保存する
↓
データを更新する
↓
画面が変わる

最初に完成イメージを分解する

プロフィール編集画面は、次のような構造にします。

ProfileEditorDialog
├─ 上部バナー
│  ├─ Edit Profile
│  └─ 閉じるボタン
├─ アイコンプレビュー
├─ アイコン色の選択
├─ 表示名入力欄
├─ ハンドル名入力欄
├─ 画像URL入力欄
├─ ステータスメッセージ入力欄
├─ 自己紹介入力欄
├─ オンライン状態の選択
└─ Cancel / Save Changes

見た目は複雑ですが、分解するとこれまで学んだWidgetで作れます。

Container
Column
Row
TextField
Wrap
GestureDetector
ElevatedButton

まずUserProfileを確認する

プロフィール編集で更新するデータは、UserProfile です。

class UserProfile {
  const UserProfile({
    required this.name,
    required this.handle,
    required this.status,
    required this.about,
    required this.avatarColor,
    required this.avatarUrl,
    required this.onlineStatus,
  });

  final String name;
  final String handle;
  final String status;
  final String about;
  final Color avatarColor;
  final String avatarUrl;
  final OnlineStatus onlineStatus;
}

このclassが、ユーザー1人分のプロフィール情報を持っています。

property内容
name表示名
handle@flutter_dev のようなID
statusステータスメッセージ
about自己紹介
avatarColorアイコン色
avatarUrlアイコン画像URL
onlineStatusオンライン状態

今回のDialogでは、この UserProfile を編集します。

OnlineStatusを確認する

オンライン状態は、enum で表します。

enum OnlineStatus {
  online,
  idle,
  doNotDisturb,
  offline,
}

それぞれの意味は、次の通りです。

意味表示色
onlineオンライン
idle離席中黄色
doNotDisturb取り込み中
offlineオフライングレー

オンライン状態に応じて色を返す関数も使います。

Color statusColor(OnlineStatus status) {
  switch (status) {
    case OnlineStatus.online:
      return DiscordColors.green;
    case OnlineStatus.idle:
      return DiscordColors.yellow;
    case OnlineStatus.doNotDisturb:
      return DiscordColors.red;
    case OnlineStatus.offline:
      return DiscordColors.textMuted;
  }
}

statusLabel関数を作る

今回は、オンライン状態を選択肢として表示します。

そのため、OnlineStatus.onlineOnline のような文字に変換する関数を作ります。

String statusLabel(OnlineStatus status) {
  switch (status) {
    case OnlineStatus.online:
      return 'Online';
    case OnlineStatus.idle:
      return 'Idle';
    case OnlineStatus.doNotDisturb:
      return 'Do Not Disturb';
    case OnlineStatus.offline:
      return 'Offline';
  }
}

この関数を使うと、選択肢を表示しやすくなります。

OnlineStatus.online
↓
Online

OnlineStatus.idle
↓
Idle

Dialogを開く親画面を作る

まず、プロフィールカードと編集ボタンを持つ画面を作ります。

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

  @override
  State<ProfileEditPracticePage> createState() =>
      _ProfileEditPracticePageState();
}

class _ProfileEditPracticePageState extends State<ProfileEditPracticePage> {
  UserProfile currentUser = const UserProfile(
    name: 'flutter_dev',
    handle: '@flutter_dev',
    status: 'Flutter UIを制作中',
    about: 'Discord風UIをFlutterで再現しています。プロフィールは一時保存のみです。',
    avatarColor: DiscordColors.blurple,
    avatarUrl: '',
    onlineStatus: OnlineStatus.online,
  );

  void updateProfile(UserProfile profile) {
    setState(() {
      currentUser = profile;
    });
  }

  void openProfileEditor() {
    showDialog<void>(
      context: context,
      barrierColor: Colors.black54,
      builder: (context) {
        return ProfileEditorDialog(
          initialProfile: currentUser,
          onSave: updateProfile,
        );
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: DiscordColors.background,
      body: SafeArea(
        child: Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              ProfileCard(user: currentUser),
              const SizedBox(height: DiscordSpacing.lg),
              ElevatedButton(
                onPressed: openProfileEditor,
                style: ElevatedButton.styleFrom(
                  backgroundColor: DiscordColors.blurple,
                  foregroundColor: Colors.white,
                  padding: const EdgeInsets.symmetric(
                    horizontal: 20,
                    vertical: 14,
                  ),
                ),
                child: const Text(
                  'プロフィールを編集',
                  style: TextStyle(fontWeight: FontWeight.bold),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

この画面では、currentUser を状態として持っています。

UserProfile currentUser = const UserProfile(...);

そして、Dialogで保存されたプロフィールを updateProfile で受け取ります。

void updateProfile(UserProfile profile) {
  setState(() {
    currentUser = profile;
  });
}

ここで setState することで、プロフィールカードが更新されます。

ProfileEditorDialogの基本構造

次に、プロフィール編集用のDialogを作ります。

まずは骨組みです。

class ProfileEditorDialog extends StatefulWidget {
  const ProfileEditorDialog({
    super.key,
    required this.initialProfile,
    required this.onSave,
  });

  final UserProfile initialProfile;
  final ValueChanged<UserProfile> onSave;

  @override
  State<ProfileEditorDialog> createState() => _ProfileEditorDialogState();
}

initialProfile は、編集前のプロフィールです。

final UserProfile initialProfile;

onSave は、保存したプロフィールを親へ返すための関数です。

final ValueChanged<UserProfile> onSave;

この2つを使って、Dialog内で編集し、保存時に親Widgetへ返します。

複数のTextEditingControllerを用意する

今回のDialogでは、複数の入力欄を扱います。

そのため、Controllerも複数用意します。

class _ProfileEditorDialogState extends State<ProfileEditorDialog> {
  late final TextEditingController nameController;
  late final TextEditingController handleController;
  late final TextEditingController statusController;
  late final TextEditingController aboutController;
  late final TextEditingController avatarUrlController;

  late Color selectedColor;
  late OnlineStatus selectedStatus;
}

それぞれの役割は次の通りです。

Controller / 変数役割
nameController表示名
handleControllerハンドル名
statusControllerステータスメッセージ
aboutController自己紹介
avatarUrlController画像URL
selectedColor選択中のアイコン色
selectedStatus選択中のオンライン状態

initStateで初期値を入れる

Dialogを開いたとき、現在のプロフィール情報が入力欄に入っていると便利です。

そこで、initState で初期値を設定します。

@override
void initState() {
  super.initState();

  final profile = widget.initialProfile;

  nameController = TextEditingController(text: profile.name);
  handleController = TextEditingController(text: profile.handle);
  statusController = TextEditingController(text: profile.status);
  aboutController = TextEditingController(text: profile.about);
  avatarUrlController = TextEditingController(text: profile.avatarUrl);

  selectedColor = profile.avatarColor;
  selectedStatus = profile.onlineStatus;
}

widget.initialProfile は、Dialogに渡された編集前のプロフィールです。

Dialogを開く
↓
initialProfileを受け取る
↓
入力欄に初期値として入れる

この処理によって、編集画面を開いたときに、現在の情報が表示されます。

disposeでControllerを片付ける

TextEditingController を作ったら、使い終わったときに片付けます。

@override
void dispose() {
  nameController.dispose();
  handleController.dispose();
  statusController.dispose();
  aboutController.dispose();
  avatarUrlController.dispose();
  super.dispose();
}

初心者のうちは、次のように覚えてください。

TextEditingControllerを作ったら、disposeで片付ける

Controllerを複数作った場合は、すべて dispose します。

アイコン色の選択肢を用意する

アイコン色を選べるようにするため、色のListを作ります。

final List<Color> avatarColors = const [
  DiscordColors.blurple,
  Color(0xFFEB459E),
  Color(0xFF57F287),
  Color(0xFF00B0F4),
  Color(0xFFFEE75C),
  Color(0xFFFF7A59),
  Color(0xFF9B59FF),
];

このListを Wrap で表示します。

avatarColors
↓
色の丸いボタンとして表示
↓
タップするとselectedColorが変わる

Wrapとは何か

Wrap は、横に並べて、入りきらない場合は次の行に折り返すWidgetです。

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

初心者向けには、次のように理解してください。

Wrap
↓
横並びにしつつ、入りきらないと折り返すWidget

色の選択肢やタグの一覧などに向いています。

Row だと横幅が足りないとoverflowする可能性があります。

Row
↓
横に入りきらないとエラーになりやすい

Wrap
↓
入りきらないと次の行に折り返す

入力欄を共通Widgetにする

複数の入力欄を作るため、DiscordTextField を用意します。

class DiscordTextField extends StatelessWidget {
  const DiscordTextField({
    super.key,
    required this.label,
    required this.controller,
    this.maxLines = 1,
    this.onChanged,
  });

  final String label;
  final TextEditingController controller;
  final int maxLines;
  final ValueChanged<String>? onChanged;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          label,
          overflow: TextOverflow.ellipsis,
          style: const TextStyle(
            color: DiscordColors.textMuted,
            fontSize: 12,
            fontWeight: FontWeight.bold,
            letterSpacing: 0.6,
          ),
        ),
        const SizedBox(height: 8),
        TextField(
          controller: controller,
          onChanged: onChanged,
          maxLines: maxLines,
          style: const TextStyle(
            color: DiscordColors.textPrimary,
            fontSize: 14,
            fontWeight: FontWeight.w600,
          ),
          cursorColor: DiscordColors.textPrimary,
          decoration: InputDecoration(
            filled: true,
            fillColor: DiscordColors.input,
            contentPadding: const EdgeInsets.symmetric(
              horizontal: 12,
              vertical: 12,
            ),
            border: OutlineInputBorder(
              borderRadius: BorderRadius.circular(DiscordRadius.md),
              borderSide: BorderSide.none,
            ),
            focusedBorder: OutlineInputBorder(
              borderRadius: BorderRadius.circular(DiscordRadius.md),
              borderSide: const BorderSide(
                color: DiscordColors.blurple,
                width: 1.4,
              ),
            ),
          ),
        ),
      ],
    );
  }
}

このWidgetを作ることで、入力欄を簡単に増やせます。

DiscordTextField(
  label: 'DISPLAY NAME',
  controller: nameController,
)
DiscordTextField(
  label: 'ABOUT ME',
  controller: aboutController,
  maxLines: 3,
)

保存処理を作る

保存処理では、入力欄の値を取り出して、新しい UserProfile を作ります。

void save() {
  final name = nameController.text.trim().isEmpty
      ? widget.initialProfile.name
      : nameController.text.trim();

  final handle = handleController.text.trim().isEmpty
      ? '@${name.toLowerCase()}'
      : handleController.text.trim();

  widget.onSave(
    UserProfile(
      name: name,
      handle: handle.startsWith('@') ? handle : '@$handle',
      status: statusController.text.trim().isEmpty
          ? 'オンライン'
          : statusController.text.trim(),
      about: aboutController.text.trim().isEmpty
          ? '自己紹介はまだありません。'
          : aboutController.text.trim(),
      avatarColor: selectedColor,
      avatarUrl: avatarUrlController.text.trim(),
      onlineStatus: selectedStatus,
    ),
  );

  Navigator.of(context).pop();
}

この保存処理では、入力が空だった場合の補完もしています。

名前が空
↓
元の名前を使う

ハンドルが空
↓
名前から作る

ステータスが空
↓
オンライン

自己紹介が空
↓
自己紹介はまだありません。

入力値をそのまま保存するだけでなく、アプリ側で最低限整えることが大切です。

handle.startsWith(’@’) の意味

保存処理では、次のようなコードがあります。

handle: handle.startsWith('@') ? handle : '@$handle',

これは、ハンドル名の先頭に @ があるか確認しています。

@flutter_dev
↓
そのまま使う

flutter_dev
↓
@flutter_dev にする

ユーザーが @ を入力してもしなくても、保存後は @ 付きの表示に統一できます。

完成コード

ここまでの内容をまとめた完成コードです。

import 'package:flutter/material.dart';

void main() {
  runApp(const ProfileEditPracticeApp());
}

class ProfileEditPracticeApp extends StatelessWidget {
  const ProfileEditPracticeApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Profile Edit Practice',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        brightness: Brightness.dark,
        scaffoldBackgroundColor: DiscordColors.background,
      ),
      home: const ProfileEditPracticePage(),
    );
  }
}

class DiscordColors {
  static const Color background = Color(0xFF313338);
  static const Color panel = Color(0xFF232428);
  static const Color input = Color(0xFF383A40);

  static const Color textPrimary = Color(0xFFF2F3F5);
  static const Color textSecondary = Color(0xFFB5BAC1);
  static const Color textMuted = Color(0xFF949BA4);

  static const Color blurple = Color(0xFF5865F2);
  static const Color green = Color(0xFF23A559);
  static const Color yellow = Color(0xFFF0B232);
  static const Color red = Color(0xFFF23F42);
}

class DiscordSpacing {
  static const double sm = 8;
  static const double md = 12;
  static const double lg = 16;
  static const double xl = 24;
}

class DiscordRadius {
  static const double md = 8;
  static const double lg = 12;
}

enum OnlineStatus {
  online,
  idle,
  doNotDisturb,
  offline,
}

class UserProfile {
  const UserProfile({
    required this.name,
    required this.handle,
    required this.status,
    required this.about,
    required this.avatarColor,
    required this.avatarUrl,
    required this.onlineStatus,
  });

  final String name;
  final String handle;
  final String status;
  final String about;
  final Color avatarColor;
  final String avatarUrl;
  final OnlineStatus onlineStatus;
}

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

  @override
  State<ProfileEditPracticePage> createState() =>
      _ProfileEditPracticePageState();
}

class _ProfileEditPracticePageState extends State<ProfileEditPracticePage> {
  UserProfile currentUser = const UserProfile(
    name: 'flutter_dev',
    handle: '@flutter_dev',
    status: 'Flutter UIを制作中',
    about: 'Discord風UIをFlutterで再現しています。プロフィールは一時保存のみです。',
    avatarColor: DiscordColors.blurple,
    avatarUrl: '',
    onlineStatus: OnlineStatus.online,
  );

  void updateProfile(UserProfile profile) {
    setState(() {
      currentUser = profile;
    });
  }

  void openProfileEditor() {
    showDialog<void>(
      context: context,
      barrierColor: Colors.black54,
      builder: (context) {
        return ProfileEditorDialog(
          initialProfile: currentUser,
          onSave: updateProfile,
        );
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: DiscordColors.background,
      body: SafeArea(
        child: Center(
          child: SingleChildScrollView(
            padding: const EdgeInsets.all(DiscordSpacing.lg),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                ProfileCard(user: currentUser),
                const SizedBox(height: DiscordSpacing.lg),
                ElevatedButton(
                  onPressed: openProfileEditor,
                  style: ElevatedButton.styleFrom(
                    backgroundColor: DiscordColors.blurple,
                    foregroundColor: Colors.white,
                    padding: const EdgeInsets.symmetric(
                      horizontal: 20,
                      vertical: 14,
                    ),
                  ),
                  child: const Text(
                    'プロフィールを編集',
                    style: TextStyle(fontWeight: FontWeight.bold),
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class ProfileEditorDialog extends StatefulWidget {
  const ProfileEditorDialog({
    super.key,
    required this.initialProfile,
    required this.onSave,
  });

  final UserProfile initialProfile;
  final ValueChanged<UserProfile> onSave;

  @override
  State<ProfileEditorDialog> createState() => _ProfileEditorDialogState();
}

class _ProfileEditorDialogState extends State<ProfileEditorDialog> {
  late final TextEditingController nameController;
  late final TextEditingController handleController;
  late final TextEditingController statusController;
  late final TextEditingController aboutController;
  late final TextEditingController avatarUrlController;

  late Color selectedColor;
  late OnlineStatus selectedStatus;

  final List<Color> avatarColors = const [
    DiscordColors.blurple,
    Color(0xFFEB459E),
    Color(0xFF57F287),
    Color(0xFF00B0F4),
    Color(0xFFFEE75C),
    Color(0xFFFF7A59),
    Color(0xFF9B59FF),
  ];

  @override
  void initState() {
    super.initState();

    final profile = widget.initialProfile;

    nameController = TextEditingController(text: profile.name);
    handleController = TextEditingController(text: profile.handle);
    statusController = TextEditingController(text: profile.status);
    aboutController = TextEditingController(text: profile.about);
    avatarUrlController = TextEditingController(text: profile.avatarUrl);

    selectedColor = profile.avatarColor;
    selectedStatus = profile.onlineStatus;
  }

  @override
  void dispose() {
    nameController.dispose();
    handleController.dispose();
    statusController.dispose();
    aboutController.dispose();
    avatarUrlController.dispose();
    super.dispose();
  }

  void save() {
    final name = nameController.text.trim().isEmpty
        ? widget.initialProfile.name
        : nameController.text.trim();

    final handle = handleController.text.trim().isEmpty
        ? '@${name.toLowerCase()}'
        : handleController.text.trim();

    widget.onSave(
      UserProfile(
        name: name,
        handle: handle.startsWith('@') ? handle : '@$handle',
        status: statusController.text.trim().isEmpty
            ? 'オンライン'
            : statusController.text.trim(),
        about: aboutController.text.trim().isEmpty
            ? '自己紹介はまだありません。'
            : aboutController.text.trim(),
        avatarColor: selectedColor,
        avatarUrl: avatarUrlController.text.trim(),
        onlineStatus: selectedStatus,
      ),
    );

    Navigator.of(context).pop();
  }

  @override
  Widget build(BuildContext context) {
    final compact = MediaQuery.of(context).size.width < 620;

    return Dialog(
      backgroundColor: Colors.transparent,
      insetPadding: EdgeInsets.symmetric(
        horizontal: compact ? 12 : 24,
        vertical: 24,
      ),
      child: ConstrainedBox(
        constraints: const BoxConstraints(maxWidth: 560),
        child: Container(
          decoration: BoxDecoration(
            color: DiscordColors.panel,
            borderRadius: BorderRadius.circular(DiscordRadius.lg),
            boxShadow: const [
              BoxShadow(
                color: Colors.black54,
                blurRadius: 30,
                offset: Offset(0, 18),
              ),
            ],
          ),
          clipBehavior: Clip.antiAlias,
          child: SingleChildScrollView(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                Container(
                  height: 96,
                  color: selectedColor,
                  child: Padding(
                    padding: const EdgeInsets.all(DiscordSpacing.lg),
                    child: Row(
                      children: [
                        const Expanded(
                          child: Text(
                            'Edit Profile',
                            overflow: TextOverflow.ellipsis,
                            style: TextStyle(
                              color: DiscordColors.textPrimary,
                              fontSize: 22,
                              fontWeight: FontWeight.bold,
                            ),
                          ),
                        ),
                        IconButton(
                          onPressed: () {
                            Navigator.of(context).pop();
                          },
                          icon: const Icon(
                            Icons.close_rounded,
                            color: DiscordColors.textPrimary,
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
                Padding(
                  padding: const EdgeInsets.fromLTRB(22, 0, 22, 22),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Transform.translate(
                        offset: const Offset(0, -34),
                        child: UserAvatar(
                          name: nameController.text.isEmpty
                              ? widget.initialProfile.name
                              : nameController.text,
                          color: selectedColor,
                          imageUrl: avatarUrlController.text,
                          status: selectedStatus,
                          size: 76,
                          borderColor: DiscordColors.panel,
                        ),
                      ),
                      Transform.translate(
                        offset: const Offset(0, -18),
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            const Text(
                              'ICON COLOR',
                              style: TextStyle(
                                color: DiscordColors.textMuted,
                                fontSize: 12,
                                fontWeight: FontWeight.bold,
                                letterSpacing: 0.6,
                              ),
                            ),
                            const SizedBox(height: DiscordSpacing.sm),
                            Wrap(
                              spacing: 10,
                              runSpacing: 10,
                              children: avatarColors.map((color) {
                                final selected =
                                    color.value == selectedColor.value;

                                return GestureDetector(
                                  onTap: () {
                                    setState(() {
                                      selectedColor = color;
                                    });
                                  },
                                  child: AnimatedContainer(
                                    duration:
                                        const Duration(milliseconds: 160),
                                    width: 34,
                                    height: 34,
                                    decoration: BoxDecoration(
                                      color: color,
                                      shape: BoxShape.circle,
                                      border: Border.all(
                                        color: selected
                                            ? Colors.white
                                            : Colors.transparent,
                                        width: 3,
                                      ),
                                    ),
                                    child: selected
                                        ? const Icon(
                                            Icons.check_rounded,
                                            color: Colors.white,
                                            size: 18,
                                          )
                                        : null,
                                  ),
                                );
                              }).toList(),
                            ),
                            const SizedBox(height: DiscordSpacing.lg),
                            DiscordTextField(
                              label: 'DISPLAY NAME',
                              controller: nameController,
                              onChanged: (_) {
                                setState(() {});
                              },
                            ),
                            const SizedBox(height: DiscordSpacing.md),
                            DiscordTextField(
                              label: 'HANDLE',
                              controller: handleController,
                            ),
                            const SizedBox(height: DiscordSpacing.md),
                            DiscordTextField(
                              label: 'ICON IMAGE URL',
                              controller: avatarUrlController,
                              onChanged: (_) {
                                setState(() {});
                              },
                            ),
                            const SizedBox(height: DiscordSpacing.md),
                            DiscordTextField(
                              label: 'STATUS MESSAGE',
                              controller: statusController,
                            ),
                            const SizedBox(height: DiscordSpacing.md),
                            DiscordTextField(
                              label: 'ABOUT ME',
                              controller: aboutController,
                              maxLines: 3,
                            ),
                            const SizedBox(height: DiscordSpacing.lg),
                            const Text(
                              'ONLINE STATUS',
                              style: TextStyle(
                                color: DiscordColors.textMuted,
                                fontSize: 12,
                                fontWeight: FontWeight.bold,
                                letterSpacing: 0.6,
                              ),
                            ),
                            const SizedBox(height: DiscordSpacing.sm),
                            Wrap(
                              spacing: 8,
                              runSpacing: 8,
                              children: OnlineStatus.values.map((status) {
                                final selected = selectedStatus == status;

                                return GestureDetector(
                                  onTap: () {
                                    setState(() {
                                      selectedStatus = status;
                                    });
                                  },
                                  child: AnimatedContainer(
                                    duration:
                                        const Duration(milliseconds: 160),
                                    padding: const EdgeInsets.symmetric(
                                      horizontal: 12,
                                      vertical: 8,
                                    ),
                                    decoration: BoxDecoration(
                                      color: selected
                                          ? DiscordColors.blurple
                                          : DiscordColors.input,
                                      borderRadius: BorderRadius.circular(999),
                                    ),
                                    child: Row(
                                      mainAxisSize: MainAxisSize.min,
                                      children: [
                                        Container(
                                          width: 9,
                                          height: 9,
                                          decoration: BoxDecoration(
                                            color: statusColor(status),
                                            shape: BoxShape.circle,
                                          ),
                                        ),
                                        const SizedBox(width: 7),
                                        Text(
                                          statusLabel(status),
                                          style: const TextStyle(
                                            color: DiscordColors.textPrimary,
                                            fontSize: 13,
                                            fontWeight: FontWeight.w700,
                                          ),
                                        ),
                                      ],
                                    ),
                                  ),
                                );
                              }).toList(),
                            ),
                            const SizedBox(height: DiscordSpacing.xl),
                            Row(
                              mainAxisAlignment: MainAxisAlignment.end,
                              children: [
                                TextButton(
                                  onPressed: () {
                                    Navigator.of(context).pop();
                                  },
                                  child: const Text(
                                    'Cancel',
                                    style: TextStyle(
                                      color: DiscordColors.textSecondary,
                                    ),
                                  ),
                                ),
                                const SizedBox(width: DiscordSpacing.sm),
                                ElevatedButton(
                                  onPressed: save,
                                  style: ElevatedButton.styleFrom(
                                    backgroundColor: DiscordColors.blurple,
                                    foregroundColor: Colors.white,
                                    padding: const EdgeInsets.symmetric(
                                      horizontal: 18,
                                      vertical: 14,
                                    ),
                                  ),
                                  child: const Text(
                                    'Save Changes',
                                    style: TextStyle(
                                      fontWeight: FontWeight.bold,
                                    ),
                                  ),
                                ),
                              ],
                            ),
                          ],
                        ),
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class DiscordTextField extends StatelessWidget {
  const DiscordTextField({
    super.key,
    required this.label,
    required this.controller,
    this.maxLines = 1,
    this.onChanged,
  });

  final String label;
  final TextEditingController controller;
  final int maxLines;
  final ValueChanged<String>? onChanged;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          label,
          overflow: TextOverflow.ellipsis,
          style: const TextStyle(
            color: DiscordColors.textMuted,
            fontSize: 12,
            fontWeight: FontWeight.bold,
            letterSpacing: 0.6,
          ),
        ),
        const SizedBox(height: 8),
        TextField(
          controller: controller,
          onChanged: onChanged,
          maxLines: maxLines,
          style: const TextStyle(
            color: DiscordColors.textPrimary,
            fontSize: 14,
            fontWeight: FontWeight.w600,
          ),
          cursorColor: DiscordColors.textPrimary,
          decoration: InputDecoration(
            filled: true,
            fillColor: DiscordColors.input,
            contentPadding: const EdgeInsets.symmetric(
              horizontal: 12,
              vertical: 12,
            ),
            border: OutlineInputBorder(
              borderRadius: BorderRadius.circular(DiscordRadius.md),
              borderSide: BorderSide.none,
            ),
            focusedBorder: OutlineInputBorder(
              borderRadius: BorderRadius.circular(DiscordRadius.md),
              borderSide: const BorderSide(
                color: DiscordColors.blurple,
                width: 1.4,
              ),
            ),
          ),
        ),
      ],
    );
  }
}

class ProfileCard extends StatelessWidget {
  const ProfileCard({
    super.key,
    required this.user,
  });

  final UserProfile user;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 300,
      margin: const EdgeInsets.all(DiscordSpacing.lg),
      decoration: BoxDecoration(
        color: DiscordColors.panel,
        borderRadius: BorderRadius.circular(DiscordRadius.lg),
      ),
      clipBehavior: Clip.antiAlias,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          SizedBox(
            height: 118,
            child: Stack(
              clipBehavior: Clip.none,
              children: [
                Container(
                  height: 64,
                  color: user.avatarColor,
                ),
                Positioned(
                  left: 14,
                  top: 34,
                  child: UserAvatar(
                    name: user.name,
                    color: user.avatarColor,
                    imageUrl: user.avatarUrl,
                    status: user.onlineStatus,
                    size: 72,
                    borderColor: DiscordColors.panel,
                  ),
                ),
              ],
            ),
          ),
          Padding(
            padding: const EdgeInsets.fromLTRB(14, 0, 14, 16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  user.name,
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(
                    color: DiscordColors.textPrimary,
                    fontSize: 22,
                    fontWeight: FontWeight.bold,
                    height: 1.1,
                  ),
                ),
                const SizedBox(height: DiscordSpacing.sm),
                Text(
                  user.handle,
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(
                    color: DiscordColors.textMuted,
                    fontSize: 14,
                  ),
                ),
                const SizedBox(height: DiscordSpacing.sm),
                Text(
                  user.status,
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(
                    color: DiscordColors.textSecondary,
                    fontSize: 13,
                  ),
                ),
                const SizedBox(height: DiscordSpacing.lg),
                Container(height: 1, color: Colors.white12),
                const SizedBox(height: DiscordSpacing.lg),
                const Text(
                  'ABOUT ME',
                  style: TextStyle(
                    color: DiscordColors.textMuted,
                    fontSize: 12,
                    fontWeight: FontWeight.bold,
                    letterSpacing: 0.4,
                  ),
                ),
                const SizedBox(height: DiscordSpacing.sm),
                Text(
                  user.about,
                  style: const TextStyle(
                    color: DiscordColors.textSecondary,
                    fontSize: 14,
                    height: 1.55,
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

class UserAvatar extends StatelessWidget {
  const UserAvatar({
    super.key,
    required this.name,
    required this.color,
    required this.imageUrl,
    required this.status,
    required this.size,
    this.borderColor,
  });

  final String name;
  final Color color;
  final String imageUrl;
  final OnlineStatus status;
  final double size;
  final Color? borderColor;

  @override
  Widget build(BuildContext context) {
    final initials = buildInitials(name);
    final hasImage = imageUrl.trim().isNotEmpty;

    return SizedBox(
      width: size,
      height: size,
      child: Stack(
        clipBehavior: Clip.none,
        children: [
          Container(
            width: size,
            height: size,
            clipBehavior: Clip.antiAlias,
            decoration: BoxDecoration(
              color: color,
              shape: BoxShape.circle,
              border: Border.all(
                color: borderColor ?? Colors.transparent,
                width: borderColor == null ? 0 : 5,
              ),
            ),
            alignment: Alignment.center,
            child: hasImage
                ? Image.network(
                    imageUrl,
                    fit: BoxFit.cover,
                    width: size,
                    height: size,
                    errorBuilder: (context, error, stackTrace) {
                      return Center(
                        child: Text(
                          initials,
                          style: TextStyle(
                            color: Colors.white,
                            fontSize: size * 0.34,
                            fontWeight: FontWeight.bold,
                          ),
                        ),
                      );
                    },
                  )
                : Text(
                    initials,
                    style: TextStyle(
                      color: Colors.white,
                      fontSize: size * 0.34,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
          ),
          Positioned(
            right: -1,
            bottom: -1,
            child: Container(
              width: size * 0.32,
              height: size * 0.32,
              decoration: BoxDecoration(
                color: statusColor(status),
                shape: BoxShape.circle,
                border: Border.all(
                  color: borderColor ?? DiscordColors.background,
                  width: 3,
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

Color statusColor(OnlineStatus status) {
  switch (status) {
    case OnlineStatus.online:
      return DiscordColors.green;
    case OnlineStatus.idle:
      return DiscordColors.yellow;
    case OnlineStatus.doNotDisturb:
      return DiscordColors.red;
    case OnlineStatus.offline:
      return DiscordColors.textMuted;
  }
}

String statusLabel(OnlineStatus status) {
  switch (status) {
    case OnlineStatus.online:
      return 'Online';
    case OnlineStatus.idle:
      return 'Idle';
    case OnlineStatus.doNotDisturb:
      return 'Do Not Disturb';
    case OnlineStatus.offline:
      return 'Offline';
  }
}

String buildInitials(String name) {
  final trimmed = name.trim();

  if (trimmed.isEmpty) {
    return '?';
  }

  final parts = trimmed.split(RegExp(r'\s+'));

  if (parts.length >= 2) {
    return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
  }

  if (trimmed.length >= 2) {
    return trimmed.substring(0, 2).toUpperCase();
  }

  return trimmed[0].toUpperCase();
}

実行して確認すること

コードを実行したら、次の順番で操作してください。

1. プロフィールカードが表示される
2. 「プロフィールを編集」を押す
3. Edit Profile のDialogが開く
4. 表示名を変更する
5. ハンドル名を変更する
6. 画像URLを入力する
7. ステータスメッセージを変更する
8. 自己紹介を変更する
9. アイコン色を選ぶ
10. オンライン状態を選ぶ
11. Save Changesを押す
12. プロフィールカードに反映される

これで、プロフィール編集用のモーダル画面が完成します。

入力とプレビューの関係

今回のDialogでは、入力中の内容をプレビューに反映しています。

たとえば、表示名を変えると、アイコンの頭文字も変わります。

name: nameController.text.isEmpty
    ? widget.initialProfile.name
    : nameController.text,

また、画像URLを変えると、アイコン画像も変わります。

imageUrl: avatarUrlController.text,

色を選ぶと、上部バナーとアイコン色が変わります。

color: selectedColor,

このように、入力内容や選択状態をプレビューに反映すると、ユーザーにとって分かりやすい編集画面になります。

手を動かす練習1:アイコン色を追加する

avatarColors に新しい色を追加してみましょう。

Color(0xFF2ECC71),

保存すると、色の選択肢が1つ増えます。

avatarColorsに追加
↓
Wrapで自動的に表示される

データを増やすだけでUIも増える流れを確認してください。

手を動かす練習2:ステータスの初期値を変える

currentUseronlineStatus を変えてみましょう。

onlineStatus: OnlineStatus.idle,

最初から黄色のステータス表示になります。

編集Dialogを開くと、Idle が選択された状態になります。

手を動かす練習3:自己紹介の初期文を変える

currentUserabout を変えてみましょう。

about: 'FlutterでUIづくりを学んでいます。',

プロフィールカードと編集画面の初期値が変わります。

手を動かす練習4:Saveボタンの文言を変える

Save Changes を日本語にしてみましょう。

child: const Text(
  '保存する',
  style: TextStyle(
    fontWeight: FontWeight.bold,
  ),
),

ボタンの文言を変えるだけでも、アプリの印象が変わります。

手を動かす練習5:Dialogの最大幅を変える

次の部分を探してください。

constraints: const BoxConstraints(maxWidth: 560),

これを 640 に変えてみます。

constraints: const BoxConstraints(maxWidth: 640),

Dialogが少し横に広くなります。

PC向けの編集画面では広め、スマホ向けでは狭めにするなど、画面サイズに応じた調整もできます。

よくあるつまずき1:編集しても反映されない

編集してもプロフィールカードが変わらない場合は、次の流れを確認してください。

Dialogで保存
↓
widget.onSave(newProfile)
↓
親WidgetのupdateProfileが呼ばれる
↓
setStateでcurrentUserを更新

親Widget側に次の処理が必要です。

void updateProfile(UserProfile profile) {
  setState(() {
    currentUser = profile;
  });
}

よくあるつまずき2:Controllerをdisposeし忘れる

複数のControllerを使う場合、すべて dispose します。

@override
void dispose() {
  nameController.dispose();
  handleController.dispose();
  statusController.dispose();
  aboutController.dispose();
  avatarUrlController.dispose();
  super.dispose();
}

どれか1つだけ片付けるのではなく、作ったControllerは全部片付けます。

よくあるつまずき3:Dialogが小さい画面で見切れる

編集項目が多い場合、画面の高さに入りきらないことがあります。

そのため、今回のDialogでは SingleChildScrollView を使っています。

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

これにより、小さい画面でもスクロールして操作できます。

よくあるつまずき4:横幅が狭いと崩れる

Dialogの横幅は、ConstrainedBox で最大幅を指定しています。

ConstrainedBox(
  constraints: const BoxConstraints(maxWidth: 560),
  child: ...
)

さらに、insetPadding で画面端との余白を調整しています。

insetPadding: EdgeInsets.symmetric(
  horizontal: compact ? 12 : 24,
  vertical: 24,
),

スマホなど狭い画面では、横余白を小さくしてDialogが入りやすくしています。

よくあるつまずき5:名前を入力してもプレビューが変わらない

表示名を入力してすぐプレビューを変えたい場合は、onChangedsetState を呼びます。

DiscordTextField(
  label: 'DISPLAY NAME',
  controller: nameController,
  onChanged: (_) {
    setState(() {});
  },
)

画像URLも同じです。

DiscordTextField(
  label: 'ICON IMAGE URL',
  controller: avatarUrlController,
  onChanged: (_) {
    setState(() {});
  },
)

setState を呼ぶことで、Dialog内のプレビューが再描画されます。

この節の確認問題

確認問題1

プロフィール編集画面を表示するために使った処理は何ですか。

答え

showDialog です。

確認問題2

複数の入力欄の文字を管理するために使ったものは何ですか。

答え

TextEditingController です。

確認問題3

編集後のプロフィールを親Widgetへ返すために使った関数は何ですか。

答え

onSave です。

確認問題4

親Widgetでプロフィールを更新するために使ったものは何ですか。

答え

setState です。

確認問題5

色やステータスの選択肢を折り返し表示するために使ったWidgetは何ですか。

答え

Wrap です。

確認問題6

編集項目が多いDialogをスクロール可能にするために使ったWidgetは何ですか。

答え

SingleChildScrollView です。

確認問題7

ハンドル名に @ がない場合、自動でつけるために使った処理は何ですか。

答え

handle.startsWith('@') ? handle : '@$handle' です。

この節のまとめ

この節では、Discord風アプリのプロフィール編集用モーダル画面を作りました。

今回できるようになったことは、次の通りです。

表示名を編集する
ハンドル名を編集する
画像URLを編集する
ステータスメッセージを編集する
自己紹介を編集する
アイコン色を選ぶ
オンライン状態を選ぶ
保存してプロフィールカードへ反映する

今回の中心の流れは、次の通りです。

Dialogを開く
↓
TextEditingControllerで入力値を管理する
↓
色や状態をsetStateで選択する
↓
Save Changesを押す
↓
新しいUserProfileを作る
↓
onSaveで親Widgetに渡す
↓
親WidgetがsetStateでcurrentUserを更新する
↓
プロフィールカードが変わる

この節で一番大切なのは、次の考え方です。

編集機能は、入力値を一時的にDialog内で管理し、保存時に新しいデータとして親Widgetへ返すことで作れる。

次の節では、ここまで作ってきたサーバー一覧、チャンネル一覧、チャット画面、プロフィール編集機能を1つのDiscord風アプリとして組み合わせていきます。

教材トップへ戻る