Ở bài viết trước, mình đã hướng dẫn các bạn cách khởi tạo và cấu hình một dự án Flutter theo mô hình MVVM. Ở bài viết này, mình sẽ hướng dẫn các bạn cách xây dựng màn hình Login cho dự án Flutter.

Để tiếp nối bài này, bạn cần phải đọc và thực hành từ bài viết trước, bạn vào đây để tham khảo bài viết.

Chuẩn bị

Bạn truy cập link github để tải logo của app về “chatbot.png”

Truy cập vào link github để coppy mục “color_extention.dart” để lấy các mã màu và size chữ của dự án mà chúng ta sẽ sử dụng.

Xây dựng màn hình Login cho dự án

Xây dựng login trong ViewModel

Trong bài viết trước, mình có cùng xây dựng mô hình dự án MVVM, các bạn truy cập vào folder ViewModel và tạo file login_view_model.dart.
Ý tưởng xây dựng file login_view_model
Màn hình login của chúng ta sẽ gồm có logo app, ô nhập username, ô nhập password (password ẩn và hiển thị khi chúng ta nhấp vào icon bên cạnh), nút đăng nhập và đăng nhập bằng hình thức khác( thường là tài khoản của bên thứ ba như Google, Facebook, Apple,…),
Mình sẽ demo sản phẩm cho các bạn có thể hình dung rõ hơn sau đó chúng ta sẽ đi xây dựng layout (cách trình bày) sản phẩm:

screenLogin

Ở file login_viewmodel.dart của chúng ta sẽ xử lý các câu lệnh vê logic và quản lý trạng thái của View (cụ thể ở đây là sẽ màn login_view.dart(file sắp tới sẽ tạo)).

Xây dựng file login_view_model.dart

class LoginViewModel extends GetxController {
  RxBool isObscured = true.obs;
  final formKey = GlobalKey<FormState>();
  final showPassword = false.obs;
  final isLoading = false.obs;
  void toggleObscureText() {
    isObscured.value = !isObscured.value;
  }

  void showHidePassword() {
    showPassword.value = !showPassword.value;
  }

  void onChangeUsername(username) {
    formKey.currentState?.validate();
  }

  void onChangePassword(password) {
    formKey.currentState?.validate();
  }

  bool containsSpecialCharacters(String text) {
    final allowedSpecialCharacters = RegExp(r'[!#\$%^&*(),?":{}|<>]');
    return allowedSpecialCharacters.hasMatch(text);
  }

  String? validatorUsername(username) {
    if ((username ?? "").isEmpty) {
      return "Username không được để trống";
    } else if (containsSpecialCharacters(username!)) {
      return "Username không đúng định dạng";
    } else {
      return null;
    }
  }

  String? validatePassword(password) {
    if ((password ?? "").isEmpty) {
      return "Password không được để trống";
    } else {
      return null;
    }
  }
}

Giải thích:

  • void toggleObscureText(): Khi gọi hàm này, nó sẽ chuyển đổi giữa ẩn và hiện trường văn bản (ở đây là trường mật khẩu).
  • void showHidePassword(): Dùng để thay đổi trạng thái hiển thị của mật khẩu khi người dùng nhấn vào biểu tượng “mắt” trong trường mật khẩu.
  • void onChangeUsername(username): Nó gọi hàm validate() để xác thực form mỗi khi có thay đổi trong trường tên đăng nhập.
  • void onChangePassword(password): Tương tự như onChangeUsername, hàm này cũng gọi hàm validate() để xác thực form mỗi khi có thay đổi trong trường mật khẩu.
  • bool containsSpecialCharacters(String text): Nó sử dụng biểu thức chính quy để xác định các ký tự đặc biệt. Nếu có, hàm trả về true, ngược lại trả về false.
  • String? validatorUsername(username): Hàm này kiểm tra nếu trường tên đăng nhập rỗng hoặc chứa ký tự đặc biệt, nó sẽ trả về một thông báo lỗi. Ngược lại, nó sẽ trả về null.
  • String? validatePassword(password): Hàm này kiểm tra nếu trường mật khẩu rỗng và trả về thông báo lỗi nếu có. Nếu mật khẩu hợp lệ, nó trả về null.

