close
콘텐츠로 이동

Flutter Login

intermediate

이 튜토리얼에서는 Bloc 라이브러리를 사용해서 Flutter에서 로그인 플로우를 만들어 봅니다.

demo

새로운 Flutter 프로젝트를 생성합니다.

Terminal window
flutter create flutter_login

의존성을 설치합니다.

Terminal window
flutter pub get

먼저 인증 도메인을 관리하는 authentication_repository 패키지를 만듭니다.

프로젝트 루트에 모든 내부 패키지가 들어갈 packages/authentication_repository 디렉토리를 생성합니다.

대략적으로 디렉토리 구조는 다음과 같습니다:

├── android
├── ios
├── lib
├── packages
│ └── authentication_repository
└── test

다음으로 authentication_repository 패키지의 pubspec.yaml을 생성합니다:

packages/authentication_repository/pubspec.yaml
name: authentication_repository
description: Dart package which manages the authentication domain.
publish_to: none
environment:
sdk: ^3.12.0

다음으로 AuthenticationRepository 클래스 자체를 packages/authentication_repository/lib/src/authentication_repository.dart에 구현합니다.

packages/authentication_repository/lib/src/authentication_repository.dart
import 'dart:async';
enum AuthenticationStatus { unknown, authenticated, unauthenticated }
class AuthenticationRepository {
final _controller = StreamController<AuthenticationStatus>();
Stream<AuthenticationStatus> get status async* {
await Future<void>.delayed(const Duration(seconds: 1));
yield AuthenticationStatus.unauthenticated;
yield* _controller.stream;
}
Future<void> logIn({
required String username,
required String password,
}) async {
await Future.delayed(
const Duration(milliseconds: 300),
() => _controller.add(AuthenticationStatus.authenticated),
);
}
void logOut() {
_controller.add(AuthenticationStatus.unauthenticated);
}
void dispose() => _controller.close();
}

AuthenticationRepository는 사용자가 로그인하거나 로그아웃할 때 앱에 알리는 데 사용할 AuthenticationStatus 업데이트 Stream을 노출합니다.

또한 단순화를 위해 stub으로 처리된 logInlogOut 메서드가 있지만, FirebaseAuth나 다른 인증 프로바이더로 쉽게 확장할 수 있습니다.

마지막으로 public exports를 포함할 packages/authentication_repository/lib/authentication_repository.dart를 생성합니다:

packages/authentication_repository/lib/authentication_repository.dart
export 'src/authentication_repository.dart';

AuthenticationRepository는 여기까지입니다. 다음으로 UserRepository를 만듭니다.

AuthenticationRepository처럼 packages 디렉토리 안에 user_repository 패키지를 만듭니다.

├── android
├── ios
├── lib
├── packages
│ ├── authentication_repository
│ └── user_repository
└── test

다음으로 user_repositorypubspec.yaml을 생성합니다:

packages/user_repository/pubspec.yaml
name: user_repository
description: Dart package which manages the user domain.
publish_to: none
environment:
sdk: ^3.12.0
dependencies:
equatable: ^2.1.0-dev.0
uuid: ^3.0.0

user_repository는 사용자 도메인을 담당하고 현재 사용자와 상호작용하기 위한 API를 노출합니다.

먼저 packages/user_repository/lib/src/models/user.dart에 user 모델을 정의합니다:

packages/user_repository/lib/src/models/user.dart
import 'package:equatable/equatable.dart';
class User extends Equatable {
const User(this.id);
final String id;
@override
List<Object> get props => [id];
static const empty = User('-');
}

단순화를 위해 user는 id 속성만 가지지만, 실제로는 firstName, lastName, avatarUrl 등의 추가 속성이 있을 수 있습니다.

다음으로 packages/user_repository/lib/src/modelsmodels.dart를 생성해서 단일 import로 여러 모델을 가져올 수 있도록 합니다.

packages/user_repository/lib/src/models/models.dart
export 'user.dart';

이제 모델이 정의됐으니 packages/user_repository/lib/src/user_repository.dartUserRepository 클래스를 구현합니다.

packages/user_repository/lib/src/user_repository.dart
import 'dart:async';
import 'package:user_repository/src/models/models.dart';
import 'package:uuid/uuid.dart';
class UserRepository {
User? _user;
Future<User?> getUser() async {
if (_user != null) return _user;
return Future.delayed(
const Duration(milliseconds: 300),
() => _user = User(const Uuid().v4()),
);
}
}

