Загрузка данных по init с помощью BLoC - PullRequest
2 голосов
/ 22 января 2020

Новый для флаттера с BLo C. Построение шаблона поиска с загрузкой данных (items) при загрузке приложения, а не при изменении состояния.

Метод getCrystals() возвращает правильные данные, когда цель поиска .isEmpty, но как это можно сделать при загрузке приложения?

crystal_repo.dart

abstract class CrystalRepo {
    Future<BuiltList<Crystal>> getCrystals();

    Future<BuiltList<Crystal>> searchCrystal({
        @required String query,
        int startIndex: 0,
    });
}

crystal_repo_impl.dart

class CrystalRepoImpl implements CrystalRepo {
    static const _timeoutInMilliseconds = 120000; // 2 minutes
    final Map<String, Tuple2<int, CrystalResponse>> _cached = {};

    ///
    final CrystalApi _api;
    final Mappers _mappers;

    CrystalRepoImpl(this._api, this._mappers);

    @override
    Future<BuiltList<Crystal>> searchCrystal({
        String query,
        int startIndex = 0,
    }) async {
        assert(query != null);
        final crystalsResponse = await _api.searchCrystal(
            query: query,
            startIndex: startIndex,
        );

        final crystal = crystalsResponse.map(_mappers.crystalResponseToDomain);
        return BuiltList<Crystal>.of(crystal);
    }

    @override
    Future<BuiltList<Crystal>> getCrystals() async {
        final crystalsResponse = await _api.getCrystals();
        final crystal = crystalsResponse.map(_mappers.crystalResponseToDomain);
        return BuiltList<Crystal>.of(crystal);
    }
}

search_blo c .dart

class SearchBloc implements BaseBloc {
    /// Input [Function]s
    final void Function(String) changeQuery;
    final void Function() loadNextPage;
    final void Function() retryNextPage;
    final void Function() retryFirstPage;
    final void Function(String) toggleFavorited;

    /// Ouput [Stream]s
    final ValueStream<SearchPageState> state$;
    final ValueStream<int> favoriteCount$;

    /// Subscribe to this stream to show message like snackbar, toast, ...
    final Stream<SearchPageMessage> message$;

    /// Clean up resource
    final void Function() _dispose;

    SearchBloc._(
        this.changeQuery,
        this.loadNextPage,
        this.state$,
        this._dispose,
        this.retryNextPage,
        this.retryFirstPage,
        this.toggleFavorited,
        this.message$,
        this.favoriteCount$,
        );

    @override
    void dispose() => _dispose();

    factory SearchBloc(final CrystalRepo crystalRepo, final FavoritedCrystalsRepo favCrystalsRepo,){
        assert(crystalRepo != null);
        assert(favCrystalsRepo != null);

        /// Stream controllers, receive input intents
        final queryController = PublishSubject<String>();
        final loadNextPageController = PublishSubject<void>();
        final retryNextPageController = PublishSubject<void>();
        final retryFirstPageController = PublishSubject<void>();
        final toggleFavoritedController = PublishSubject<String>();
        final controllers = [
            queryController,
            loadNextPageController,
            retryNextPageController,
            retryFirstPageController,
            toggleFavoritedController,
        ];

        /// Debounce query stream
        final searchString$ = queryController
            .debounceTime(const Duration(milliseconds: 300))
            .distinct()
            .map((s) => s.trim());

        /// Search intent
        final searchIntent$ = searchString$.mergeWith([
            retryFirstPageController.withLatestFrom(
                searchString$,
                    (_, String query) => query,
            )
        ]).map((s) => SearchIntent.searchIntent(search: s));

        /// Forward declare to [loadNextPageIntent] can access latest state via [DistinctValueConnectableStream.value] getter
        DistinctValueConnectableStream<SearchPageState> state$;

        /// Load next page intent
        final loadAndRetryNextPageIntent$ = Rx.merge(
            [
                loadNextPageController.map((_) => state$.value).where((currentState) {
                    /// Can load next page?
                    return currentState.crystals.isNotEmpty &&
                        currentState.loadFirstPageError == null &&
                        currentState.loadNextPageError == null;
                }),
                retryNextPageController.map((_) => state$.value).where((currentState) {
                    /// Can retry?
                    return currentState.loadFirstPageError != null ||
                        currentState.loadNextPageError != null;
                })
            ],
        ).withLatestFrom(searchString$, (currentState, String query) =>
                Tuple2(currentState.crystals.length, query),
        ).map(
                (tuple2) => SearchIntent.loadNextPageIntent(
                search: tuple2.item2,
                startIndex: tuple2.item1,
            ),
        );

        /// State stream
        state$ = Rx.combineLatest2(
            Rx.merge([searchIntent$, loadAndRetryNextPageIntent$]) // All intent
                .doOnData((intent) => print('[INTENT] $intent'))
                .switchMap((intent) => _processIntent$(intent, crystalRepo))
                .doOnData((change) => print('[CHANGE] $change'))
                .scan((state, action, _) => action.reduce(state),
                SearchPageState.initial(),
            ),
            favCrystalsRepo.favoritedIds$,
                (SearchPageState state, BuiltSet<String> ids) => state.rebuild(
                    (b) => b.crystals.map(
                        (crystal) => crystal.rebuild((b) => b.isFavorited = ids.contains(b.id)),
                ),
            ),

        ).publishValueSeededDistinct(seedValue: SearchPageState.initial());

        final message$ = _getMessage$(toggleFavoritedController, favCrystalsRepo, state$);

        final favoriteCount = favCrystalsRepo.favoritedIds$
            .map((ids) => ids.length)
            .publishValueSeededDistinct(seedValue: 0);

        return SearchBloc._(
            queryController.add,
                () => loadNextPageController.add(null),
            state$,
            DisposeBag([
                ...controllers,
                message$.listen((message) => print('[MESSAGE] $message')),
                favoriteCount.listen((count) => print('[FAV_COUNT] $count')),
                state$.listen((state) => print('[STATE] $state')),
                state$.connect(),
                message$.connect(),
                favoriteCount.connect(),
            ]).dispose,
                () => retryNextPageController.add(null),
                () => retryFirstPageController.add(null),
            toggleFavoritedController.add,
            message$,
            favoriteCount,
        );
    }
}