Tiếp theo chúng ta sẽ xây dựng file login_view.dart để hiện thị giao diện mà mình đã demo ở đầu tiên.

Ý tưởng xây dựng file login_view.dart
Ở file này chúng ta sẽ hiển thị cho người dùng các hình ảnh, ô nhập liệu, các button.

Xây dựng file login_view.dart


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

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final TextEditingController username = TextEditingController();
  final TextEditingController password = TextEditingController();

  @override
  void initState() {
    super.initState();
    SystemChrome.setEnabledSystemUIMode(SystemUiMode.leanBack);
  }

  @override
  void dispose() {
    username.dispose();
    password.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final loginController = Get.put(ViewModel());
    return Scaffold(
      backgroundColor: TColor.bg,
      body: SafeArea(
        child: SingleChildScrollView(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.center,
            children: <Widget>[
              const SizedBox(height: 80),
              _textLogin(), //login text
              const SizedBox(height: 120),
              Obx(
                () => _formUsernameAndPassword(
                    loginController), //Returns a form that includes two input fields for username and password
              ),
              const SizedBox(height: 40),
              // button login
              LoginButton(
                  loginController: loginController,
                  context: context,
                  usernameController: username,
                  passwordController: password,
                  onInvalidCredentials: () {
                    setState(() {
                      alert_dialog = true;
                    });
                  }),
              const SizedBox(
                height: 60,
              ),
              _textOrContinueWith(),
              const SizedBox(
                height: 30,
              ),
              _logoAppleAndGoogle(),
              alert_dialog
                  ? const Text(
                      "Invalid credentials.",
                      style: TextStyle(color: Colors.redAccent),
                    )
                  : Container(),
            ],
          ),
        ),
      ),
    );
  }

  Logo _logoAppleAndGoogle() {
    return const Logo();
  }

  OrContinueWith _textOrContinueWith() {
    return const OrContinueWith();
  }

  //form input fields for username and password
  Form _formUsernameAndPassword(ViewModel loginController) {
    return Form(
      key: loginController.formKey,
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 25),
        child: Column(
          children: [
            _formUsername(loginController),
            const SizedBox(height: 20),
            _formPassword(loginController),
          ],
        ),
      ),
    );
  }

  //form input field for password
  TextFormField _formPassword(ViewModel loginController) {
    return TextFormField(
      controller: password,
      obscureText: loginController.isObscured.value,
      textAlign: TextAlign.center,
      decoration: InputDecoration(
        border: const OutlineInputBorder(),
        enabledBorder: const OutlineInputBorder(
          borderSide: BorderSide(color: Colors.lightBlueAccent, width: 0.0),
        ),
        focusedBorder: const OutlineInputBorder(
          borderSide: BorderSide(color: Colors.blueAccent, width: 2.0),
        ),
        hintText: 'Password',
        hintStyle: TextStyle(color: TColor.primaryText28),
        suffixIcon: IconButton(
          icon: Icon(
            loginController.isObscured.value
                ? Icons.visibility
                : Icons.visibility_off,
          ),
          onPressed: loginController.toggleObscureText,
        ),
      ),
      onChanged: loginController.onChangePassword,
      validator: loginController.validatePassword,
      style:
          TextStyle(color: TColor.primaryText28, fontWeight: FontWeight.bold),
    );
  }

  //form field for username
  TextFormField _formUsername(ViewModel loginController) {
    return TextFormField(
      controller: username,
      obscureText: false,
      textAlign: TextAlign.center,
      decoration: InputDecoration(
          border: const OutlineInputBorder(),
          enabledBorder: const OutlineInputBorder(
            borderSide: BorderSide(color: Colors.lightBlueAccent, width: 0.0),
          ),
          focusedBorder: const OutlineInputBorder(
            borderSide: BorderSide(color: Colors.blueAccent, width: 2.0),
          ),
          hintText: 'Username',
          hintStyle: TextStyle(color: TColor.primaryText28),
          suffixIcon: const Icon(Icons.person)),
      onChanged: loginController.onChangeUsername,
      validator: loginController.validatorUsername,
      style:
          TextStyle(color: TColor.primaryText28, fontWeight: FontWeight.bold),
    );
  }

  //login text
  Text _textLogin() {
    return Text(
      'Login',
      style: TextStyle(
        fontSize: 60,
        fontWeight: FontWeight.bold,
        color: TColor.primaryText28,
      ),
    );
  }
}