이 간단한 예제에서 UserRepository는 현재 사용자를 가져오는 getUser 메서드 하나만 노출합니다. stub으로 처리됐지만 실제로는 백엔드에서 현재 사용자를 쿼리하는 곳입니다.

user_repository 패키지가 거의 완료됐습니다. 남은 건 public exports를 정의하는 packages/user_repository/libuser_repository.dart 파일을 만드는 것뿐입니다:

packages/user_repository/lib/user_repository.dart
export 'src/models/models.dart';
export 'src/user_repository.dart';

이제 authentication_repositoryuser_repository 패키지가 완료됐으니 Flutter 앱에 집중할 수 있습니다.

프로젝트 루트에 생성된 pubspec.yaml을 업데이트합니다:

pubspec.yaml
name: flutter_login
description: A new Flutter project.
version: 1.0.0+1
publish_to: none
environment:
sdk: ^3.12.0
dependencies:
authentication_repository:
path: packages/authentication_repository
bloc: ^9.0.0
equatable: ^2.1.0-dev.0
flutter:
sdk: flutter
flutter_bloc: ^9.1.0
formz: ^0.8.0
user_repository:
path: packages/user_repository
dev_dependencies:
bloc_lint: ^0.3.0
bloc_test: ^10.0.0
flutter_test:
sdk: flutter
mocktail: ^1.0.0
flutter:
uses-material-design: true

다음을 실행해서 의존성을 설치합니다:

Terminal window
flutter pub get

AuthenticationBloc은 (AuthenticationRepository가 노출하는) 인증 상태 변화에 반응하고 프레젠테이션 레이어에서 반응할 수 있는 상태를 emit합니다.

AuthenticationBloc 구현은 lib/authentication 안에 있습니다. 인증을 앱 레이어의 기능으로 취급하기 때문입니다.

├── lib
│ ├── app.dart
│ ├── authentication
│ │ ├── authentication.dart
│ │ └── bloc
│ │ ├── authentication_bloc.dart
│ │ ├── authentication_event.dart
│ │ └── authentication_state.dart
│ ├── main.dart

AuthenticationEvent 인스턴스는 AuthenticationBloc의 입력이 되고, 처리되어 새로운 AuthenticationState 인스턴스를 emit하는 데 사용됩니다.

이 앱에서 AuthenticationBloc은 두 가지 이벤트에 반응합니다:

  • AuthenticationSubscriptionRequested: bloc에게 AuthenticationStatus 스트림을 구독하라고 알리는 초기 이벤트
  • AuthenticationLogoutPressed: 사용자 로그아웃 액션을 bloc에 알림
lib/authentication/bloc/authentication_event.dart
part of 'authentication_bloc.dart';
sealed class AuthenticationEvent {
const AuthenticationEvent();
}
final class AuthenticationSubscriptionRequested extends AuthenticationEvent {}
final class AuthenticationLogoutPressed extends AuthenticationEvent {}

다음으로 AuthenticationState를 살펴봅니다.

AuthenticationState 인스턴스는 AuthenticationBloc의 출력이 되고 프레젠테이션 레이어에서 사용됩니다.

AuthenticationState 클래스는 세 개의 named constructor가 있습니다:

  • AuthenticationState.unknown(): bloc이 현재 사용자가 인증됐는지 아닌지 아직 모르는 기본 상태.

  • AuthenticationState.authenticated(): 사용자가 현재 인증된 상태.

  • AuthenticationState.unauthenticated(): 사용자가 현재 인증되지 않은 상태.

lib/authentication/bloc/authentication_state.dart
part of 'authentication_bloc.dart';
class AuthenticationState extends Equatable {
const AuthenticationState._({
this.status = AuthenticationStatus.unknown,
this.user = User.empty,
});
const AuthenticationState.unknown() : this._();
const AuthenticationState.authenticated(User user)
: this._(status: AuthenticationStatus.authenticated, user: user);
const AuthenticationState.unauthenticated()
: this._(status: AuthenticationStatus.unauthenticated);
final AuthenticationStatus status;
final User user;
@override
List<Object> get props => [status, user];
}

AuthenticationEventAuthenticationState 구현을 봤으니 이제 AuthenticationBloc을 살펴봅니다.

