Skip to content

Commit

Permalink
refactor(flutter_firebase_login): use emit.onEach in AppBloc (#4223)
Browse files Browse the repository at this point in the history
Co-authored-by: Felix Angelov <[email protected]>
  • Loading branch information
LukasMirbt and felangel authored Aug 27, 2024
1 parent a43469c commit 0c68c96
Show file tree
Hide file tree
Showing 16 changed files with 97 additions and 129 deletions.
20 changes: 15 additions & 5 deletions docs/src/content/docs/tutorials/flutter-firebase-login.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ The `AppBloc` is responsible for managing the global state of the application. I

### State

The `AppState` consists of an `AppStatus` and a `User`. Two named constructors are exposed: `unauthenticated` and `authenticated` to make it easier to work with.
The `AppState` consists of an `AppStatus` and a `User`. The default constructor accepts an optional `User` and redirects to the private constructor with the appropriate authentication status.

<RemoteCode
url="https://raw.githubusercontent.com/felangel/bloc/master/examples/flutter_firebase_login/lib/app/bloc/app_state.dart"
Expand All @@ -170,8 +170,8 @@ The `AppState` consists of an `AppStatus` and a `User`. Two named constructors a

The `AppEvent` has two subclasses:

- `AppUserChanged` which notifies the bloc that the current user has changed.
- `AppLogoutRequested` which notifies the bloc that the current user has requested to be logged out.
- `AppUserSubscriptionRequested` which notifies the bloc to subscribe to the user stream.
- `AppLogoutPressed` which notifies the bloc of a user logout action.

<RemoteCode
url="https://raw.githubusercontent.com/felangel/bloc/master/examples/flutter_firebase_login/lib/app/bloc/app_event.dart"
Expand All @@ -180,10 +180,20 @@ The `AppEvent` has two subclasses:

### Bloc

The `AppBloc` responds to incoming `AppEvents` and transforms them into outgoing `AppStates`. Upon initialization, it immediately subscribes to the `user` stream from the `AuthenticationRepository` and adds an `AuthenticationUserChanged` event internally to process changes in the current user.
In the constructor body, `AppEvent` subclasses are mapped to their corresponding event handlers.

In the `_onUserSubscriptionRequested` event handler, the `AppBloc` uses `emit.onEach` to subscribe to the user stream of the `AuthenticationRepository` and emit a state in response to each `User`.

`emit.onEach` creates a stream subscription internally and takes care of canceling it when either `AppBloc` or the user stream is closed.

If the user stream emits an error, `addError` forwards the error and stack trace to any `BlocObserver` listening.

:::caution
`close` is overridden in order to handle cancelling the internal `StreamSubscription`.
If `onError` is omitted, any errors on the user stream are considered unhandled, and will be thrown by `onEach`. As a result, the subscription to the user stream will be canceled.
:::

:::tip
A [`BlocObserver`](/bloc-concepts/#blocobserver-1) is great for logging Bloc events, errors, and state changes especially in the context analytics and crash reporting.
:::

<RemoteCode
Expand Down
3 changes: 1 addition & 2 deletions examples/flutter_firebase_login/ios/Podfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '11.0'
# platform :ios, '12.0'

# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
Expand Down Expand Up @@ -28,7 +28,6 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe
flutter_ios_podfile_setup

target 'Runner' do
pod 'FirebaseFirestore', :git => 'https://github.com/invertase/firestore-ios-sdk-frameworks.git', :tag => $FirebaseSDKVersion
use_frameworks!
use_modular_headers!

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import UIKit
import Flutter

@UIApplicationMain
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
Expand Down
40 changes: 16 additions & 24 deletions examples/flutter_firebase_login/lib/app/bloc/app_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,36 +10,28 @@ part 'app_state.dart';
class AppBloc extends Bloc<AppEvent, AppState> {
AppBloc({required AuthenticationRepository authenticationRepository})
: _authenticationRepository = authenticationRepository,
super(
authenticationRepository.currentUser.isNotEmpty
? AppState.authenticated(authenticationRepository.currentUser)
: const AppState.unauthenticated(),
) {
on<_AppUserChanged>(_onUserChanged);
on<AppLogoutRequested>(_onLogoutRequested);
_userSubscription = _authenticationRepository.user.listen(
(user) => add(_AppUserChanged(user)),
);
super(AppState(user: authenticationRepository.currentUser)) {
on<AppUserSubscriptionRequested>(_onUserSubscriptionRequested);
on<AppLogoutPressed>(_onLogoutPressed);
}

final AuthenticationRepository _authenticationRepository;
late final StreamSubscription<User> _userSubscription;

void _onUserChanged(_AppUserChanged event, Emitter<AppState> emit) {
emit(
event.user.isNotEmpty
? AppState.authenticated(event.user)
: const AppState.unauthenticated(),
Future<void> _onUserSubscriptionRequested(
AppUserSubscriptionRequested event,
Emitter<AppState> emit,
) {
return emit.onEach(
_authenticationRepository.user,
onData: (user) => emit(AppState(user: user)),
onError: addError,
);
}

void _onLogoutRequested(AppLogoutRequested event, Emitter<AppState> emit) {
unawaited(_authenticationRepository.logOut());
}

@override
Future<void> close() {
_userSubscription.cancel();
return super.close();
void _onLogoutPressed(
AppLogoutPressed event,
Emitter<AppState> emit,
) {
_authenticationRepository.logOut();
}
}
10 changes: 4 additions & 6 deletions examples/flutter_firebase_login/lib/app/bloc/app_event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@ sealed class AppEvent {
const AppEvent();
}

final class AppLogoutRequested extends AppEvent {
const AppLogoutRequested();
final class AppUserSubscriptionRequested extends AppEvent {
const AppUserSubscriptionRequested();
}

final class _AppUserChanged extends AppEvent {
const _AppUserChanged(this.user);

final User user;
final class AppLogoutPressed extends AppEvent {
const AppLogoutPressed();
}
21 changes: 9 additions & 12 deletions examples/flutter_firebase_login/lib/app/bloc/app_state.dart
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
part of 'app_bloc.dart';

enum AppStatus {
authenticated,
unauthenticated,
}
enum AppStatus { authenticated, unauthenticated }

final class AppState extends Equatable {
const AppState._({
required this.status,
this.user = User.empty,
});

const AppState.authenticated(User user)
: this._(status: AppStatus.authenticated, user: user);
const AppState({User user = User.empty})
: this._(
status: user == User.empty
? AppStatus.unauthenticated
: AppStatus.authenticated,
user: user,
);

const AppState.unauthenticated() : this._(status: AppStatus.unauthenticated);
const AppState._({required this.status, this.user = User.empty});

final AppStatus status;
final User user;
Expand Down
3 changes: 2 additions & 1 deletion examples/flutter_firebase_login/lib/app/view/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ class App extends StatelessWidget {
return RepositoryProvider.value(
value: _authenticationRepository,
child: BlocProvider(
lazy: false,
create: (_) => AppBloc(
authenticationRepository: _authenticationRepository,
),
)..add(const AppUserSubscriptionRequested()),
child: const AppView(),
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class HomePage extends StatelessWidget {
key: const Key('homePage_logout_iconButton'),
icon: const Icon(Icons.exit_to_app),
onPressed: () {
context.read<AppBloc>().add(const AppLogoutRequested());
context.read<AppBloc>().add(const AppLogoutPressed());
},
),
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,6 @@ class User extends Equatable {
/// Empty user which represents an unauthenticated user.
static const empty = User(id: '');

/// Convenience getter to determine whether the current user is empty.
bool get isEmpty => this == User.empty;

/// Convenience getter to determine whether the current user is not empty.
bool get isNotEmpty => this != User.empty;

@override
List<Object?> get props => [email, id, name, photo];
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,5 @@ void main() {
equals(User(email: email, id: id)),
);
});

test('isEmpty returns true for empty user', () {
expect(User.empty.isEmpty, isTrue);
});

test('isEmpty returns false for non-empty user', () {
final user = User(email: email, id: id);
expect(user.isEmpty, isFalse);
});

test('isNotEmpty returns false for empty user', () {
expect(User.empty.isNotEmpty, isFalse);
});

test('isNotEmpty returns true for non-empty user', () {
final user = User(email: email, id: id);
expect(user.isNotEmpty, isTrue);
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ environment:
sdk: ">=3.0.0 <4.0.0"

dependencies:
formz: ^0.6.0
formz: ^0.7.0
6 changes: 3 additions & 3 deletions examples/flutter_firebase_login/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ dependencies:
bloc: ^8.1.0
equatable: ^2.0.3
firebase_core: ^3.0.0
flow_builder: ^0.0.9
flow_builder: ^0.1.0
flutter:
sdk: flutter
flutter_bloc: ^8.1.1
font_awesome_flutter: ^10.1.0
form_inputs:
path: packages/form_inputs
formz: ^0.6.0
google_fonts: ^4.0.0
formz: ^0.7.0
google_fonts: ^6.2.1
meta: ^1.7.0

dev_dependencies:
Expand Down
55 changes: 27 additions & 28 deletions examples/flutter_firebase_login/test/app/bloc/app_bloc_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,69 +11,68 @@ class MockAuthenticationRepository extends Mock
class MockUser extends Mock implements User {}

void main() {
group('AppBloc', () {
group(AppBloc, () {
final user = MockUser();
late AuthenticationRepository authenticationRepository;

setUp(() {
authenticationRepository = MockAuthenticationRepository();
when(() => authenticationRepository.user).thenAnswer(
(_) => Stream.empty(),
(_) => const Stream.empty(),
);
when(
() => authenticationRepository.currentUser,
).thenReturn(User.empty);
).thenReturn(user);
});

test('initial state is unauthenticated when user is empty', () {
expect(
AppBloc(authenticationRepository: authenticationRepository).state,
AppState.unauthenticated(),
AppBloc buildBloc() {
return AppBloc(
authenticationRepository: authenticationRepository,
);
}

test('initial state is $AppState', () {
expect(buildBloc().state, equals(AppState(user: user)));
});

group('UserChanged', () {
group(AppUserSubscriptionRequested, () {
final error = Exception('oops');

blocTest<AppBloc, AppState>(
'emits authenticated when user is not empty',
'emits $AppState when user stream emits a new value',
setUp: () {
when(() => user.isNotEmpty).thenReturn(true);
when(() => authenticationRepository.user).thenAnswer(
(_) => Stream.value(user),
);
},
build: () => AppBloc(
authenticationRepository: authenticationRepository,
),
seed: AppState.unauthenticated,
expect: () => [AppState.authenticated(user)],
build: buildBloc,
act: (bloc) => bloc.add(AppUserSubscriptionRequested()),
expect: () => [AppState(user: user)],
);

blocTest<AppBloc, AppState>(
'emits unauthenticated when user is empty',
'adds error when user stream emits an error',
setUp: () {
when(() => authenticationRepository.user).thenAnswer(
(_) => Stream.value(User.empty),
);
when(
() => authenticationRepository.user,
).thenAnswer((_) => Stream.error(error));
},
build: () => AppBloc(
authenticationRepository: authenticationRepository,
),
expect: () => [AppState.unauthenticated()],
build: buildBloc,
act: (bloc) => bloc.add(AppUserSubscriptionRequested()),
errors: () => [error],
);
});

group('LogoutRequested', () {
group(AppLogoutPressed, () {
blocTest<AppBloc, AppState>(
'invokes logOut',
setUp: () {
when(
() => authenticationRepository.logOut(),
).thenAnswer((_) async {});
},
build: () => AppBloc(
authenticationRepository: authenticationRepository,
),
act: (bloc) => bloc.add(AppLogoutRequested()),
build: buildBloc,
act: (bloc) => bloc.add(AppLogoutPressed()),
verify: (_) {
verify(() => authenticationRepository.logOut()).called(1);
},
Expand Down
26 changes: 12 additions & 14 deletions examples/flutter_firebase_login/test/app/bloc/app_state_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,20 @@ import 'package:mocktail/mocktail.dart';
class MockUser extends Mock implements User {}

void main() {
group('AppState', () {
group('unauthenticated', () {
test('has correct status', () {
final state = AppState.unauthenticated();
expect(state.status, AppStatus.unauthenticated);
expect(state.user, User.empty);
});
group(AppState, () {
test(
'returns state with status unauthenticated '
'when user is empty', () {
expect(AppState().status, equals(AppStatus.unauthenticated));
});

group('authenticated', () {
test('has correct status', () {
final user = MockUser();
final state = AppState.authenticated(user);
expect(state.status, AppStatus.authenticated);
expect(state.user, user);
});
test(
'returns state with status authenticated and user '
'when user is not empty', () {
final user = MockUser();
final state = AppState(user: user);
expect(state.status, equals(AppStatus.authenticated));
expect(state.user, equals(user));
});
});
}
Loading

0 comments on commit 0c68c96

Please sign in to comment.