Когда вы создаете различные формы (например: регистрации или входа) на Flutter, вы не заморачиваетесь с кастомизацией компонентов, потому что вы можете изменить любое поле формы под свой стиль.

Помимо кастомизации, Flutter предоставляет возможность обработки ошибок и валидации полей формы.

И сегодня мы постараемся разобраться с этой темой на небольшом примере.

Ну что ж, погнали!

Наш план
  • Часть 1 - введение в разработку, первое приложение, понятие состояния;

  • Часть 2 - файл pubspec.yaml и использование flutter в командной строке;

  • Часть 3 - BottomNavigationBar и Navigator;

  • Часть 4 - MVC. Мы будем использовать именно этот паттерн, как один из самых простых;

  • Часть 5 - http пакет. Создание Repository класса, первые запросы, вывод списка постов;

  • Часть 6 (текущая статья) - работа с формами, текстовые поля и создание поста.

  • Часть 7 - работа с картинками, вывод картинок в виде сетки, получение картинок из сети, добавление своих в приложение;

  • Часть 8 - создание своей темы, добавление кастомных шрифтов и анимации;

  • Часть 9 - немного о тестировании;

Создание формы: добавление поста

Для начала добавим на нашу страницу HomePage кнопку по которой мы будем добавлять новый пост:

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: Text("Post List Page"),
    ),
    body: _buildContent(),
    // в первой части мы уже рассматривали FloatingActionButton
    floatingActionButton: FloatingActionButton(
      child: Icon(Icons.add),
      onPressed: () {

      },
    ),
  );
}

Далее создадим новую страницу в файле post_add_page.dart:


import 'package:flutter/material.dart';

class PostDetailPage extends StatefulWidget {
  @override
  _PostDetailPageState createState() => _PostDetailPageState();
}

class _PostDetailPageState extends State<PostDetailPage> {
  
  // TextEditingController'ы позволят нам получить текст из полей формы
  final TextEditingController titleController = TextEditingController();
  final TextEditingController contentController = TextEditingController();
  
  // _formKey пригодится нам для валидации
  final _formKey = GlobalKey<FormState>();
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Post Add Page"),
        actions: [
          // пункт меню в AppBar
          IconButton(
            icon: Icon(Icons.check),
            onPressed: () {
              // сначала запускаем валидацию формы
              if (_formKey.currentState!.validate()) {
                // здесь мы будем делать запроc на сервер
              }
            },
          )
        ],
      ),
      body: Padding(
        padding: EdgeInsets.all(15),
        child: _buildContent(),
      ),
    );
  }
  Widget _buildContent() {
    // построение формы
    return Form(
      key: _formKey,
      // у нас будет два поля
      child: Column(
        children: [
          // поля для ввода заголовка
          TextFormField(
            // указываем для поля границу,
            // иконку и подсказку (hint)
            decoration: InputDecoration(
                border: OutlineInputBorder(),
                prefixIcon: Icon(Icons.face),
                hintText: "Заголовок"
            ),
            // не забываем указать TextEditingController
            controller: titleController,
            // параметр validator - функция которая,
            // должна возвращать null при успешной проверки
            // или строку при неудачной
            validator: (value) {
              // здесь мы для наглядности добавили 2 проверки
              if (value == null || value.isEmpty) {
                return "Заголовок пустой";
              }
              if (value.length < 3) {
                return "Заголовок должен быть не короче 3 символов";
              }
              return null;
            },
          ),
          // небольшой отступ между полями
          SizedBox(height: 10),
          // Expanded означает, что мы должны
          // расширить наше поле на все доступное пространство
          Expanded(
            child: TextFormField(
              // maxLines: null и expands: true 
              // указаны для расширения поля на все доступное пространство
              maxLines: null,
              expands: true,
              textAlignVertical: TextAlignVertical.top,
              decoration: InputDecoration(
                  border: OutlineInputBorder(),
                  hintText: "Содержание",
              ),
              // не забываем указать TextEditingController
              controller: contentController,
              // также добавляем проверку поля
              validator: (value) {
                if (value == null || value.isEmpty) {
                  return "Содержание пустое";
                }
                return null;
              },
            ),
          )
        ],
      ),
    );
  }
}