AuthenticationBloc은 사용자를 로그인 페이지에서 시작할지 홈 페이지에서 시작할지 같은 것들을 결정하는 데 사용되는 앱의 인증 상태를 관리합니다.

lib/authentication/bloc/authentication_bloc.dart
import 'dart:async';
import 'package:authentication_repository/authentication_repository.dart';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:user_repository/user_repository.dart';
part 'authentication_event.dart';
part 'authentication_state.dart';
class AuthenticationBloc
extends Bloc<AuthenticationEvent, AuthenticationState> {
AuthenticationBloc({
required this._authenticationRepository,
required this._userRepository,
}) : super(const AuthenticationState.unknown()) {
on<AuthenticationSubscriptionRequested>(_onSubscriptionRequested);
on<AuthenticationLogoutPressed>(_onLogoutPressed);
}
final AuthenticationRepository _authenticationRepository;
final UserRepository _userRepository;
Future<void> _onSubscriptionRequested(
AuthenticationSubscriptionRequested event,
Emitter<AuthenticationState> emit,
) {
return emit.onEach(
_authenticationRepository.status,
onData: (status) async {
switch (status) {
case AuthenticationStatus.unauthenticated:
return emit(const AuthenticationState.unauthenticated());
case AuthenticationStatus.authenticated:
final user = await _tryGetUser();
return emit(
user != null
? AuthenticationState.authenticated(user)
: const AuthenticationState.unauthenticated(),
);
case AuthenticationStatus.unknown:
return emit(const AuthenticationState.unknown());
}
},
onError: addError,
);
}
void _onLogoutPressed(
AuthenticationLogoutPressed event,
Emitter<AuthenticationState> emit,
) {
_authenticationRepository.logOut();
}
Future<User?> _tryGetUser() async {
try {
final user = await _userRepository.getUser();
return user;
} catch (_) {
return null;
}
}
}

AuthenticationBlocAuthenticationRepositoryUserRepository 모두에 의존하고 초기 상태를 AuthenticationState.unknown()으로 정의합니다.

생성자 본문에서 AuthenticationEvent 하위 클래스가 해당 이벤트 핸들러에 매핑됩니다.

_onSubscriptionRequested 이벤트 핸들러에서 AuthenticationBlocemit.onEach를 사용해서 AuthenticationRepositorystatus 스트림을 구독하고 각 AuthenticationStatus에 대한 응답으로 상태를 emit합니다.

emit.onEach는 내부적으로 스트림 구독을 생성하고 AuthenticationBloc이나 status 스트림이 닫히면 취소를 처리합니다.

status 스트림이 에러를 emit하면 addError가 에러와 stackTrace를 listen하고 있는 BlocObserver에 전달합니다.

status 스트림이 AuthenticationStatus.unknown이나 unauthenticated를 emit하면 해당 AuthenticationState가 emit됩니다.

AuthenticationStatus.authenticated가 emit되면 AuthenticationBlocUserRepository를 통해 사용자를 쿼리합니다.

기본 main.dart를 다음으로 교체합니다:

lib/main.dart
import 'package:flutter/widgets.dart';
import 'package:flutter_login/app.dart';
void main() => runApp(const App());

app.dart는 전체 앱의 루트 App 위젯을 포함합니다.

lib/app.dart
import 'package:authentication_repository/authentication_repository.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_login/authentication/authentication.dart';
import 'package:flutter_login/home/home.dart';
import 'package:flutter_login/login/login.dart';
import 'package:flutter_login/splash/splash.dart';
import 'package:user_repository/user_repository.dart';
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MultiRepositoryProvider(
providers: [
RepositoryProvider(
create: (_) => AuthenticationRepository(),
dispose: (repository) => repository.dispose(),
),
RepositoryProvider(create: (_) => UserRepository()),
],
child: BlocProvider(
lazy: false,
create: (context) => AuthenticationBloc(
authenticationRepository: context.read<AuthenticationRepository>(),
userRepository: context.read<UserRepository>(),
)..add(AuthenticationSubscriptionRequested()),
child: const AppView(),
),
);
}
}
class AppView extends StatefulWidget {
const AppView({super.key});
@override
State<AppView> createState() => _AppViewState();
}
class _AppViewState extends State<AppView> {
final _navigatorKey = GlobalKey<NavigatorState>();
NavigatorState get _navigator => _navigatorKey.currentState!;
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: _navigatorKey,
builder: (context, child) {
return BlocListener<AuthenticationBloc, AuthenticationState>(
listener: (context, state) {
switch (state.status) {
case AuthenticationStatus.authenticated:
_navigator.pushAndRemoveUntil<void>(
HomePage.route(),
(route) => false,
);
case AuthenticationStatus.unauthenticated:
_navigator.pushAndRemoveUntil<void>(
LoginPage.route(),
(route) => false,
);
case AuthenticationStatus.unknown:
break;
}
},
child: child,
);
},
onGenerateRoute: (_) => SplashPage.route(),
);
}
}