/// Process [intent], convert [intent] to [Stream] of [PartialStateChange]s
Stream<PartialStateChange> _processIntent$(
    SearchIntent intent,
    CrystalRepo crystalRepo,
    ) {
    perform<RESULT, PARTIAL_CHANGE>(
        Stream<RESULT> streamFactory(),
        PARTIAL_CHANGE map(RESULT a),
        PARTIAL_CHANGE loading,
        PARTIAL_CHANGE onError(dynamic e),
        ) {
        return Rx.defer(streamFactory)
            .map(map)
            .startWith(loading)
            .doOnError((e, s) => print(s))
            .onErrorReturnWith(onError);
    }

    searchIntentToPartialChange$(SearchInternalIntent intent) =>
        perform<BuiltList<Crystal>, PartialStateChange>(
            () {
                if (intent.search.isEmpty) {
                    return Stream.fromFuture(crystalRepo.getCrystals());
                }
                return Stream.fromFuture(crystalRepo.searchCrystal(query: intent.search));
            },
            (list) {
                final crystalItems = list.map((crystal) => CrystalItem.fromDomain(crystal)).toList();
                return PartialStateChange.firstPageLoaded(crystals: crystalItems, textQuery: intent.search,);
            },
            PartialStateChange.firstPageLoading(),
                (e) {
                return PartialStateChange.firstPageError(error: e,textQuery: intent.search,);
            },
        );

    loadNextPageIntentToPartialChange$(LoadNextPageIntent intent) =>
        perform<BuiltList<Crystal>, PartialStateChange>();

    return intent.join(
        searchIntentToPartialChange$,
        loadNextPageIntentToPartialChange$,
    );
}

search_state. дротик

abstract class SearchPageState implements Built<SearchPageState, SearchPageStateBuilder> {
    String get resultText;

    BuiltList<CrystalItem> get crystals;

    bool get isFirstPageLoading;

    @nullable
    Object get loadFirstPageError;

    bool get isNextPageLoading;

    @nullable
    Object get loadNextPageError;

    SearchPageState._();

    factory SearchPageState([updates(SearchPageStateBuilder b)]) = _$SearchPageState;

    factory SearchPageState.initial() {
        return SearchPageState((b) => b
            ..resultText = ''
            ..crystals = ListBuilder<CrystalItem>()
            ..isFirstPageLoading = false
            ..loadFirstPageError = null
            ..isNextPageLoading = false
            ..loadNextPageError = null);
    }
}

