Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to handle multiple success states with rxdart? #4342

Open
DanMossa opened this issue Jan 21, 2025 · 3 comments
Open

How to handle multiple success states with rxdart? #4342

DanMossa opened this issue Jan 21, 2025 · 3 comments
Assignees
Labels
question Further information is requested waiting for response Waiting for follow up

Comments

@DanMossa
Copy link

DanMossa commented Jan 21, 2025

I'm following the todos guide and changed it to my use case.
I'm having trouble understanding how to actually handle different success/loading states in the UI.

Here is what I have so far.

My bloc file.
The _StreamRequested event is called upon creation of the blocprovider.
This sets the state to initial while I do all the fetching in the repo and then the repo adds a value to the stream and a the status is then changed to success and on success, the UI is updated to display the new value.

The save button in my UI calls the _onEmailChanged event.
This changes the state to loading and my UI then updates the save button to say loading.
Inside the repo, if this call is successful _authenticationRepo.changeEmail it adds the value to stream, which then triggers this bloc's onData and the status is set to success. Otherwise, _authenticationRepo.changeEmail adds an error and the onError is called and the status is set to failure.

This issue I'm having is that in the UI, I want to create a listener that displays a popup if _authenticationRepo.changeEmail is successful.
But as you can see, there's currently two instances of when success would happen and potentially even more in the future.
I can't make my UI listener just listen to when the status is success, because that'll even happen during initialization.

So what's the correct/proper way to handle this?

Thanks!

class AuthenticationBloc extends Bloc<AuthenticationEvent, AuthenticationState> {
  AuthenticationBloc({required AuthenticationRepo authenticationRepo})
      : _authenticationRepo = authenticationRepo,
        super(const AuthenticationState(
          status: AuthenticationStatus.initial,
          authentication: AuthenticationModel(
            email: null,
          ),
          error: null,
        )) {
    on<_StreamRequested>(_onStreamRequested);
    on<_EmailChanged>(_onEmailChanged);
  }

  final AuthenticationRepo _authenticationRepo;

  Future<void> _onStreamRequested(_StreamRequested event, Emitter<AuthenticationState> emit) async {
    emit(
      state.copyWith(status: AuthenticationStatus.initial, error: null),
    );

    await emit.forEach<AuthenticationModel>(
      _authenticationRepo.getAuthentication(),
      onData: (AuthenticationModel authentication) {
        return state.copyWith(
          status: AuthenticationStatus.success,
          authentication: authentication,
          error: null,
        );
      },
      onError: (Object error, StackTrace stackTrace) {
        return state.copyWith(
          status: AuthenticationStatus.failure,
          error: error as ErrorModel,
        );
      },
    );
  }

  Future<void> _onEmailChanged(_EmailChanged event, Emitter<AuthenticationState> emit) async {
    emit(
      state.copyWith(status: AuthenticationStatus.loading, error: null),
    );

    await _authenticationRepo.changeEmail(event.email);
  }
}
@DanMossa DanMossa added the documentation Documentation requested label Jan 21, 2025
@DanMossa
Copy link
Author

https://bloclibrary.dev/tutorials/flutter-login/#app

Looking at this example, this seems impossible to do with rxdart and streams.
If a user clicks a button and an event is emitted, the only status that is given is Success. This makes it impossible to have multiple "success" states like Authorized or Unauthorized since only one state is emitted after calling the repo.

@DanMossa
Copy link
Author

Here is a condensed reproducible example:

ChangeEmailView.dart

class ChangeEmailView extends StatefulWidget {
  const ChangeEmailView({super.key});

  @override
  State<ChangeEmailView> createState() => _ChangeEmailViewState();
}

class _ChangeEmailViewState extends State<ChangeEmailView> {
  final GlobalKey<FormBuilderState> formKey = GlobalKey<FormBuilderState>();