기본적으로 BlocProvider는 lazy라서 Bloc이 처음 접근될 때까지 create를 호출하지 않습니다. AuthenticationBloc은 항상 (AuthenticationSubscriptionRequested 이벤트를 통해) AuthenticationStatus 스트림을 즉시 구독해야 하므로 lazy: false를 설정해서 이 동작을 명시적으로 opt out합니다.

AppViewNavigatorState에 접근하는 데 사용되는 GlobalKey를 유지하기 때문에 StatefulWidget입니다. 기본적으로 AppViewSplashPage(나중에 살펴봄)를 렌더링하고 BlocListener를 사용해서 AuthenticationState 변화에 따라 다른 페이지로 이동합니다.

splash 기능은 앱이 시작될 때 사용자가 인증됐는지 판단하는 동안 렌더링될 간단한 뷰만 포함합니다.

lib
└── splash
├── splash.dart
└── view
└── splash_page.dart
lib/splash/view/splash_page.dart
import 'package:flutter/material.dart';
class SplashPage extends StatelessWidget {
const SplashPage({super.key});
static Route<void> route() {
return MaterialPageRoute<void>(builder: (_) => const SplashPage());
}
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
}

login 기능은 LoginPage, LoginForm, LoginBloc을 포함하고 사용자가 앱에 로그인하기 위해 username과 password를 입력할 수 있게 합니다.

├── lib
│ ├── login
│ │ ├── bloc
│ │ │ ├── login_bloc.dart
│ │ │ ├── login_event.dart
│ │ │ └── login_state.dart
│ │ ├── login.dart
│ │ ├── models
│ │ │ ├── models.dart
│ │ │ ├── password.dart
│ │ │ └── username.dart
│ │ └── view
│ │ ├── login_form.dart
│ │ ├── login_page.dart
│ │ └── view.dart

package:formz를 사용해서 usernamepassword에 대한 재사용 가능하고 표준적인 모델을 만듭니다.

lib/login/models/username.dart
import 'package:formz/formz.dart';
enum UsernameValidationError { empty }
class Username extends FormzInput<String, UsernameValidationError> {
const Username.pure() : super.pure('');
const Username.dirty([super.value = '']) : super.dirty();
@override
UsernameValidationError? validator(String value) {
if (value.isEmpty) return UsernameValidationError.empty;
return null;
}
}

단순화를 위해 username이 비어있지 않은지만 검증하지만, 실제로는 특수 문자 사용, 길이 등을 적용할 수 있습니다.

lib/login/models/password.dart
import 'package:formz/formz.dart';
enum PasswordValidationError { empty }
class Password extends FormzInput<String, PasswordValidationError> {
const Password.pure() : super.pure('');
const Password.dirty([super.value = '']) : super.dirty();
@override
PasswordValidationError? validator(String value) {
if (value.isEmpty) return PasswordValidationError.empty;
return null;
}
}

마찬가지로 password가 비어있지 않은지 간단히 확인합니다.

이전처럼 단일 import로 UsernamePassword 모델을 쉽게 가져올 수 있도록 models.dart barrel이 있습니다.

lib/login/models/models.dart
export 'password.dart';
export 'username.dart';

LoginBlocLoginForm의 상태를 관리하고 username과 password 입력 유효성 검사와 폼 상태를 처리합니다.

이 앱에는 세 가지 LoginEvent 타입이 있습니다:

  • LoginUsernameChanged: username이 수정됐음을 bloc에 알림.
  • LoginPasswordChanged: password가 수정됐음을 bloc에 알림.
  • LoginSubmitted: 폼이 submit됐음을 bloc에 알림.
