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

【アイコン登録機能】画像URLでプロフィールアイコンを変更する

この節で学ぶこと

前回の 5-10 では、Discord風のプロフィールカードUIを作りました。

ProfileCard
├─ 上部バナー
├─ UserAvatar
├─ ユーザー名
├─ ハンドル名
├─ ABOUT ME
└─ 自己紹介文

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

ここまでのプロフィールカードでは、avatarUrl が空の場合、ユーザー名の頭文字を表示していました。

avatarUrl: ''
↓
FL のような文字アイコンを表示

今回からは、ユーザーがプロフィール編集画面で画像URLを入力できるようにします。

画像URLを入力
↓
保存
↓
プロフィールアイコンが画像に変わる

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

プロフィール編集機能は、TextFieldで入力を受け取り、保存時にUserProfileを作り直してsetStateで反映する。

今回作る機能

今回作る機能は、次の通りです。

プロフィールカードを表示する
↓
編集ボタンを押す
↓
Dialogでプロフィール編集画面を開く
↓
画像URL、ユーザー名、自己紹介を入力する
↓
保存ボタンを押す
↓
UserProfileを更新する
↓
プロフィールカードに反映する

完成すると、アプリをリロードするまでは、編集内容が画面に反映されます。

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

今回の保存
↓
アプリの中だけで一時保存

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

これは、Flutterの状態管理を学ぶための練習です。

今回使う主なWidget

今回使う主なWidgetは、次の通りです。

Widget役割
ProfileEditorDialogプロフィール編集画面
TextField名前・画像URL・自己紹介の入力欄
TextEditingController入力された文字を管理する
Dialog画面の上に編集画面を表示する
ElevatedButton保存ボタン
setState編集後のプロフィールを画面に反映する

新しく重要になるのは、Dialog です。

Dialogとは何か

Dialog は、現在の画面の上に重ねて表示する小さな画面です。

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

Dialog = 画面の上に出る編集・確認用の小さな画面

たとえば、プロフィール編集、削除確認、設定変更などでよく使います。

通常画面
↓
編集ボタンを押す
↓
Dialogが開く
↓
保存またはキャンセル
↓
元の画面に戻る

今回のプロフィール編集画面も、Dialog で作ります。

showDialogとは何か

Dialog を表示するには、showDialog を使います。

showDialog<void>(
  context: context,
  builder: (context) {
    return ProfileEditorDialog(
      initialProfile: currentUser,
      onSave: updateProfile,
    );
  },
);

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

showDialog
↓
画面の上にDialogを表示する処理

builder の中で、表示したいDialogのWidgetを返します。

builder: (context) {
  return ProfileEditorDialog(...);
}

ここでは、ProfileEditorDialog という自作Widgetを表示します。

今回の状態管理の流れ

今回のプロフィール編集では、currentUser という状態を持ちます。

UserProfile currentUser = UserProfile(...);

ユーザーが編集画面で保存すると、新しい UserProfile を作り、currentUser を更新します。

編集前のcurrentUser
↓
Dialogで入力
↓
保存
↓
新しいUserProfileを作る
↓
setStateでcurrentUserを更新
↓
ProfileCardが更新される

この流れは、Flutterアプリでとてもよく使います。

状態を持つ
↓
ユーザー操作で状態を変える
↓
画面が変わる

まずはプロフィール編集の最小コードを作る

最初は、画像URLだけを変更できる簡単なプロフィール編集画面を作ります。

次のコードを main.dart に貼り付けてください。

import 'package:flutter/material.dart';

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

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

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

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 IconRegisterPracticePage extends StatefulWidget {
  const IconRegisterPracticePage({super.key});

  @override
  State<IconRegisterPracticePage> createState() =>
      _IconRegisterPracticePageState();
}