  @override
  Widget build(BuildContext parentContext) {
    return Scaffold(
      body: BlocProvider(
        create: (context) => ChangeEmailBloc(
          authenticationRepo: context.read<AuthenticationRepo>(),
        )..add(const ChangeEmailEvent.streamRequested()),
        child: BlocListener<ChangeEmailBloc, ChangeEmailState>(
          listener: (BuildContext context, ChangeEmailState state) async {
            // I want to navigate users to either BottomNavView() or SignUpView()

            // If the user taps "I want to keep using" -> ChangeEmailEvent.emailNotChanged()
            // We should navigate to BottomNavView()

            // If the user taps "Change email" -> ChangeEmailEvent.emailChanged(email: email)
            // We should navigate to SignUpView()
          },
          child: Column(
            children: [
              FormBuilderTextField(name: "email"),
              Column(
                children: [
                  BlocBuilder<ChangeEmailBloc, ChangeEmailState>(
                    builder: (context, state) {
                      final ErrorModel? error = state.error;
                      final bool isErrorVisible = state.status == ChangeEmailStatus.failure &&
                          error != null &&
                          !error.isRetryable;

                      if (isErrorVisible) {
                        return Padding(
                          padding: const EdgeInsets.only(bottom: 8.0),
                          child: Text(
                            error.message,
                            style: TextStyle(color: Theme.of(context).colorScheme.error),
                          ),
                        );
                      } else {
                        return const SizedBox.shrink();
                      }
                    },
                  ),
                  BlocBuilder<ChangeEmailBloc, ChangeEmailState>(
                    builder: (context, state) {
                      return IntroBottomButton(
                        "Change email",
                        isLoading: state.status == ChangeEmailStatus.loading,
                        isEnabled: state.status != ChangeEmailStatus.initial,
                        onPressed: () async {
                          final FormBuilderState? currentState = formKey.currentState;
                          final bool? isFormValid = currentState?.saveAndValidate();
                          if (currentState == null || isFormValid != true) {
                            return;
                          }

                          final String email = formKey.currentState?.value["email"];

                          final ChangeEmailEvent newEmailLinkEvent = ChangeEmailEvent.emailChanged(
                            email: email,
                          );
                          context.read<ChangeEmailBloc>().add(newEmailLinkEvent);

                          return;
                        },
                      );
                    },
                  ),
                  BlocBuilder<ChangeEmailBloc, ChangeEmailState>(
                    builder: (context, state) {
                      final bool isEnabled = [ChangeEmailStatus.initial, ChangeEmailStatus.loading]
                          .contains(state.status);

                      return TextButton(
                        onPressed: isEnabled
                            ? () {
                                const ChangeEmailEvent emailNotChangedEvent =
                                    ChangeEmailEvent.emailNotChanged();

                                context.read<ChangeEmailBloc>().add(emailNotChangedEvent);
                              }
                            : null,
                        child: Text(
                          "I want to keep using\n${state.email}",
                          textAlign: TextAlign.center,
                        ),
                      );
                    },
                  ),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }
}

ChangeEmailState

part of 'change_email_bloc.dart';

enum ChangeEmailStatus { initial, loading, success, failure }

@freezed
class ChangeEmailState with _$ChangeEmailState {
  const factory ChangeEmailState({
    required ChangeEmailStatus status,
    required String? email,
    required ErrorModel? error,
  }) = _ChangeEmailState;
}

ChangeEmailBloc

class ChangeEmailBloc extends Bloc<ChangeEmailEvent, ChangeEmailState> {
  ChangeEmailBloc({required AuthenticationRepo authenticationRepo})
      : _authenticationRepo = authenticationRepo,
        super(const ChangeEmailState(
          status: ChangeEmailStatus.initial,
          email: null,
          error: null,
        )) {
    on<_StreamRequested>(_onStreamRequested);
    on<_EmailChanged>(_onEmailChanged);
    on<_EmailNotChanged>(_onEmailNotChanged);
  }

  final AuthenticationRepo _authenticationRepo;

  Future<void> _onStreamRequested(_StreamRequested event, Emitter<ChangeEmailState> emit) async {
    emit(
      state.copyWith(status: ChangeEmailStatus.initial, error: null),
    );

    await emit.forEach<AuthenticationModel>(
      _authenticationRepo.getAuthentication(),
      onData: (AuthenticationModel authentication) {
        return state.copyWith(
          status: ChangeEmailStatus.success,
          email: authentication.email,
          error: null,
        );
      },
      onError: (Object error, StackTrace stackTrace) {
        final AuthenticationModel? lastValue = _authenticationRepo.getLastValue();

        return state.copyWith(
          status: ChangeEmailStatus.failure,
          email: lastValue?.email,
          error: error as ErrorModel,
        );
      },
    );
  }

  Future<void> _onEmailChanged(_EmailChanged event, Emitter<ChangeEmailState> emit) async {
    emit(
      state.copyWith(status: ChangeEmailStatus.loading, error: null),
    );

    await _authenticationRepo.changeEmail(event.email);
  }

  Future<void> _onEmailNotChanged(_EmailNotChanged event, Emitter<ChangeEmailState> emit) async {
    emit(
      state.copyWith(status: ChangeEmailStatus.loading, error: null),
    );

    await _authenticationRepo.skipChangeEmail();
  }
}

ChangeEmailEvent

part of 'change_email_bloc.dart';

@freezed
sealed class ChangeEmailEvent with _$ChangeEmailEvent {
  const factory ChangeEmailEvent.streamRequested() = _StreamRequested;

  const factory ChangeEmailEvent.emailChanged({
    required String email,
  }) = _EmailChanged;

  const factory ChangeEmailEvent.emailNotChanged() = _EmailNotChanged;
}

AuthenticationRepo

class AuthenticationRepo {
  AuthenticationRepo() {
    unawaited(_init());
  }

  final SupabaseClient _supabase = Supabase.instance.client;
  final _authenticationStreamController = BehaviorSubject<AuthenticationModel>();

  Stream<AuthenticationModel> getAuthentication() =>
      _authenticationStreamController.asBroadcastStream();

  AuthenticationModel? getLastValue() => _authenticationStreamController.valueOrNull;

  Future<void> _init() async {
    try {
      final UserResponse userFromDb = await _supabase.auth.getUser();

      final AuthenticationModel authenticationModel = AuthenticationModel(
        email: userFromDb.user?.email,
      );
      _authenticationStreamController.add(authenticationModel);

      return;
    } catch (e, s) {
      const ErrorModel error = ErrorModel(
        title: "Error",
        message: "Unable to grab current email. Please try again.",
        isRetryable: true,
      );
      _authenticationStreamController.addError(error);

      return;
    }
  }

  Future<void> changeEmail(String email) async {
    final UserAttributes userAttributes = UserAttributes(
      email: email,
    );

    try {
      final UserResponse res = await _supabase.auth.updateUser(
        userAttributes,
        emailRedirectTo: 'https://datefirefly.com/account-verified',
      );

      final AuthenticationModel authenticationModel =
          _authenticationStreamController.value.copyWith(
        email: res.user?.newEmail,
      );

      _authenticationStreamController.add(authenticationModel);

      return;
    } catch (e, s) {
      final ErrorModel publicError = buildUserFacingError(e);

      _authenticationStreamController.addError(publicError);

      return;
    }
  }

  Future<void> skipChangeEmail(String email) async {
    try {
      final res = await _supabase.rpc(
        'skip-email',
      );

      final AuthenticationModel authenticationModel =
          _authenticationStreamController.value.copyWith(
        email: res.user?.newEmail,
      );

      _authenticationStreamController.add(authenticationModel);

      return;
    } catch (e, s) {
      final ErrorModel publicError = buildUserFacingError(e);

      _authenticationStreamController.addError(publicError);

      return;
    }
  }
}

You can see that whether or not the the event _onEmailChanged or _onEmailNotChanged is used, the resulting status is ChangeEmailStatus.success. Because there's no way for me change the status depending on what was called, for example EmailChangeSuccess or EmailNotChangedSuccess , I'm not sure how I can make the listener do different things depending on the status.

@DanMossa DanMossa changed the title How to handle multiple success states? How to handle multiple success states with rxdart? Jan 22, 2025
@felangel
Copy link
Owner

You can see that whether or not the the event _onEmailChanged or _onEmailNotChanged is used, the resulting status is ChangeEmailStatus.success. Because there's no way for me change the status depending on what was called, for example EmailChangeSuccess or EmailNotChangedSuccess , I'm not sure how I can make the listener do different things depending on the status.

You are in full control of the state so there's nothing stopping you from changing the state to contain another property/enum:

enum ChangeEmailStatus { initial, loading, success, failure }

@freezed
class ChangeEmailState with _$ChangeEmailState {
  const factory ChangeEmailState({
    required ChangeEmailStatus status,
    required bool emailChanged,
    required String? email,
    required ErrorModel? error,
  }) = _ChangeEmailState;
}

Then you can conditionally do whatever you need to do based on the emailChanged. This is just to illustrate how you're in full control over how you want to model the state (the bloc library doesn't care).

In general, if the user decides not to change their email why do you even need to notify the bloc at all? There doesn't appear to be any logic needed -- you can just imperatively navigate to whatever screen/page you want directly.

@felangel felangel added question Further information is requested waiting for response Waiting for follow up and removed documentation Documentation requested labels Jan 23, 2025
@felangel felangel self-assigned this Jan 23, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Further information is requested waiting for response Waiting for follow up
Projects
None yet
Development

No branches or pull requests

2 participants