lib/login/bloc/login_event.dart
part of 'login_bloc.dart';
sealed class LoginEvent extends Equatable {
const LoginEvent();
@override
List<Object> get props => [];
}
final class LoginUsernameChanged extends LoginEvent {
const LoginUsernameChanged(this.username);
final String username;
@override
List<Object> get props => [username];
}
final class LoginPasswordChanged extends LoginEvent {
const LoginPasswordChanged(this.password);
final String password;
@override
List<Object> get props => [password];
}
final class LoginSubmitted extends LoginEvent {
const LoginSubmitted();
}

LoginState는 폼의 상태와 username, password 입력 상태를 포함합니다.

lib/login/bloc/login_state.dart
part of 'login_bloc.dart';
final class LoginState extends Equatable {
const LoginState({
this.status = FormzSubmissionStatus.initial,
this.username = const Username.pure(),
this.password = const Password.pure(),
this.isValid = false,
});
final FormzSubmissionStatus status;
final Username username;
final Password password;
final bool isValid;
LoginState copyWith({
FormzSubmissionStatus? status,
Username? username,
Password? password,
bool? isValid,
}) {
return LoginState(
status: status ?? this.status,
username: username ?? this.username,
password: password ?? this.password,
isValid: isValid ?? this.isValid,
);
}
@override
List<Object> get props => [status, username, password];
}

LoginBlocLoginForm의 사용자 상호작용에 반응하고 폼의 유효성 검사와 submit을 처리합니다.

lib/login/bloc/login_bloc.dart
import 'package:authentication_repository/authentication_repository.dart';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_login/login/login.dart';
import 'package:formz/formz.dart';
part 'login_event.dart';
part 'login_state.dart';
class LoginBloc extends Bloc<LoginEvent, LoginState> {
LoginBloc({required this._authenticationRepository})
: super(const LoginState()) {
on<LoginUsernameChanged>(_onUsernameChanged);
on<LoginPasswordChanged>(_onPasswordChanged);
on<LoginSubmitted>(_onSubmitted);
}
final AuthenticationRepository _authenticationRepository;
void _onUsernameChanged(
LoginUsernameChanged event,
Emitter<LoginState> emit,
) {
final username = Username.dirty(event.username);
emit(
state.copyWith(
username: username,
isValid: Formz.validate([state.password, username]),
),
);
}
void _onPasswordChanged(
LoginPasswordChanged event,
Emitter<LoginState> emit,
) {
final password = Password.dirty(event.password);
emit(
state.copyWith(
password: password,
isValid: Formz.validate([password, state.username]),
),
);
}
Future<void> _onSubmitted(
LoginSubmitted event,
Emitter<LoginState> emit,
) async {
if (state.isValid) {
emit(state.copyWith(status: FormzSubmissionStatus.inProgress));
try {
await _authenticationRepository.logIn(
username: state.username.value,
password: state.password.value,
);
emit(state.copyWith(status: FormzSubmissionStatus.success));
} catch (_) {
emit(state.copyWith(status: FormzSubmissionStatus.failure));
}
}
}
}

LoginBloc은 폼이 submit될 때 logIn을 호출하므로 AuthenticationRepository에 의존합니다. bloc의 초기 상태는 pure로, 입력이나 폼이 아직 터치되거나 상호작용되지 않은 상태입니다.

username이나 password가 바뀔 때마다 bloc은 Username/Password 모델의 dirty variant를 생성하고 Formz.validate API를 통해 폼 상태를 업데이트합니다.

LoginSubmitted 이벤트가 추가되면 폼의 현재 상태가 valid인 경우 bloc이 logIn을 호출하고 요청 결과에 따라 상태를 업데이트합니다.

다음으로 LoginPageLoginForm을 살펴봅니다.

LoginPageRoute를 노출하고 LoginBloc을 생성해서 LoginForm에 제공하는 역할을 합니다.

lib/login/view/login_page.dart
import 'package:authentication_repository/authentication_repository.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_login/login/login.dart';
class LoginPage extends StatelessWidget {
const LoginPage({super.key});
static Route<void> route() {
return MaterialPageRoute<void>(builder: (_) => const LoginPage());
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.all(12),
child: BlocProvider(
create: (context) => LoginBloc(
authenticationRepository: context.read<AuthenticationRepository>(),
),
child: const LoginForm(),
),
),
);
}
}

LoginForm은 사용자 이벤트를 LoginBloc에 알리고 BlocBuilderBlocListener를 사용해서 상태 변화에 반응합니다.