Trong file login_View.dart, đây là file giao diện nên code sẽ rất dài và điều này khiến cho lập trình viên cũng như những người quản lý code khó sử dụng và fix bug nếu như sau này cần chỉnh sửa, vì vậy chúng ta sẽ tách nhỏ các file code để dễ quản lý và bảo trì.

Ở đây ngoài file code chính mình đã tách giao diện thành 3 file nhỏ hơn lần lượt là loginbutton.dart (chứa nút button để sau kiểm tra dữ liệu sẽ chuyển đến màn tiếp theo hoặc hiển thị lỗi nếu sai thông tin), logo.dart (chứa logo của app), login_text.dart (nơi chứa các văn bản dạng text).
Các file này mình sẽ chứa trong foder Widgets, khi sử dụng đến chỉ cần gọi đến là có thể sử dụng được.
Cụ thể:
file loginbutton.dart

class LoginButton extends StatelessWidget {
  final ViewModel loginController;
  final BuildContext context;
  final TextEditingController usernameController;
  final TextEditingController passwordController;
  final VoidCallback onInvalidCredentials;

  const LoginButton({
    super.key,
    required this.loginController,
    required this.context,
    required this.usernameController,
    required this.passwordController,
    required this.onInvalidCredentials,
  });

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () {
        if (loginController.formKey.currentState!.validate()) {
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (context) => Chat(
                username: usernameController.text,
                password: passwordController.text,
              ),
            ),
          );
        } else {
          onInvalidCredentials();
        }
      },
      style: ElevatedButton.styleFrom(
        backgroundColor: TColor.darkGray,
        elevation: 2,
        shadowColor: Colors.black,
        padding: const EdgeInsets.symmetric(
          horizontal: 40,
          vertical: 10,
        ),
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(30),
        ),
      ),
      child: const Icon(
        Icons.door_front_door_outlined,
        color: Colors.lightBlueAccent,
      ),
    );
  }
}

file logo.dart

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

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Image.asset(
          ImagesAssset.logoApple,
          height: 100,
        ),
        const SizedBox(
          width: 20,
        ),
        Image.asset(
          ImagesAssset.logoGG,
          height: 90,
        ),
      ],
    );
  }
}

file login_text.dart

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

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 25.0),
      child: Row(
        children: [
          Expanded(
            child: Divider(
              thickness: 0.5,
              color: Colors.grey[400],
            ),
          ),
          const Padding(
            padding: EdgeInsets.symmetric(horizontal: 10.0),
            child: Text(
              'Or continue with',
              style: TextStyle(color: Color(0xFF616161)),
            ),
          ),
          Expanded(
            child: Divider(
              thickness: 0.5,
              color: Colors.grey[400],
            ),
          ),
        ],
      ),
    );
  }
}

Tổng kết

Vừa rồi thì mình đã hướng dẫn các bạn cách xây dựng màn login cho dự án chat realtime. Ở bài viết sau mình sẽ hướng dẫn các bạn khởi tạo dự án Sever.
link github dự án: https://github.com/nguyendao2101/freechat (truy cập branch “new_main” để tham khảo dự án)