class _IconRegisterPracticePageState extends State<IconRegisterPracticePage> {
  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,
      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),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

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 avatarUrlController;

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

    avatarUrlController = TextEditingController(
      text: widget.initialProfile.avatarUrl,
    );
  }

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

  void save() {
    final newProfile = UserProfile(
      name: widget.initialProfile.name,
      handle: widget.initialProfile.handle,
      status: widget.initialProfile.status,
      about: widget.initialProfile.about,
      avatarColor: widget.initialProfile.avatarColor,
      avatarUrl: avatarUrlController.text.trim(),
      onlineStatus: widget.initialProfile.onlineStatus,
    );

    widget.onSave(newProfile);

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

  @override
  Widget build(BuildContext context) {
    return Dialog(
      backgroundColor: DiscordColors.panel,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(DiscordRadius.lg),
      ),
      child: Container(
        width: 420,
        padding: const EdgeInsets.all(DiscordSpacing.xl),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            const Text(
              'Edit Profile Icon',
              style: TextStyle(
                color: DiscordColors.textPrimary,
                fontSize: 22,
                fontWeight: FontWeight.bold,
              ),
            ),
            const SizedBox(height: DiscordSpacing.lg),
            UserAvatar(
              name: widget.initialProfile.name,
              color: widget.initialProfile.avatarColor,
              imageUrl: avatarUrlController.text,
              status: widget.initialProfile.onlineStatus,
              size: 76,
              borderColor: DiscordColors.panel,
            ),
            const SizedBox(height: DiscordSpacing.lg),
            TextField(
              controller: avatarUrlController,
              onChanged: (_) {
                setState(() {});
              },
              style: const TextStyle(
                color: DiscordColors.textPrimary,
                fontSize: 14,
              ),
              cursorColor: DiscordColors.textPrimary,
              decoration: InputDecoration(
                labelText: 'ICON IMAGE URL',
                labelStyle: const TextStyle(
                  color: DiscordColors.textMuted,
                  fontSize: 12,
                  fontWeight: FontWeight.bold,
                ),
                hintText: 'https://example.com/icon.jpg',
                hintStyle: const TextStyle(
                  color: DiscordColors.textMuted,
                  fontSize: 13,
                ),
                filled: true,
                fillColor: DiscordColors.input,
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(DiscordRadius.md),
                  borderSide: BorderSide.none,
                ),
              ),
            ),
            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,
                  ),
                  child: const Text('Save'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

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.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 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. 編集Dialogが開く
4. ICON IMAGE URLに画像URLを入力する
5. プレビューのアイコンが画像に変わる
6. Saveを押す
7. プロフィールカードのアイコンも画像に変わる

画像URLの例としては、次のような形式を使います。

https://images.unsplash.com/photo-1531746790731-6c087fecd65a?w=120&h=120&fit=crop

URLが正しく読み込めない場合は、文字アイコンに戻ります。

ProfileEditorDialogの構造

ProfileEditorDialog の構造を整理すると、次のようになります。

ProfileEditorDialog
├─ タイトル
├─ UserAvatarのプレビュー
├─ 画像URL入力欄
└─ Cancel / Save ボタン

編集画面では、現在の入力内容をすぐにプレビューに反映しています。

UserAvatar(
  imageUrl: avatarUrlController.text,
)

そして、入力欄の文字が変わったら setState しています。

onChanged: (_) {
  setState(() {});
},

これによって、入力中の画像URLが変わるたびに、アイコンのプレビューも更新されます。

TextEditingControllerを使う理由

画像URL入力欄では、TextEditingController を使っています。

late final TextEditingController avatarUrlController;

TextEditingController を使うと、入力欄に書かれた文字を取り出せます。

avatarUrlController.text

保存時には、この文字を avatarUrl として使います。

avatarUrl: avatarUrlController.text.trim(),

つまり、流れは次の通りです。

TextFieldにURLを入力
↓
avatarUrlController.text に入る
↓
Saveを押す
↓
UserProfile.avatarUrl に入る
↓
UserAvatarが画像を表示

initStateで初期値を入れる

編集画面を開いたとき、現在のプロフィール画像URLが入力欄に入っていると便利です。

そのために、initState で初期値を入れています。

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

  avatarUrlController = TextEditingController(
    text: widget.initialProfile.avatarUrl,
  );
}

これは、次のような意味です。

Dialogを開く
↓
現在のavatarUrlを取得
↓
TextFieldの初期値として入れる

もしすでに画像URLが登録されている場合、編集画面を開いたときにそのURLが表示されます。

widget.initialProfileとは何か

ProfileEditorDialogStatefulWidget です。

State の中から、Widgetに渡された値を使うときは、widget. をつけます。

widget.initialProfile.avatarUrl

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

widget.initialProfile
↓
ProfileEditorDialogに渡された初期プロフィール情報

StatefulWidget では、この widget. という書き方がよく出てきます。

保存処理を分解する

保存処理は、次の部分です。

void save() {
  final newProfile = UserProfile(
    name: widget.initialProfile.name,
    handle: widget.initialProfile.handle,
    status: widget.initialProfile.status,
    about: widget.initialProfile.about,
    avatarColor: widget.initialProfile.avatarColor,
    avatarUrl: avatarUrlController.text.trim(),
    onlineStatus: widget.initialProfile.onlineStatus,
  );

  widget.onSave(newProfile);

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

この処理では、3つのことをしています。

1. 新しいUserProfileを作る
2. onSaveで親Widgetに渡す
3. Dialogを閉じる

ここでは、画像URLだけを変更しています。

そのため、nameabout などは、もとの値をそのまま使っています。

name: widget.initialProfile.name,
about: widget.initialProfile.about,

一方で、avatarUrl だけは、入力欄の文字を使っています。

avatarUrl: avatarUrlController.text.trim(),

onSaveとは何か

ProfileEditorDialog は、保存した結果を親Widgetへ返す必要があります。

そのために、onSave を使います。

final ValueChanged<UserProfile> onSave;

ValueChanged<UserProfile> は、UserProfile を受け取る関数です。

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

ValueChanged<UserProfile>
↓
UserProfileを親に渡すための関数

保存時には、次のように呼び出します。

widget.onSave(newProfile);

これにより、親Widgetの updateProfile が実行されます。

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

保存処理の最後に、次のコードがあります。

Navigator.of(context).pop();

これは、今開いているDialogを閉じる処理です。

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

Navigator.of(context).pop()
↓
今開いている画面やDialogを閉じる

Cancelボタンでも同じように使っています。

onPressed: () {
  Navigator.of(context).pop();
}

つまり、保存するときもキャンセルするときも、最後はDialogを閉じます。

画像URLが空の場合の動き

画像URLを空にして保存すると、avatarUrl は空文字になります。

avatarUrl: ''

この場合、UserAvatar は画像を表示せず、頭文字を表示します。

final hasImage = imageUrl.trim().isNotEmpty;

hasImagefalse になるため、次の表示になります。

画像URLなし
↓
頭文字アイコン

つまり、この機能は「画像登録」だけでなく、「画像を外す」ことにも対応しています。

画像URLが間違っている場合の動き

画像URLが間違っている場合、Image.network は画像を読み込めません。

そのときは errorBuilder が実行されます。

errorBuilder: (context, error, stackTrace) {
  return Center(
    child: Text(initials),
  );
},

これにより、アプリが壊れずに文字アイコンへ戻ります。

画像URLが間違っている
↓
画像読み込み失敗
↓
errorBuilder
↓
頭文字アイコンを表示

ユーザーが自由にURLを入力する機能では、このような失敗時の表示がとても大切です。

名前・自己紹介も編集できるようにする

ここからは、発展として、画像URLだけでなく、名前・ハンドル・自己紹介も編集できるようにします。

編集項目を増やすには、Controllerを増やします。

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

initState で初期値を入れます。

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

  final profile = widget.initialProfile;

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

そして、dispose で片付けます。

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

Controllerを作ったら、必ず片付けることを忘れないようにします。

入力欄を共通Widgetにする

入力欄が増えてくると、毎回 TextField を書くのが大変になります。

そこで、DiscordTextField という共通Widgetを作ります。

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

これで、入力欄を次のように簡単に作れます。

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

共通Widgetにするメリット

入力欄を共通Widgetにすると、次のメリットがあります。

同じデザインを何度も書かなくてよい
↓
コードが短くなる

見た目を変更したいとき
↓
DiscordTextFieldだけ直せばよい

入力欄のデザインが統一される
↓
Discord風UIとしてまとまりやすい

これは、アプリ開発でとても重要な考え方です。

同じ形が何度も出てくる
↓
Widget化する

発展版:複数項目を編集できるProfileEditorDialog

ProfileEditorDialog を、次のように発展させます。

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 aboutController;
  late final TextEditingController avatarUrlController;

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

    final profile = widget.initialProfile;

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

  @override
  void dispose() {
    nameController.dispose();
    handleController.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
        ? widget.initialProfile.handle
        : handleController.text.trim();

    final about = aboutController.text.trim().isEmpty
        ? '自己紹介はまだありません。'
        : aboutController.text.trim();

    final newProfile = UserProfile(
      name: name,
      handle: handle.startsWith('@') ? handle : '@$handle',
      status: widget.initialProfile.status,
      about: about,
      avatarColor: widget.initialProfile.avatarColor,
      avatarUrl: avatarUrlController.text.trim(),
      onlineStatus: widget.initialProfile.onlineStatus,
    );

    widget.onSave(newProfile);

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

  @override
  Widget build(BuildContext context) {
    return Dialog(
      backgroundColor: Colors.transparent,
      child: ConstrainedBox(
        constraints: const BoxConstraints(maxWidth: 520),
        child: Container(
          decoration: BoxDecoration(
            color: DiscordColors.panel,
            borderRadius: BorderRadius.circular(DiscordRadius.lg),
          ),
          clipBehavior: Clip.antiAlias,
          child: SingleChildScrollView(
            child: Column(
              children: [
                Container(
                  height: 96,
                  color: DiscordColors.blurple,
                  padding: const EdgeInsets.all(DiscordSpacing.lg),
                  child: Row(
                    children: [
                      const Expanded(
                        child: Text(
                          'Edit Profile',
                          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: widget.initialProfile.avatarColor,
                          imageUrl: avatarUrlController.text,
                          status: widget.initialProfile.onlineStatus,
                          size: 76,
                          borderColor: DiscordColors.panel,
                        ),
                      ),
                      Transform.translate(
                        offset: const Offset(0, -18),
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            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: 'ABOUT ME',
                              controller: aboutController,
                              maxLines: 3,
                            ),
                            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,
                                    ),
                                  ),
                                ),
                              ],
                            ),
                          ],
                        ),
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

SingleChildScrollViewとは何か

発展版のDialogでは、SingleChildScrollView を使っています。

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

これは、中身が画面に入りきらないときにスクロールできるようにするWidgetです。

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

SingleChildScrollView
↓
中身が長くなったときにスクロールできるようにするWidget

プロフィール編集画面は、スマホや小さい画面では縦に入りきらないことがあります。

そのため、スクロールできるようにしておくと安全です。

Transform.translateとは何か

発展版では、アイコンを少し上にずらすために Transform.translate を使っています。

Transform.translate(
  offset: const Offset(0, -34),
  child: UserAvatar(...),
)

これは、Widgetの表示位置を少し移動させるためのWidgetです。

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

Transform.translate
↓
Widgetを見た目上少しずらす

Offset(0, -34) は、上方向に34px移動するという意味です。

x方向: 0
y方向: -34
↓
上に34pxずらす

プロフィール編集画面でも、Discord風にアイコンをバナーに重ねるために使っています。

handle.startsWith(’@’) の意味

保存処理では、ハンドル名の先頭に @ があるか確認しています。

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

これは、次のような意味です。

handleが@で始まる
↓
そのまま使う

handleが@で始まらない
↓
先頭に@をつける

たとえば、ユーザーが flutter_dev と入力した場合でも、保存時には @flutter_dev になります。

flutter_dev
↓
@flutter_dev

このように、入力された値をそのまま使うのではなく、アプリ側で整えることも大切です。

手を動かす練習1:画像URLを登録する

編集画面を開いて、画像URLを入力してみましょう。

https://images.unsplash.com/photo-1531746790731-6c087fecd65a?w=120&h=120&fit=crop

入力すると、Dialog内のプレビューアイコンが画像に変わります。

保存すると、プロフィールカードにも反映されます。

手を動かす練習2:画像URLを空にする

一度登録した画像URLを消して、空の状態で保存してみましょう。

空欄で保存

すると、画像アイコンではなく、ユーザー名の頭文字アイコンに戻ります。

画像あり
↓
画像表示

画像なし
↓
頭文字表示

手を動かす練習3:ユーザー名を変える

発展版を使っている場合、DISPLAY NAME を変更してみましょう。

flutter_dev
↓
design_master

保存すると、プロフィールカードの名前が変わります。

画像URLが空の場合は、アイコンの頭文字も変わります。

手を動かす練習4:自己紹介を変える

ABOUT ME に次のような文章を入れてみましょう。

FlutterでDiscord風UIを作りながら、画面分解と状態管理を学んでいます。

保存すると、プロフィールカードの自己紹介文が更新されます。

手を動かす練習5:壊れたURLを入れてみる

画像URL欄に、存在しないURLを入れてみましょう。

https://example.com/not-found-image.jpg

画像は読み込めませんが、アプリは壊れず、頭文字アイコンに戻ります。

これは、Image.networkerrorBuilder があるためです。

よくあるつまずき1:画像URLを入れても表示されない

画像が表示されない場合、次の可能性があります。

URLが間違っている
画像の直リンクではない
画像提供元が読み込みを制限している
ネットワーク環境の問題

Image.network は、直接画像として読み込めるURLが必要です。

WebページのURLではなく、画像ファイルとして読み込めるURLを使う必要があります。

よくあるつまずき2:Dialogを閉じられない

Dialogを閉じるには、次の処理を使います。

Navigator.of(context).pop();

Cancelボタンにも、Save後にも入れておきます。

Cancel
↓
閉じる

Save
↓
保存してから閉じる

よくあるつまずき3:保存しても画面が変わらない

保存してもプロフィールカードが変わらない場合は、親Widgetで setState しているか確認してください。

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

Dialog内で新しいプロフィールを作っても、親の状態を更新しなければ、画面は変わりません。

Dialogで編集
↓
onSaveで親に渡す
↓
親がsetState
↓
画面が更新される

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

TextEditingController を作ったら、dispose で片付けます。

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

複数のControllerを作った場合は、すべて片付けます。

nameController.dispose();
handleController.dispose();
aboutController.dispose();
avatarUrlController.dispose();

よくあるつまずき5:プレビューがすぐ更新されない

入力中にプレビューを更新するには、onChangedsetState を呼びます。

onChanged: (_) {
  setState(() {});
},

これがないと、入力欄の値は変わっていても、プレビュー表示が更新されない場合があります。

入力する
↓
setState
↓
UserAvatarが再描画
↓
プレビューが変わる

この節の確認問題

確認問題1

画像URLを入力するために使ったWidgetは何ですか。

答え

TextField です。

確認問題2

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

答え

TextEditingController です。

確認問題3

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

答え

showDialog です。

確認問題4

Dialogを閉じるために使った処理は何ですか。

答え

Navigator.of(context).pop() です。

確認問題5

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

答え

onSave です。

確認問題6

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

答え

setState です。

確認問題7

画像URLが空、または読み込み失敗した場合、アイコンには何を表示しますか。

答え

ユーザー名から作った頭文字を表示します。

この節のまとめ

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

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

プロフィール編集ボタンを押す
↓
showDialogで編集画面を開く
↓
TextFieldで画像URLを入力する
↓
TextEditingControllerで文字を取得する
↓
Saveで新しいUserProfileを作る
↓
onSaveで親Widgetに渡す
↓
setStateでcurrentUserを更新する
↓
ProfileCardのアイコンが変わる

今回作った主な部品は、次の通りです。

ProfileEditorDialog
DiscordTextField
UserAvatar
ProfileCard

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

編集画面では、入力欄の値をControllerで管理し、保存時に新しいデータを作って親の状態を更新する。

次の節では、ここまで作ったサーバー一覧、チャンネル一覧、チャット画面、プロフィールカードを組み合わせ、Discord風アプリ全体の画面構成に近づけていきます。

教材トップへ戻る