🍃

シンプルなスマホUIを実装する(Flutter):その2

に公開

概要

その1の続き。

値を取得して表示

続いて、入力された値を取得し表示してみます。表示には手軽に使えるSnackBarを利用してみます。
各入力に対応するTextEditingControllerを作成し、マッピングすることで値が取得できます。

contact.dart
import 'package:flutter/material.dart';

// dart format off

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

  @override
  State<Contact> createState() => _ContactState();

}

class _ContactState extends State<Contact> {

+   // 各入力値を保持する変数宣言
+   final _titleController = TextEditingController();
+   final _emailController = TextEditingController();
+   final _messageController = TextEditingController();

+   // Controllerはdisposeとセットで定義
+   @override
+   void dispose(){
+     _titleController.dispose();
+     _emailController.dispose();
+     _messageController.dispose();
+     super.dispose();
+   }
  
  @override
  Widget build(BuildContext context) {
    // 全体をColumnでレイアウト
    return Column(
      children: [

        //hero
        Container(
          color: Color(0xFFAAAAAA),
          width: double.infinity,
          height: 120,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              SizedBox(height: 40), // spacer
              Text("お問い合わせフォーム",
                style: TextStyle(color: Colors.white, fontSize: 18)),
              Text("お気軽にお問い合わせください",
                style: TextStyle(color: Colors.white))
            ],
          ),
        ),

        // form
        Padding(
          padding: EdgeInsets.all(30),
          child: Form(
            child: Column(
              children: [

                // title
                SizedBox(height: 10),
                TextFormField(
                  decoration: InputDecoration(
                    labelText: "お問い合わせタイトル",
                    border: OutlineInputBorder(),
                  ),
+                   controller: _titleController,
                ),

                // email
                SizedBox(height: 30),
                TextFormField(
                  decoration: InputDecoration(
                    labelText: "Email",
                    border: OutlineInputBorder(),
                  ),
+                   controller: _emailController,
                ),

                // message
                SizedBox(height: 30),
                TextFormField(
                  decoration: InputDecoration(
                    labelText: "お問い合わせ内容",
                    border: OutlineInputBorder(),
                  ),
                  minLines: 3,
                  maxLines: 5,
                  keyboardType: TextInputType.multiline,
+                   controller: _messageController,
                ),

                // button
                SizedBox(height: 30),
                SizedBox(
                  width: double.infinity,
                  child: ElevatedButton(
                    style: ElevatedButton.styleFrom(
                      backgroundColor: Color(0xFF333333),
                      foregroundColor: Color(0xFFFFFFFF),
                    ),
                    onPressed: (){
+                       ScaffoldMessenger.of(context).showSnackBar(
+                         SnackBar(
+                           content: Text(
+                             "title: ${_titleController.text}\n"
+                             "email: ${_emailController.text}\n"
+                             "message: ${_messageController.text}"
+                           ),
+                         ),
+                       );
                    },
                    child: Text("送信")),
                ),
              ],
            ),
          ),
        ),
      ],
    );
  }
}

記述が完了したら動作確認してください。

バリデーション

値が取得できるようになったのでバリデーション機能を追加します。
TextFormFieldにvalidatorを追加することで対応できます。

contact.dart
import 'package:flutter/material.dart';

// dart format off

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

  @override
  State<Contact> createState() => _ContactState();

}

class _ContactState extends State<Contact> {

  // 各入力値を保持する変数宣言
  final _titleController = TextEditingController();
  final _emailController = TextEditingController();
  final _messageController = TextEditingController();

+   // Formを特定するkeyを生成(訳があって、GlobalKeyとして生成)
+   GlobalKey<FormState> _formKey = GlobalKey<FormState>();

