Skip to main content

Hướng dẫn sử dụng Bloc trong ứng dụng đăng nhập

· 9 min read

Flutter Bloc Login App

Giới thiệu

Bloc (Business Logic Component) là một pattern quản lý state mạnh mẽ trong Flutter, đặc biệt phù hợp cho các ứng dụng có business logic phức tạp như authentication. Trong bài viết này, chúng ta sẽ xây dựng một hệ thống đăng nhập hoàn chỉnh sử dụng Bloc pattern.

Yêu cầu dự án

Ứng dụng đăng nhập sẽ có:

  • ✅ Màn hình đăng nhập với email và password
  • ✅ Validation form
  • ✅ Xử lý loading state
  • ✅ Hiển thị lỗi
  • ✅ Navigation sau khi đăng nhập thành công
  • ✅ Màn hình home sau khi đăng nhập

Bước 1: Setup dự án

Tạo Flutter project

flutter create bloc_login_app
cd bloc_login_app

Thêm dependencies

Thêm vào file pubspec.yaml:

dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.1.3
equatable: ^2.0.5
formz: ^0.6.1

Chạy lệnh:

flutter pub get

Bước 2: Tạo Models và Validators

Email Model

Tạo file lib/models/email.dart:

import 'package:formz/formz.dart';

enum EmailValidationError { invalid }

class Email extends FormzInput<String, EmailValidationError> {
const Email.pure() : super.pure('');
const Email.dirty([super.value = '']) : super.dirty();

static final _emailRegex = RegExp(
r'^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$',
);


EmailValidationError? validator(String value) {
return _emailRegex.hasMatch(value) ? null : EmailValidationError.invalid;
}
}

Password Model

Tạo file lib/models/password.dart:

import 'package:formz/formz.dart';

enum PasswordValidationError { invalid }

class Password extends FormzInput<String, PasswordValidationError> {
const Password.pure() : super.pure('');
const Password.dirty([super.value = '']) : super.dirty();


PasswordValidationError? validator(String value) {
return value.length >= 6 ? null : PasswordValidationError.invalid;
}
}

Bước 3: Tạo Login Events

Tạo file lib/bloc/login/login_event.dart:

import 'package:equatable/equatable.dart';
import '../models/email.dart';
import '../models/password.dart';

abstract class LoginEvent extends Equatable {
const LoginEvent();


List<Object> get props => [];
}

class LoginEmailChanged extends LoginEvent {
final String email;

const LoginEmailChanged(this.email);


List<Object> get props => [email];
}

class LoginPasswordChanged extends LoginEvent {
final String password;

const LoginPasswordChanged(this.password);


List<Object> get props => [password];
}

class LoginSubmitted extends LoginEvent {
const LoginSubmitted();
}

class LoginPasswordVisibilityToggled extends LoginEvent {
const LoginPasswordVisibilityToggled();
}

Bước 4: Tạo Login State

Tạo file lib/bloc/login/login_state.dart:

import 'package:equatable/equatable.dart';
import 'package:formz/formz.dart';
import '../models/email.dart';
import '../models/password.dart';

class LoginState extends Equatable {
const LoginState({
this.email = const Email.pure(),
this.password = const Password.pure(),
this.status = FormzSubmissionStatus.initial,
this.isPasswordVisible = false,
this.errorMessage,
});

final Email email;
final Password password;
final FormzSubmissionStatus status;
final bool isPasswordVisible;
final String? errorMessage;

bool get isValid => Formz.validate([email, password]);

LoginState copyWith({
Email? email,
Password? password,
FormzSubmissionStatus? status,
bool? isPasswordVisible,
String? errorMessage,
}) {
return LoginState(
email: email ?? this.email,
password: password ?? this.password,
status: status ?? this.status,
isPasswordVisible: isPasswordVisible ?? this.isPasswordVisible,
errorMessage: errorMessage ?? this.errorMessage,
);
}


List<Object?> get props => [
email,
password,
status,
isPasswordVisible,
errorMessage,
];
}

Bước 5: Tạo Login Bloc

Tạo file lib/bloc/login/login_bloc.dart:

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:formz/formz.dart';
import 'login_event.dart';
import 'login_state.dart';
import '../models/email.dart';
import '../models/password.dart';