class PartialStateChange extends Union6Impl<
    LoadingFirstPage,
    LoadFirstPageError,
    FirstPageLoaded,
    LoadingNextPage,
    NextPageLoaded,
    LoadNextPageError> {
    static const Sextet<LoadingFirstPage, LoadFirstPageError, FirstPageLoaded,
        LoadingNextPage, NextPageLoaded, LoadNextPageError> _factory =
    Sextet<LoadingFirstPage, LoadFirstPageError, FirstPageLoaded,
        LoadingNextPage, NextPageLoaded, LoadNextPageError>();

    PartialStateChange._(
        Union6<LoadingFirstPage, LoadFirstPageError, FirstPageLoaded,
            LoadingNextPage, NextPageLoaded, LoadNextPageError>
        union)
        : super(union);

    factory PartialStateChange.firstPageLoading() {
        return PartialStateChange._(
            _factory.first(
                const LoadingFirstPage()
            )
        );
    }

    factory PartialStateChange.firstPageError({
        @required Object error,
        @required String textQuery,
    }) {
        return PartialStateChange._(
            _factory.second(
                LoadFirstPageError(
                    error: error,
                    textQuery: textQuery,
                ),
            ),
        );
    }

    factory PartialStateChange.firstPageLoaded({
        @required List<CrystalItem> crystals,
        @required String textQuery,
    }) {
        return PartialStateChange._(
            _factory.third(
                FirstPageLoaded(
                    crystals: crystals,
                    textQuery: textQuery,
                ),
            )
        );
    }

    factory PartialStateChange.nextPageLoading() {
        return PartialStateChange._(
            _factory.fourth(
                const LoadingNextPage()
            )
        );
    }

    factory PartialStateChange.nextPageLoaded({
        @required List<CrystalItem> crystals,
        @required String textQuery,
    }) {
        return PartialStateChange._(
            _factory.fifth(
                NextPageLoaded(
                    textQuery: textQuery,
                    crystals: crystals,
                ),
            ),
        );
    }

    factory PartialStateChange.nextPageError({
        @required Object error,
        @required String textQuery,
    }) {
        return PartialStateChange._(
            _factory.sixth(
                LoadNextPageError(
                    textQuery: textQuery,
                    error: error,
                ),
            ),
        );
    }

    /// Pure function, produce new state from previous state [state] and partial state change [partialChange]
    SearchPageState reduce(SearchPageState state) {
        return join<SearchPageState>(
            (LoadingFirstPage change) {
                return state.rebuild((b) => b..isFirstPageLoading = true);
            },
                (LoadFirstPageError change) {
                return state.rebuild((b) => b
                    ..resultText = "Search for '${change.textQuery}', error occurred"
                    ..isFirstPageLoading = false
                    ..loadFirstPageError = change.error
                    ..isNextPageLoading = false
                    ..loadNextPageError = null
                    ..crystals = ListBuilder<CrystalItem>());
            },
                (FirstPageLoaded change) {
                return state.rebuild((b) => b
                    //..resultText = "Search for ${change.textQuery}, have ${change.crystals.length} crystals"
                    ..resultText = ""
                    ..crystals = ListBuilder<CrystalItem>(change.crystals)
                    ..isFirstPageLoading = false
                    ..isNextPageLoading = false
                    ..loadFirstPageError = null
                    ..loadNextPageError = null);
            },
                (LoadingNextPage change) {
                return state.rebuild((b) => b..isNextPageLoading = true);
            },
                (NextPageLoaded change) {
                return state.rebuild((b) {
                    var newListBuilder = b.crystals..addAll(change.crystals);
                    return b
                        ..crystals = newListBuilder
                        ..resultText =
                            "Search for '${change.textQuery}', have ${newListBuilder.length} crystals"
                        ..isNextPageLoading = false
                        ..loadNextPageError = null;
                });
            },
                (LoadNextPageError change) {
                return state.rebuild((b) => b
                    ..resultText =
                        "Search for '${change.textQuery}', have ${state.crystals.length} crystals"
                    ..isNextPageLoading = false
                    ..loadNextPageError = change.error);
            },
        );
    }

    @override
    String toString() => join<String>(_toString, _toString, _toString, _toString, _toString, _toString);
}

search_page.dart

class SearchListViewWidget extends StatelessWidget {
    final SearchPageState state;

    const SearchListViewWidget({Key key, @required this.state})
        : assert(state != null),
            super(key: key);

    @override
    Widget build(BuildContext context) {
        final bloc = BlocProvider.of<SearchBloc>(context);

        if (state.loadFirstPageError != null) {}


// LOOKING TO HAVE items LOADED ON APP LOAD //

        final BuiltList<CrystalItem> items = state.crystals;

        if (items.isEmpty) {
            debugPrint('items.isEmpty');
        }

        return ListView.builder(
            itemCount: items.length + 1,
            padding: const EdgeInsets.all(0),
            physics: const BouncingScrollPhysics(),
            itemBuilder: (context, index) {
                debugPrint('itemBuilder');
                if (index < items.length) {
                    final item = items[index];
                    return SearchCrystalItemWidget(
                        crystal: item,
                        key: Key(item.id),
                    );
                }

                if (state.loadNextPageError != null) {
                    final Object error = state.loadNextPageError;

                    return Padding(
                        padding: const EdgeInsets.all(8.0),
                        child: Column(
                            mainAxisAlignment: MainAxisAlignment.center,
                            mainAxisSize: MainAxisSize.min,
                            crossAxisAlignment: CrossAxisAlignment.stretch,
                            children: <Widget>[
                                Text(
                                    error is HttpException
                                        ? error.message
                                        : 'An error occurred $error',
                                    textAlign: TextAlign.center,
                                    maxLines: 2,
                                    style:
                                    Theme.of(context).textTheme.body1.copyWith(fontSize: 15),
                                ),
                                SizedBox(height: 8),
                                RaisedButton(
                                    shape: RoundedRectangleBorder(
                                        borderRadius: BorderRadius.circular(16),
                                    ),
                                    onPressed: bloc.retryNextPage,
                                    padding: const EdgeInsets.all(16.0),
                                    child: Text(
                                        'Retry',
                                        style: Theme.of(context).textTheme.body1.copyWith(fontSize: 16),
                                    ),
                                    elevation: 4.0,
                                ),
                            ],
                        ),
                    );
                }

                return Container();
            },
        );
    }
}

...