  // Controllerはdisposeとセットで定義
  @override
  void dispose(){
    _titleController.dispose();
    _emailController.dispose();
    _messageController.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    // 全体をColumnでレイアウト
    return Column(
      children: [

        //hero
        Container(
          color: Color(0xFFAAAAAA),
          width: double.infinity,
          height: 120,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              SizedBox(height: 40), // spacer
              Text("お問い合わせフォーム",
                style: TextStyle(color: Colors.white, fontSize: 18)),
              Text("お気軽にお問い合わせください",
                style: TextStyle(color: Colors.white))
            ],
          ),
        ),

        // form
        Padding(
          padding: EdgeInsets.all(30),
          child: Form(
+             key: _formKey,
            child: Column(
              children: [

                // title
                SizedBox(height: 10),
                TextFormField(
                  decoration: InputDecoration(
                    labelText: "お問い合わせタイトル",
                    border: OutlineInputBorder(),
                  ),
                  controller: _titleController,
+                   validator: (value){
+                     if(value == null || value.isEmpty){
+                       return "お問い合わせタイトルは必須です。";
+                     }
+                     if(value.length > 10){
+                       return "10文字以下で入力してください。";
+                     }
+                     return null;
+                   },
+                   autovalidateMode: AutovalidateMode.onUserInteraction, // 入力時チェックに必要
                ),

                // email
                SizedBox(height: 30),
                TextFormField(
                  decoration: InputDecoration(
                    labelText: "Email",
                    border: OutlineInputBorder(),
                  ),
                  controller: _emailController,
                  validator: (value){
                    if(value == null || value.isEmpty){
                      return "Emailは必須です。";
                    }
                    final emailRegExp = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
                    if(!emailRegExp.hasMatch(value)){
                      return "Emailを正しく入力してください。";
                    }
                    return null;
                  },
                  autovalidateMode: AutovalidateMode.onUserInteraction,
                ),

                // message
                SizedBox(height: 30),
                TextFormField(
                  decoration: InputDecoration(
                    labelText: "お問い合わせ内容",
                    border: OutlineInputBorder(),
                  ),
                  minLines: 3,
                  maxLines: 5,
                  keyboardType: TextInputType.multiline,
                  controller: _messageController,
+                   validator: (value){
+                     if(value == null || value.isEmpty){
+                       return "お問い合わせ内容は必須です。";
+                     }
+                     if(value.length > 20){
+                       return "20文字以下で入力してください。";
+                     }
+                     return null;
+                   },
+                   autovalidateMode: AutovalidateMode.onUserInteraction,
                ),

                // button
                SizedBox(height: 30),
                SizedBox(
                  width: double.infinity,
                  child: ElevatedButton(
                    style: ElevatedButton.styleFrom(
                      backgroundColor: Color(0xFF333333),
                      foregroundColor: Color(0xFFFFFFFF),
                    ),
                    onPressed: (){
+                       if(_formKey.currentState!.validate()){
                        ScaffoldMessenger.of(context).showSnackBar(
                          SnackBar(
                            content: Text(
                              "title: ${_titleController.text}\n"
                              "email: ${_emailController.text}\n"
                              "message: ${_messageController.text}"
                            ),
                          ),
                        );
+                       }
                    },
                    child: Text("送信")),
                ),
              ],
            ),
          ),
        ),
      ],
    );
  }
}

動作を確認してください。

_formKeyをfinalにしろという警告がでるかもですが、後で解消するのでとりあえず無視します。

API連携

では、アプリ機能の肝であるAPI連携を行います。
連携するAPIは「シンプルなWebAPIを実装する(Node.js)」のものです。

通信につかうhttpとJSON処理に利用するconvertをimportしてから記述を追加します。

contact.dart
import 'package:flutter/material.dart';
+ import 'package:http/http.dart' as http;
+ import 'dart:convert';

// dart format off

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

  @override
  State<Contact> createState() => _ContactState();

}

class _ContactState extends State<Contact> {

  // 各入力値を保持する変数宣言
  final _titleController = TextEditingController();
  final _emailController = TextEditingController();
  final _messageController = TextEditingController();

  // Formを特定するkeyを生成(訳があって、GlobalKeyとして生成)
  GlobalKey<FormState> _formKey = GlobalKey<FormState>();