class LoginBloc extends Bloc<LoginEvent, LoginState> {
LoginBloc() : super(const LoginState()) {
on<LoginEmailChanged>(_onEmailChanged);
on<LoginPasswordChanged>(_onPasswordChanged);
on<LoginSubmitted>(_onSubmitted);
on<LoginPasswordVisibilityToggled>(_onPasswordVisibilityToggled);
}

void _onEmailChanged(
LoginEmailChanged event,
Emitter<LoginState> emit,
) {
final email = Email.dirty(event.email);
emit(
state.copyWith(
email: email,
status: Formz.validate([email, state.password])
? FormzSubmissionStatus.initial
: FormzSubmissionStatus.initial,
),
);
}

void _onPasswordChanged(
LoginPasswordChanged event,
Emitter<LoginState> emit,
) {
final password = Password.dirty(event.password);
emit(
state.copyWith(
password: password,
status: Formz.validate([state.email, password])
? FormzSubmissionStatus.initial
: FormzSubmissionStatus.initial,
),
);
}

void _onPasswordVisibilityToggled(
LoginPasswordVisibilityToggled event,
Emitter<LoginState> emit,
) {
emit(state.copyWith(isPasswordVisible: !state.isPasswordVisible));
}

Future<void> _onSubmitted(
LoginSubmitted event,
Emitter<LoginState> emit,
) async {
if (state.isValid) {
emit(state.copyWith(status: FormzSubmissionStatus.inProgress));
try {
// Simulate API call
await Future.delayed(const Duration(seconds: 2));

// Mock authentication logic
if (state.email.value == 'user@example.com' &&
state.password.value == 'password123') {
emit(state.copyWith(status: FormzSubmissionStatus.success));
} else {
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: 'Email hoặc mật khẩu không đúng',
),
);
}
} catch (error) {
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: 'Đã xảy ra lỗi. Vui lòng thử lại.',
),
);
}
}
}
}

Bước 6: Tạo Auth Repository (Optional)

Tạo file lib/repositories/auth_repository.dart:

class AuthRepository {
Future<bool> login(String email, String password) async {
// Simulate API call
await Future.delayed(const Duration(seconds: 2));

// Mock authentication
if (email == 'user@example.com' && password == 'password123') {
return true;
}
return false;
}

Future<void> logout() async {
// Clear user session
await Future.delayed(const Duration(milliseconds: 500));
}
}

Bước 7: Tạo Login Screen

Tạo file lib/screens/login_screen.dart:

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/login/login_bloc.dart';
import '../bloc/login/login_event.dart';
import '../bloc/login/login_state.dart';
import 'home_screen.dart';

class LoginScreen extends StatelessWidget {
const LoginScreen({Key? key}) : super(key: key);


Widget build(BuildContext context) {
return Scaffold(
body: BlocProvider(
create: (context) => LoginBloc(),
child: const LoginForm(),
),
);
}
}

class LoginForm extends StatelessWidget {
const LoginForm({Key? key}) : super(key: key);


Widget build(BuildContext context) {
return BlocListener<LoginBloc, LoginState>(
listener: (context, state) {
if (state.status.isSuccess) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const HomeScreen()),
);
} else if (state.status.isFailure) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(state.errorMessage ?? 'Đăng nhập thất bại'),
backgroundColor: Colors.red,
),
);
}
},
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.lock_outline,
size: 80,
color: Colors.blue,
),
const SizedBox(height: 32),
const Text(
'Đăng Nhập',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 48),
const _EmailInput(),
const SizedBox(height: 16),
const _PasswordInput(),
const SizedBox(height: 24),
const _LoginButton(),
const SizedBox(height: 16),
const _DemoCredentials(),
],
),
),
),
),
);
}
}

class _EmailInput extends StatelessWidget {
const _EmailInput();


Widget build(BuildContext context) {
return BlocBuilder<LoginBloc, LoginState>(
buildWhen: (previous, current) => previous.email != current.email,
builder: (context, state) {
return TextField(
key: const Key('loginForm_emailInput_textField'),
onChanged: (email) =>
context.read<LoginBloc>().add(LoginEmailChanged(email)),
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: 'Email',
prefixIcon: const Icon(Icons.email),
errorText: state.email.displayError != null
? 'Email không hợp lệ'
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
},
);
}
}

class _PasswordInput extends StatelessWidget {
const _PasswordInput();


Widget build(BuildContext context) {
return BlocBuilder<LoginBloc, LoginState>(
buildWhen: (previous, current) =>
previous.password != current.password ||
previous.isPasswordVisible != current.isPasswordVisible,
builder: (context, state) {
return TextField(
key: const Key('loginForm_passwordInput_textField'),
onChanged: (password) =>
context.read<LoginBloc>().add(LoginPasswordChanged(password)),
obscureText: !state.isPasswordVisible,
decoration: InputDecoration(
labelText: 'Mật khẩu',
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
state.isPasswordVisible
? Icons.visibility
: Icons.visibility_off,
),
onPressed: () => context
.read<LoginBloc>()
.add(const LoginPasswordVisibilityToggled()),
),
errorText: state.password.displayError != null
? 'Mật khẩu phải có ít nhất 6 ký tự'
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
},
);
}
}

class _LoginButton extends StatelessWidget {
const _LoginButton();


Widget build(BuildContext context) {
return BlocBuilder<LoginBloc, LoginState>(
builder: (context, state) {
return SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
key: const Key('loginForm_continue_raisedButton'),
onPressed: state.isValid && !state.status.isInProgress
? () => context.read<LoginBloc>().add(const LoginSubmitted())
: null,
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: state.status.isInProgress
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text(
'Đăng Nhập',
style: TextStyle(fontSize: 16),
),
),
);
},
);
}
}

