Skip to content

Commit

Permalink
Merge branch 'master' into refactor/post-fetched
Browse files Browse the repository at this point in the history
  • Loading branch information
felangel authored Sep 3, 2024
2 parents 13a0fee + d4d9f06 commit 4fc98f1
Show file tree
Hide file tree
Showing 35 changed files with 817 additions and 670 deletions.
4 changes: 4 additions & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ export default defineConfig({
label: 'Architecture',
link: '/architecture/',
},
{
label: 'Modeling State',
link: '/modeling-state/',
},
{
label: 'Testing',
link: '/testing/',
Expand Down
7 changes: 3 additions & 4 deletions docs/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
import { Code } from '@astrojs/starlight/components';
const code = `
enum TodoStatus { initial, loading, success, failure }
final class TodoState {
const TodoState({
this.status = TodoStatus.initial,
this.todos = const <Todo>[],
this.exception = null,
});
final TodoStatus status;
final List<Todos> todos;
final Exception? exception;
}
`;
---

<Code code={code} lang="dart" title="todo_state.dart" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
import { Code } from '@astrojs/starlight/components';
const code = `
sealed class WeatherState {
const WeatherState();
}
final class WeatherInitial extends WeatherState {
const WeatherInitial();
}
final class WeatherLoadInProgress extends WeatherState {
const WeatherLoadInProgress();
}
final class WeatherLoadSuccess extends WeatherState {
const WeatherLoadSuccess({required this.weather});
final Weather weather;
}
final class WeatherLoadFailure extends WeatherState {
const WeatherLoadFailure({required this.exception});
final Exception exception;
}
`;
---

<Code code={code} lang="dart" title="weather_state.dart" />
98 changes: 98 additions & 0 deletions docs/src/content/docs/modeling-state.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
---
title: Modeling State
description: An overview of several ways to model states when using package:bloc.
---

import ConcreteClassAndStatusEnumSnippet from '~/components/modeling-state/ConcreteClassAndStatusEnumSnippet.astro';
import SealedClassAndSubclassesSnippet from '~/components/modeling-state/SealedClassAndSubclassesSnippet.astro';

There are many different approaches when it comes to structuring application
state. Each has its own advantages and drawbacks. In this section, we'll take a
look at several approaches, their pros and cons, and when to use each one.

The following approaches are simply recommendations and are completely optional.
Feel free to use whatever approach you prefer. You may find some of the
examples/documentation do not follow the approaches mainly for
simplicity/conciseness.

:::tip

The following code snippets are focused on the state structure. In practice, you
may also want to:

- Extend `Equatable` from [`package:equatable`](https://pub.dev/packages/equatable)
- Annotate the class with `@Data()` from [`package:data_class`](https://pub.dev/packages/data_class)
- Annotate the class with **@immutable** from [`package:meta`](https://pub.dev/packages/meta)
- Implement a `copyWith` method
- Use the `const` keyword for constructors

:::

## Concrete Class and Status Enum

This approach consists of a **single concrete class** for all states along with
an `enum` representing different statuses. Properties are made nullable and are
handled based on the current status. This approach works best for states which
are not strictly exclusive and/or contain lots of shared properties.

<ConcreteClassAndStatusEnumSnippet />

#### Pros

- **Simple**: Easy to manage a single class and a status enum and all properties are readily accessible.
- **Concise**: Generally requires fewer lines of code as compared to other approaches.

#### Cons

- **Not Type Safe**: Requires checking the `status` before accessing properties.
It's possible to `emit` a malformed state which can lead to bugs. Properties
for specific states are nullable, which can be cumbersome to manage and
requires either force unwrapping or performing null checks. Some of these cons
can be mitigated by writing unit tests and writing specialized, named
constructors.
- **Bloated**: Results in a single state that can become bloated with many
properties over time.

#### Verdict

This approach works best for simple states or when the requirements call for
states that aren't exclusive (e.g. showing a snackbar when an error occurs while
still showing old data from the last success state). This approach provides
flexibility and conciseness at the cost of type safety.

## Sealed Class and Subclasses

This approach consists of a **sealed class** that holds any shared properties
and multiple subclasses for the separate states. This approach is great for
separate, exclusive states.

<SealedClassAndSubclassesSnippet />

#### Pros

- **Type Safe**: The code is compile-safe and it's not possible to accidentally
access an invalid property. Each subclass holds its own properties, making it
clear which properties belong to which state.
- **Explicit:** Separates shared properties from state-specific properties.
- **Exhaustive**: Using a `switch` statement for exhaustiveness checks
to ensure that each state is explicitly handled.
- If you don't want [exhaustive
switching](https://dart.dev/language/branches#exhaustiveness-checking) or
want to be able to add subtypes later without breaking the API, use the
[final](https://dart.dev/language/class-modifiers#final) modifier.
- See the [sealed class documentation](https://dart.dev/language/class-modifiers#sealed) for more details.

#### Cons

- **Verbose**: Requires more code (one base class and a subclass per state).
Also may require duplicate code for shared properties across subclasses.
- **Complex**: Adding new properties requires updating each subclass and the
base class, which can be cumbersome and lead to increases in complexity of the
state. In addition, may require unnecessary/excessive type checking to access
properties.

#### Verdict

This approach works best for well-defined, exclusive states with unique
properties. This approach provides type safety & exhaustiveness checks and emphasizes
safety over conciseness and simplicity.
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
6 changes: 3 additions & 3 deletions docs/src/content/docs/tutorials/flutter-infinite-list.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -170,11 +170,11 @@ Now every time a `PostEvent` is added, if it is a `PostFetched` event and there

The API will return an empty array if we try to fetch beyond the maximum number of posts (100), so if we get back an empty array, our bloc will `emit` the currentState except we will set `hasReachedMax` to true.

If we cannot retrieve the posts, we throw an exception and `emit` `PostFailure()`.
If we cannot retrieve the posts, we emit `PostStatus.failure`.

If we can retrieve the posts, we return `PostSuccess()` which takes the entire list of posts.
If we can retrieve the posts, we emit `PostStatus.success` and the entire list of posts.

One optimization we can make is to `debounce` the `Events` in order to prevent spamming our API unnecessarily. We can do this by overriding the `transform` method in our `PostBloc`.
One optimization we can make is to `throttle` the `PostFetched` event in order to prevent spamming our API unnecessarily. We can do this by using the `transform` parameter when we register the `_onPostFetched` event handler.

:::note
Passing a `transformer` to `on<PostFetched>` allows us to customize how events are processed.
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];
}
Loading

0 comments on commit 4fc98f1

Please sign in to comment.