  // Controllerはdisposeとセットで定義
  @override
  void dispose(){
    _titleController.dispose();
    _emailController.dispose();
    _messageController.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    // 全体をColumnでレイアウト
    return Column(
      children: [

        //hero
        Container(
          color: Color(0xFFAAAAAA),
          width: double.infinity,
          height: 120,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              SizedBox(height: 40), // spacer
              Text("お問い合わせフォーム",
                style: TextStyle(color: Colors.white, fontSize: 18)),
              Text("お気軽にお問い合わせください",
                style: TextStyle(color: Colors.white))
            ],
          ),
        ),

        // form
        Padding(
          padding: EdgeInsets.all(30),
          child: Form(
            key: _formKey,
            child: Column(
              children: [

                // title
                SizedBox(height: 10),
                TextFormField(
                  decoration: InputDecoration(
                    labelText: "お問い合わせタイトル",
                    border: OutlineInputBorder(),
                  ),
                  controller: _titleController,
                  validator: (value){
                    if(value == null || value.isEmpty){
                      return "お問い合わせタイトルは必須です。";
                    }
                    if(value.length > 10){
                      return "10文字以下で入力してください。";
                    }
                    return null;
                  },
                  autovalidateMode: AutovalidateMode.onUserInteraction, // 入力時チェックに必要
                ),

                // email
                SizedBox(height: 30),
                TextFormField(
                  decoration: InputDecoration(
                    labelText: "Email",
                    border: OutlineInputBorder(),
                  ),
                  controller: _emailController,
                  validator: (value){
                    if(value == null || value.isEmpty){
                      return "Emailは必須です。";
                    }
                    final emailRegExp = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
                    if(!emailRegExp.hasMatch(value)){
                      return "Emailを正しく入力してください。";
                    }
                    return null;
                  },
                  autovalidateMode: AutovalidateMode.onUserInteraction,
                ),

                // message
                SizedBox(height: 30),
                TextFormField(
                  decoration: InputDecoration(
                    labelText: "お問い合わせ内容",
                    border: OutlineInputBorder(),
                  ),
                  minLines: 3,
                  maxLines: 5,
                  keyboardType: TextInputType.multiline,
                  controller: _messageController,
                  validator: (value){
                    if(value == null || value.isEmpty){
                      return "お問い合わせ内容は必須です。";
                    }
                    if(value.length > 20){
                      return "20文字以下で入力してください。";
                    }
                    return null;
                  },
                  autovalidateMode: AutovalidateMode.onUserInteraction,
                ),

                // button
                SizedBox(height: 30),
                SizedBox(
                  width: double.infinity,
                  child: ElevatedButton(
                    style: ElevatedButton.styleFrom(
                      backgroundColor: Color(0xFF333333),
                      foregroundColor: Color(0xFFFFFFFF),
                    ),
                    onPressed: () async {
                      if(_formKey.currentState!.validate()){
+                         final messenger = ScaffoldMessenger.of(context);
+                         final result = await sendDataForm(
+                           _titleController.text,
+                           _emailController.text,
+                           _messageController.text,
+                         );
+                         if(!mounted) return; // ページ移動されてないか確認
+                         messenger.showSnackBar(
+                           SnackBar(content: Text(result ?? "")),
+                         );
                      }
                    },
                    child: Text("送信")),
                ),
              ],
            ),
          ),
        ),
      ],
    );
  }
}

+ // API連携
+ // sendDataForm定義
+ Future<String?> sendDataForm(String title, String email, String message) async {
+ 
+   final client = http.Client();
+   final apiUrl = Uri.parse("http://localhost:3333/contacts");
+ 
+   try{
+ 
+     final response = await client.post(
+       apiUrl,
+       headers: {"Content-Type": "application/json"},
+       body: jsonEncode({
+         "title": title,
+         "email": email,
+         "message": message,
+       }),
+     );
+ 
+     final json = jsonDecode(response.body) as Map<String, dynamic>;
+     return json['message'] as String?;
+ 
+   }catch(e){
+     return jsonEncode({"status": "error", "message": e.toString()});
+   }finally{
+     client.close();
+   }
+ 
+ }

記述が完了したら動作確認してください。

ボタンの2度押し防止や送信後のリセットなど

では最後にボタンの2度押し防止や送信後の値リセット処理を実装します。

contact.dart
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';

// dart format off

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

  @override
  State<Contact> createState() => _ContactState();

}

class _ContactState extends State<Contact> {

  // 各入力値を保持する変数宣言
  final _titleController = TextEditingController();
  final _emailController = TextEditingController();
  final _messageController = TextEditingController();

  // Formを特定するkeyを生成(訳があって、GlobalKeyとして生成)
  GlobalKey<FormState> _formKey = GlobalKey<FormState>();

+   // 送信中チェック用変数宣言
+   bool isSending = false;

