felangel / bloc

A predictable state management library that helps implement the BLoC design pattern
https://bloclibrary.dev
MIT License
11.53k stars 3.37k forks source link

How to rely on data from another Stream? #4172

Closed DanMossa closed 1 month ago

DanMossa commented 1 month ago

I have two Blocs and one Repo. Bloc One is named AnsweredAsks:

class AnsweredAsksBloc extends Bloc<AnsweredAsksEvent, AnsweredAsksState> {
  AnsweredAsksBloc({required AnsweredAsksRepo answeredAsksRepo})
      : _answeredAsksRepo = answeredAsksRepo,
        super(const AnsweredAsksState(
          status: AnsweredAsksStatus.initial,
          answeredAsks: [],
          errorMessage: null,
        )) {
    on<_StreamRequested>(_onStreamRequested);
    on<_AskAnswered>(_onAskAnswered);
  }

  final AnsweredAsksRepo _answeredAsksRepo;

  Future<void> _onStreamRequested(_StreamRequested event, Emitter<AnsweredAsksState> emit) async {
    emit(
      state.copyWith(status: AnsweredAsksStatus.loading, errorMessage: null),
    );

    await emit.forEach<List<AnsweredAsksModel>>(
      _answeredAsksRepo.getAnsweredAsks(),
      onData: (List<AnsweredAsksModel> answeredAsks) {
        return state.copyWith(
          status: AnsweredAsksStatus.success,
          answeredAsks: answeredAsks,
          errorMessage: null,
        );
      },
      onError: (Object error, StackTrace stackTrace) {
        return state.copyWith(
          status: AnsweredAsksStatus.failure,
          errorMessage: error.toString(),
        );
      },
    );
  }

  Future<void> _onAskAnswered(_AskAnswered event, Emitter<AnsweredAsksState> emit) async {
    await _answeredAsksRepo.upsertAnsweredAsk(event.answeredAsk);
  }
}```

Repo is named `AnsweredAsksRepo`
```dart
class AnsweredAsksRepo {
  AnsweredAsksRepo() {
    unawaited(_init());
  }

  final _answeredAsksStreamController = BehaviorSubject<List<AnsweredAsksModel>>();
  Stream<List<AnsweredAsksModel>> getAnsweredAsks() =>
      _answeredAsksStreamController.asBroadcastStream();

  Future<void> _init() async {
    if (isUserShadowBanned || isUserBanned) {
      _answeredAsksStreamController.add([]);

      return;
    }

    try {
      final res = await databaseGetMyAnsweredAsks();

      final List<AnsweredAsksModel> answeredAsksFromDatabase = AnsweredAsksModel.fromJsons(res);

      final List<AnsweredAsksModel> answeredAsks = [
        ...(_answeredAsksStreamController.valueOrNull ?? [])
      ];
      answeredAsks.addAll(answeredAsksFromDatabase);

      _answeredAsksStreamController.add(answeredAsks);

      return;
    } catch (e, s) {
      _answeredAsksStreamController.add([]);

      return;
    }
  }

  Future<void> upsertAnsweredAsk(AnsweredAsksModel answeredAsk) async {
    final List<AnsweredAsksModel> answeredAsks = [
      ...(_answeredAsksStreamController.valueOrNull ?? [])
    ];

    final int existingIndex = answeredAsks.indexWhere(
      (element) => element.askId == answeredAsk.askId,
    );

    if (existingIndex == -1) {
      answeredAsks.add(answeredAsk);
    } else {
      answeredAsks[existingIndex] = answeredAsk;
    }

    _answeredAsksStreamController.add(answeredAsks);

    await databaseUpsertAnsweredAsk();
  }
}

And the second bloc is named CategoryAsksBloc

class CategoryAsksBloc extends Bloc<CategoryAsksEvent, CategoryAsksState> {
  CategoryAsksBloc({
    required AnsweredAsksRepo answeredAsksRepo,
    required constants.AskCategory askCategory,
  })  : _answeredAsksRepo = answeredAsksRepo,
        _askCategory = askCategory,
        super(const CategoryAsksState(
          status: CategoryAsksStatus.initial,
          asksToDisplay: [],
          hideAnsweredAsks: true,
          errorMessage: null,
        )) {
    on<_StreamRequested>(_onStreamRequested);
    on<_HideAnsweredToggled>(_onHideAnsweredToggled);
  }

  final constants.AskCategory _askCategory;
  final AnsweredAsksRepo _answeredAsksRepo;

  Future<void> _onStreamRequested(_StreamRequested event, Emitter<CategoryAsksState> emit) async {
    emit(
      state.copyWith(status: CategoryAsksStatus.loading, errorMessage: null),
    );

    await emit.forEach<List<AnsweredAsksModel>>(
      _answeredAsksRepo.getAnsweredAsks(),
      onData: (List<AnsweredAsksModel> answeredAsks) {
        final List<AskChoiceModel> asksToDisplay = [];

        final Iterable<AskModel> allAsksInCategory = (Data.allAsksMap[_askCategory] ?? {}).values;
        for (final AskModel askInCategory in allAsksInCategory) {
          final AnsweredAsksModel? answeredAsk =
              answeredAsks.firstWhereOrNull((element) => element.askId == askInCategory.id);

          if (state.hideAnsweredAsks && answeredAsk != null) {
            continue;
          }

          final AskChoiceModel choice = AskChoiceModel(
            question: askInCategory.question,
            askId: askInCategory.id,
            answered: answeredAsk != null,
            visibleStatus: answeredAsk?.visibleStatus ?? VisibleStatus.public,
          );

          asksToDisplay.add(choice);
        }

        return state.copyWith(
          status: CategoryAsksStatus.success,
          asksToDisplay: asksToDisplay,
          errorMessage: null,
        );
      },
      onError: (Object error, StackTrace stackTrace) {
        return state.copyWith(
          status: CategoryAsksStatus.failure,
          errorMessage: error.toString(),
        );
      },
    );
  }