Не забудьте добавить переход на страницу формы:

floatingActionButton: FloatingActionButton(
   child: Icon(Icons.add),
   onPressed: () {
      Navigator.push(context, MaterialPageRoute(
         builder: (context) => PostDetailPage()
      ));
   },
),

Запускаем и нажимаем на кнопку:

Вуаля! Форма работает.

Небольшая заметка

У новичков могут возникнуть проблемы даже с готовым кодом. И это не издевательство, такое бывает.

Поэтому для 100%-ной работы коды постарайтесь использовать схожие версии Flutter и Dart с моими:

  • Flutter 2.0.6

  • Dart SDK version: 2.12.3

Также в комментах я обратил внимание на null safety. Это очень важно, я позабыл об этом и это мой косяк.

Я уже добавил в приложение поддержку null safety. Вы наверно обратили внимание на восклицательный знак:

// ! указывает на то, что мы 100% уверены
// что currentState не содержит null значение
_formKey.currentState!.validate()

О null safety и о её поддержи в Dart можно сделать целый цикл статей, а возможно и написать целую книгу.

Мы задерживаться не будем и переходим к созданию POST запроса.

POST запрос для добавления данных на сервер

POST, как уже было отмечено, является одним из HTTP методов и служит для добавления новых данных на сервер.

Для начала добавим модель для нашего результата и изменим немного класс Post:

class Post {
  // все поля являются private
  // это сделано для инкапсуляции данных
  final int? _userId;
  final int? _id;
  final String? _title;
  final String? _body;

  // создаем getters для наших полей
  // дабы только мы могли читать их
  int? get userId => _userId;
  int? get id => _id;
  String? get title => _title;
  String? get body => _body;

  // добавим новый конструктор для поста
  Post(this._userId, this._id, this._title, this._body);

  // toJson() превращает Post в строку JSON
  String toJson() {
    return json.encode({
      "title": _title,
      "content": _body
    });
  }

  // Dart позволяет создавать конструкторы с разными именами
  // В данном случае Post.fromJson(json) - это конструктор
  // здесь мы принимаем объект поста и получаем его поля
  // обратите внимание, что dynamic переменная
  // может иметь разные типы: String, int, double и т.д.
  Post.fromJson(Map<String, dynamic> json) :
    this._userId = json["userId"],
    this._id = json["id"],
    this._title = json["title"],
    this._body = json["body"];
}

// у нас будут только два состояния
abstract class PostAdd {}

// успешное добавление
class PostAddSuccess extends PostAdd {}
// ошибка
class PostAddFailure extends PostAdd {}

Затем создадим новый метод в нашем Repository:

// добавление поста на сервер
Future<PostAdd> addPost(Post post) async {
  final url = Uri.parse("$SERVER/posts");
  // делаем POST запрос, в качестве тела
  // указываем JSON строку нового поста
  final response = await http.post(url, body: post.toJson());
  // если пост был успешно добавлен
  if (response.statusCode == 201) {
    // говорим, что все ок
    return PostAddSuccess();
  } else {
    // иначе ошибка
    return PostAddFailure();
  }
}

Далее добавим немного кода в PostController:

// добавление поста
// функция addPost будет принимать callback,
// через который мы будет получать результат
void addPost(Post post, void Function(PostAdd) callback) async {
  try {
    final result = await repo.addPost(post);
    // сервер вернул результат
    callback(result);
  } catch (error) {
    // произошла ошибка
    callback(PostAddFailure());
  }
}

Ну что ж пора нам вернуться к нашему представлению PostAddPage:

class PostDetailPage extends StatefulWidget {

  @override
  _PostDetailPageState createState() => _PostDetailPageState();
}

// не забываем поменять на StateMVC
class _PostDetailPageState extends StateMVC {

  // _controller может быть null
  PostController? _controller;

  // получаем PostController
  _PostDetailPageState() : super(PostController()) {
    _controller = controller as PostController;
  }