lib/login/view/login_form.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_login/login/login.dart';
import 'package:formz/formz.dart';
class LoginForm extends StatelessWidget {
const LoginForm({super.key});
@override
Widget build(BuildContext context) {
return BlocListener<LoginBloc, LoginState>(
listener: (context, state) {
if (state.status.isFailure) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
const SnackBar(content: Text('Authentication Failure')),
);
}
},
child: Align(
alignment: const Alignment(0, -1 / 3),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_UsernameInput(),
const Padding(padding: EdgeInsets.all(12)),
_PasswordInput(),
const Padding(padding: EdgeInsets.all(12)),
_LoginButton(),
],
),
),
);
}
}
class _UsernameInput extends StatelessWidget {
@override
Widget build(BuildContext context) {
final displayError = context.select(
(LoginBloc bloc) => bloc.state.username.displayError,
);
return TextField(
key: const Key('loginForm_usernameInput_textField'),
onChanged: (username) {
context.read<LoginBloc>().add(LoginUsernameChanged(username));
},
decoration: InputDecoration(
labelText: 'username',
errorText: displayError != null ? 'invalid username' : null,
),
);
}
}
class _PasswordInput extends StatelessWidget {
@override
Widget build(BuildContext context) {
final displayError = context.select(
(LoginBloc bloc) => bloc.state.password.displayError,
);
return TextField(
key: const Key('loginForm_passwordInput_textField'),
onChanged: (password) {
context.read<LoginBloc>().add(LoginPasswordChanged(password));
},
obscureText: true,
decoration: InputDecoration(
labelText: 'password',
errorText: displayError != null ? 'invalid password' : null,
),
);
}
}
class _LoginButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isInProgressOrSuccess = context.select(
(LoginBloc bloc) => bloc.state.status.isInProgressOrSuccess,
);
if (isInProgressOrSuccess) return const CircularProgressIndicator();
final isValid = context.select((LoginBloc bloc) => bloc.state.isValid);
return ElevatedButton(
key: const Key('loginForm_continue_raisedButton'),
onPressed: isValid
? () => context.read<LoginBloc>().add(const LoginSubmitted())
: null,
child: const Text('Login'),
);
}
}

BlocListener는 로그인 submit이 실패하면 SnackBar를 표시하는 데 사용됩니다. 또한 context.select를 사용해서 각 위젯이 LoginState의 특정 부분에 효율적으로 접근하여 불필요한 rebuild를 방지합니다. onChanged 콜백은 username/password 변경을 LoginBloc에 알리는 데 사용됩니다.

_LoginButton 위젯은 폼의 상태가 valid인 경우에만 활성화되고, 폼이 submit되는 동안에는 CircularProgressIndicator가 대신 표시됩니다.

성공적인 logIn 요청 시 AuthenticationBloc의 상태가 authenticated로 바뀌고 사용자는 user의 id와 로그아웃 버튼이 표시되는 HomePage로 이동합니다.

├── lib
│ ├── home
│ │ ├── home.dart
│ │ └── view
│ │ └── home_page.dart

HomePagecontext.select((AuthenticationBloc bloc) => bloc.state.user.id)를 통해 현재 user id에 접근하고 Text 위젯을 통해 표시합니다. 또한 logout 버튼이 탭되면 AuthenticationBlocAuthenticationLogoutPressed 이벤트가 추가됩니다.

lib/home/view/home_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_login/authentication/authentication.dart';
class HomePage extends StatelessWidget {
const HomePage({super.key});
static Route<void> route() {
return MaterialPageRoute<void>(builder: (_) => const HomePage());
}
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [_UserId(), _LogoutButton()],
),
),
);
}
}
class _LogoutButton extends StatelessWidget {
const _LogoutButton();
@override
Widget build(BuildContext context) {
return ElevatedButton(
child: const Text('Logout'),
onPressed: () {
context.read<AuthenticationBloc>().add(AuthenticationLogoutPressed());
},
);
}
}
class _UserId extends StatelessWidget {
const _UserId();
@override
Widget build(BuildContext context) {
final userId = context.select(
(AuthenticationBloc bloc) => bloc.state.user.id,
);
return Text('UserID: $userId');
}
}

이 시점에서 꽤 괜찮은 로그인 구현이 있고, Bloc을 사용해서 프레젠테이션 레이어와 비즈니스 로직 레이어를 분리했습니다.

이 예제의 전체 소스 코드(단위 테스트와 위젯 테스트 포함)는 여기에서 확인할 수 있습니다.