  Future<void> _onHideAnsweredToggled(
      _HideAnsweredToggled event, Emitter<CategoryAsksState> emit) async {
    final bool newHideStatus = !state.hideAnsweredAsks;

    // I need to be able to get the current answered asks and filter them based on the newHideStatus.
    // How can I get those values here?

    emit(
      state.copyWith(
        asksToDisplay: filteredAsks,
        hideAnsweredAsks: !state.hideAnsweredAsks,
      ),
    );
  }
}

The repo is named AnsweredAsksRepo

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

  final _answeredAsksStreamController = BehaviorSubject<List<AnsweredAsksModel>>();
  Stream<List<AnsweredAsksModel>> getAnsweredAsks() =>
      _answeredAsksStreamController.asBroadcastStream();

  Future<void> _init() async {
    if (isUserShadowBanned || isUserBanned) {
      _answeredAsksStreamController.add([]);

      return;
    }

    try {
      final res = await databaseGetMyAnsweredAsks();

      final List<AnsweredAsksModel> answeredAsksFromDatabase = AnsweredAsksModel.fromJsons(res);

      final List<AnsweredAsksModel> answeredAsks = [
        ...(_answeredAsksStreamController.valueOrNull ?? [])
      ];
      answeredAsks.addAll(answeredAsksFromDatabase);

      _answeredAsksStreamController.add(answeredAsks);

      return;
    } catch (e, s) {
      _answeredAsksStreamController.add([]);

      return;
    }
  }

  Future<void> upsertAnsweredAsk(AnsweredAsksModel answeredAsk) async {
    final List<AnsweredAsksModel> answeredAsks = [
      ...(_answeredAsksStreamController.valueOrNull ?? [])
    ];

    final int existingIndex = answeredAsks.indexWhere(
      (element) => element.askId == answeredAsk.askId,
    );

    if (existingIndex == -1) {
      answeredAsks.add(answeredAsk);
    } else {
      answeredAsks[existingIndex] = answeredAsk;
    }

    _answeredAsksStreamController.add(answeredAsks);

    await databaseUpsertAnsweredAsk();
  }
}

The big picture is that AnsweredAsks holds all the asks that have been answered. I then have a view named Catagory Asks view. This view basically runs a filter from the answeredAsks, one of the filters is whether or not hideAnsweredAsks is true or false.

In my CategoryAsksBloc, I listen in on the ansewerdAsksRepo stream. This works great. The issue I'm having is that once _HideAnsweredToggled is called, I need to filter answered asks and then emit a a new state with the new answeredAsks, but I don't have access to the variable answeredAsks from the stream _answeredAsksRepo.getAnsweredAsks()

What's the best to go about doing this?

ksyro98 commented 1 month ago

Hey!

First of all, I think it's worth taking a moment to think on whether filtering should be managed by your Blocs or by the Presentation layer. This is a good resource on that.

With that out of the way, you can probably store that variable in your state. I notice that you already store asksToDisplay in your state, so perhaps you could also store answeredAsks or something similar. You could also store only answeredAsks and a filter variable that can be used to dynamically determine which asks to display.

Does this help? Am I understanding the situation correctly?

DanMossa commented 1 month ago

@ksyro98 You are understanding correctly! I really really appreciate the response.

You make a good point about where the filtering should take place.

My understanding was that all logic for what the UI displays should be in the Bloc, which is why I was trying to do it there. Is that not the right move? Should I not be having the value of asksToDisplay in my state and instead just have the BlocBuilder rebuild and the filtering is done inside the builder?

Storing answeredAsks in the state from the AnsweredAsksRepo stream would work, but then I have the value of answeredAsks in two different blocs, which is fine if they're both synced up, but there's no clear source of truth this way since I could change the value of answeredAsks in the CategoryBloc which wouldn't effect the answeredAsks in the repo.

Do my concerns make any sense?

ksyro98 commented 1 month ago

Hey @DanMossa! Yes they make sense!

In my experience, there isn't a hard rule on whether all of the logic for the UI needs to be in a Bloc. It depends on the architecture you use.

What I see most often is that not all of the logic for the UI has to be in a Bloc. Specifically, UI logic can stay in the widgets, while business logic can be in the blocs. Which is more or less equivalent to saying "manage ephemeral state in widgets and app state in Blocs".

It's worth noting that logic which includes BuildContext (e.g. localization logic or navigation logic) should never be in the Blocs, because Blocs are framework independent (the framework being Flutter).

For your case both approaches are valid, which is why I didn't recommend anything in particular in my previous response. I would personally manage filtering in the widget classes, and hold all of the items in the Bloc, but this isn't a hard rule, it's more of a personal preference. (Or there might be a rule I'm not aware of.)


You are correct that if you also store the state in the Blocs you might end up without a single source of truth. Good point!

Since you're using a behavior stream could you maybe get the answeredAsks by getting the stream's last value? Otherwise which is currently your single source of truth and can the CategoryAsksBloc access it?

felangel commented 1 month ago

Closing this for now since it sounds like the issue was resolved. Feel free to comment with additional questions/thoughts if that's not the case and happy to continue the conversation 👍