class _DemoCredentials extends StatelessWidget {
const _DemoCredentials();


Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
const Text(
'Thông tin đăng nhập demo:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text('Email: user@example.com'),
const Text('Password: password123'),
],
),
);
}
}

Bước 8: Tạo Home Screen

Tạo file lib/screens/home_screen.dart:

import 'package:flutter/material.dart';

class HomeScreen extends StatelessWidget {
const HomeScreen({Key? key}) : super(key: key);


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Trang Chủ'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () {
Navigator.of(context).pop();
},
tooltip: 'Đăng xuất',
),
],
),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.check_circle,
size: 80,
color: Colors.green,
),
SizedBox(height: 24),
Text(
'Đăng nhập thành công!',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 16),
Text(
'Chào mừng bạn đến với ứng dụng',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
],
),
),
);
}
}

Bước 9: Setup main.dart

Cập nhật file lib/main.dart:

import 'package:flutter/material.dart';
import 'screens/login_screen.dart';

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

class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);


Widget build(BuildContext context) {
return MaterialApp(
title: 'Bloc Login App',
theme: ThemeData(
primarySwatch: Colors.blue,
useMaterial3: true,
),
home: const LoginScreen(),
debugShowCheckedModeBanner: false,
);
}
}

Giải thích các khái niệm Bloc

1. Events

Events đại diện cho các hành động từ UI (user input, button clicks, etc.). Mỗi event là một class extends Equatable.

2. States

States đại diện cho trạng thái hiện tại của ứng dụng. State class cũng extends Equatable để so sánh.

3. Bloc

Bloc nhận Events và emit States. Nó chứa business logic và xử lý các side effects.

4. BlocBuilder

BlocBuilder rebuild UI khi state thay đổi. Sử dụng buildWhen để kiểm soát khi nào rebuild.

5. BlocListener

BlocListener lắng nghe state changes và thực hiện side effects (navigation, show snackbar, etc.) mà không rebuild UI.

Best Practices

1. Separation of Concerns

  • Events: User actions
  • States: UI state
  • Bloc: Business logic

2. Immutable States

Luôn tạo state mới thay vì modify state hiện tại:

// ✅ Good
emit(state.copyWith(email: newEmail));

// ❌ Bad
state.email = newEmail;
emit(state);

3. Use Equatable

Sử dụng Equatable để so sánh states và events hiệu quả.

4. Error Handling

Luôn xử lý errors và emit appropriate states:

try {
// API call
} catch (error) {
emit(state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: error.toString(),
));
}

5. Testing

Bloc dễ test vì logic tách biệt khỏi UI:

test('emits [LoginState] when email changes', () {
final bloc = LoginBloc();
bloc.add(const LoginEmailChanged('test@example.com'));
expect(bloc.state.email.value, 'test@example.com');
});

Mở rộng ứng dụng

Thêm Remember Me

class LoginRememberMeToggled extends LoginEvent {
const LoginRememberMeToggled();
}

// In state
final bool rememberMe;

// In bloc
void _onRememberMeToggled(...) {
emit(state.copyWith(rememberMe: !state.rememberMe));
}

Thêm Forgot Password

Tạo ForgotPasswordBloc tương tự LoginBloc.

Tích hợp với API thật

Thay thế mock logic bằng API calls:

Future<void> _onSubmitted(...) async {
if (state.isValid) {
emit(state.copyWith(status: FormzSubmissionStatus.inProgress));
try {
final success = await authRepository.login(
state.email.value,
state.password.value,
);
if (success) {
emit(state.copyWith(status: FormzSubmissionStatus.success));
} else {
emit(state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: 'Đăng nhập thất bại',
));
}
} catch (error) {
emit(state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: error.toString(),
));
}
}
}

Kết luận

Trong bài viết này, chúng ta đã xây dựng một hệ thống đăng nhập hoàn chỉnh sử dụng Bloc pattern. Các điểm chính:

  1. ✅ Tạo Events và States
  2. ✅ Implement LoginBloc với business logic
  3. ✅ Sử dụng BlocBuilder và BlocListener
  4. ✅ Form validation với Formz
  5. ✅ Xử lý loading và error states

Bloc pattern cung cấp một architecture rõ ràng và dễ test, đặc biệt phù hợp cho các ứng dụng có business logic phức tạp. Hãy tiếp tục khám phá và mở rộng ứng dụng này!

Happy coding với Flutter và Bloc!