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

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:
- ✅ Tạo Events và States
- ✅ Implement LoginBloc với business logic
- ✅ Sử dụng BlocBuilder và BlocListener
- ✅ Form validation với Formz
- ✅ 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!