  // Controllerはdisposeとセットで定義
  @override
  void dispose(){
    _titleController.dispose();
    _emailController.dispose();
    _messageController.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    // 全体をColumnでレイアウト
    return Column(
      children: [

        //hero
        Container(
          color: Color(0xFFAAAAAA),
          width: double.infinity,
          height: 120,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              SizedBox(height: 40), // spacer
              Text("お問い合わせフォーム",
                style: TextStyle(color: Colors.white, fontSize: 18)),
              Text("お気軽にお問い合わせください",
                style: TextStyle(color: Colors.white))
            ],
          ),
        ),

        // form
        Padding(
          padding: EdgeInsets.all(30),
          child: Form(
            key: _formKey,
            child: Column(
              children: [

                // title
                SizedBox(height: 10),
                TextFormField(
                  decoration: InputDecoration(
                    labelText: "お問い合わせタイトル",
                    border: OutlineInputBorder(),
                  ),
                  controller: _titleController,
                  validator: (value){
                    if(value == null || value.isEmpty){
                      return "お問い合わせタイトルは必須です。";
                    }
                    if(value.length > 10){
                      return "10文字以下で入力してください。";
                    }
                    return null;
                  },
                  autovalidateMode: AutovalidateMode.onUserInteraction, // 入力時チェックに必要
                ),

                // email
                SizedBox(height: 30),
                TextFormField(
                  decoration: InputDecoration(
                    labelText: "Email",
                    border: OutlineInputBorder(),
                  ),
                  controller: _emailController,
                  validator: (value){
                    if(value == null || value.isEmpty){
                      return "Emailは必須です。";
                    }
                    final emailRegExp = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
                    if(!emailRegExp.hasMatch(value)){
                      return "Emailを正しく入力してください。";
                    }
                    return null;
                  },
                  autovalidateMode: AutovalidateMode.onUserInteraction,
                ),

                // message
                SizedBox(height: 30),
                TextFormField(
                  decoration: InputDecoration(
                    labelText: "お問い合わせ内容",
                    border: OutlineInputBorder(),
                  ),
                  minLines: 3,
                  maxLines: 5,
                  keyboardType: TextInputType.multiline,
                  controller: _messageController,
                  validator: (value){
                    if(value == null || value.isEmpty){
                      return "お問い合わせ内容は必須です。";
                    }
                    if(value.length > 20){
                      return "20文字以下で入力してください。";
                    }
                    return null;
                  },
                  autovalidateMode: AutovalidateMode.onUserInteraction,
                ),

                // button
                SizedBox(height: 30),
                SizedBox(
                  width: double.infinity,
                  child: ElevatedButton(
                    style: ElevatedButton.styleFrom(
                      backgroundColor: Color(0xFF333333),
                      foregroundColor: Color(0xFFFFFFFF),
                    ),
+                     onPressed: isSending ? null : () async {

+                       setState(() {
+                         isSending = true;
+                       });

                      if(_formKey.currentState!.validate()){
	                    
                        final messenger = ScaffoldMessenger.of(context);
                        final result = await sendDataForm(
                          _titleController.text,
                          _emailController.text,
                          _messageController.text,
                        );
                        if(!mounted) return; // ページ移動されてないか確認
                        messenger.showSnackBar(
                          SnackBar(content: Text(result ?? "")),
                        );

+                         // 値・バリデーション状態のリセット
+                         _titleController.clear();
+                         _emailController.clear();
+                         _messageController.clear();

+                         // formごと入れ替える
+                         _formKey = GlobalKey<FormState>();
                        
                      }

+                       setState(() {
+                         isSending = false;
+                       });

                    },
+                     child: Text(isSending ? "送信中..." : "送信")),
                ),
              ],
            ),
          ),
        ),
      ],
    );
  }
}

// API連携

// sendDataForm定義
Future<String?> sendDataForm(String title, String email, String message) async {

  final client = http.Client();
  final apiUrl = Uri.parse("http://localhost:3333/contacts");

  try{

    final response = await client.post(
      apiUrl,
      headers: {"Content-Type": "application/json"},
      body: jsonEncode({
        "title": title,
        "email": email,
        "message": message,
      }),
    );

    final json = jsonDecode(response.body) as Map<String, dynamic>;
    return json['message'] as String?;

  }catch(e){
    return jsonEncode({"status": "error", "message": e.toString()});
  }finally{
    client.close();
  }

}

記述が完了したら動作確認してください。
実装は以上となります。お疲れ様でした。

Android対応

ReactNative(expo)と同様FlutterでもAndroidに特化した対応が必要となりますが、ここではひとまず割愛します。
AIに対応を依頼すると直ぐにしてくれるはずです。

時間のあるときに追記するかも。


参考課題

基本確認

  • Flutterとは何ですか?
  • Flutterを採用するメリット・デメリットは何ですか?

技術・ツール関連

  • ReactNativeと何が違いますか?

その他

PM関連

  • WebUIや他のアプリ実装と機能は同じですが要件定義、設計上のどこに影響しますか?
  • Flutterで開発することは工数や費用にどう影響しますか?

AI関連

  • AIに指示し、本モバイルUI(アプリ)と同じものを実装してください
    • できれば2回繰り返してください

デプロイ

  • とりあえず実機確認したいのですが、どうしたらいいですか?
  • 開発したアプリを公開したいです。どうしたらいいですか?
    • Android
    • iOS
bluecodeテックブログ

Discussion