  // TextEditingController'ы позволят нам получить текст из полей формы
  final TextEditingController titleController = TextEditingController();
  final TextEditingController contentController = TextEditingController();

  // _formKey нужен для валидации формы
  final _formKey = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Post Add Page"),
        actions: [
          // пункт меню в AppBar
          IconButton(
            icon: Icon(Icons.check),
            onPressed: () {
              // сначала запускаем валидацию формы
              if (_formKey.currentState!.validate()) {
                // создаем пост
                // получаем текст через TextEditingController'ы
                final post = Post(
                  -1, -1, titleController.text, contentController.text
                );
                // добавляем пост
                _controller!.addPost(post, (status) {
                  if (status is PostAddSuccess) {
                    // если все успешно то возвращаемя
                    // на предыдущую страницу и возвращаем
                    // результат
                    Navigator.pop(context, status);
                  } else {
                    // в противном случае сообщаем об ошибке
                    // SnackBar - всплывающее сообщение
                    ScaffoldMessenger.of(context).showSnackBar(
                      SnackBar(content: Text("Произошла ошибка при добавлении поста"))
                    );
                  }
                });
              }
            },
          )
        ],
      ),
      body: Padding(
        padding: EdgeInsets.all(15),
        child: _buildContent(),
      ),
    );
  }

  Widget _buildContent() {
    // построение формы
    return Form(
      key: _formKey,
      // у нас будет два поля
      child: Column(
        children: [
          // поля для ввода заголовка
          TextFormField(
            // указываем для поля границу,
            // иконку и подсказку (hint)
            decoration: InputDecoration(
                border: OutlineInputBorder(),
                prefixIcon: Icon(Icons.face),
                hintText: "Заголовок"
            ),
            // указываем TextEditingController
            controller: titleController,
            // параметр validator - функция которая,
            // должна возвращать null при успешной проверки
            // и строку при неудачной
            validator: (value) {
              // здесь мы для наглядности добавили 2 проверки
              if (value == null || value.isEmpty) {
                return "Заголовок пустой";
              }
              if (value.length < 3) {
                return "Заголовок должен быть не короче 3 символов";
              }
              return null;
            },
          ),
          // небольшой отступ между полями
          SizedBox(height: 10),
          // Expanded означает, что мы должны
          // расширить наше поле на все доступное пространство
          Expanded(
            child: TextFormField(
              // maxLines: null и expands: true
              // указаны для расширения поля
              maxLines: null,
              expands: true,
              textAlignVertical: TextAlignVertical.top,
              decoration: InputDecoration(
                  border: OutlineInputBorder(),
                  hintText: "Содержание",
              ),
              // указываем TextEditingController
              controller: contentController,
              // также добавляем проверку поля
              validator: (value) {
                if (value == null || value.isEmpty) {
                  return "Содержание пустое";
                }
                return null;
              },
            ),
          )
        ],
      ),
    );
  }

}

Логика работы следующая:

  1. мы нажаем добавить новый пост

  2. открывается окно с формой, вводим данные

  3. если все ок, то возвращаемся на предыдущую страницу и сообщаем об этом иначе выводим сообщение об ошибке.

Заключительный момент, добавим обработку результата в PostListPage:

floatingActionButton: FloatingActionButton(
  child: Icon(Icons.add),
  onPressed: () {
    // then возвращает объект Future
    // на который мы подписываемся и ждем результата
    Navigator.push(context, MaterialPageRoute(
      builder: (context) => PostDetailPage()
    )).then((value) {
      if (value is PostAddSuccess) {
        // SnackBar - всплывающее сообщение
        ScaffoldMessenger.of(context).showSnackBar(
         SnackBar(content: Text("Пост был успешно добавлен"))
        );
      }
    });
  },
),

Теперь тестируем:

К сожалению JSONPlaceholder на самом деле не добавляет пост и поэтому мы не сможем его увидеть среди прочих постов.

Заключение

Я надеюсь, что убедил вас в том, что работа с формами на Flutter очень проста и не требует почти никаких усилий.

Большая часть кода - это создание POST запроса на сервер и обработка ошибок.

Полезные ссылки

Всем хорошего кода)