Why adding data in Flutter Sink is not working? - flutter

Purpose is very simple. After getting data it is filterable by specific set of Strings. So I am initially filtering with 'all' which means showing all data and when clicking any choice chips then filtering based on that specific strings. Everything working fine except not showing all data after loading it from api call. Even if I Hot Reload again its showing the full list data. So basically adding string data in Sink is not working. I think I have done some silly mistake but couldn't figure it out. Need suggestions.
BLOC Class
final Application _application;
ProductListScreenBloc(this._application);
int totalPages = 1;
final _productList = BehaviorSubject<List<Product>>();
Observable<List<Product>> _filteredProductList = Observable.empty();
final _filterName = BehaviorSubject<String>();
Stream<List<Product>> get productList => _productList.stream;
Stream<List<Product>> get filteredProductList => _filteredProductList;
Sink<String> get filterName => _filterName;
void loadBrandWiseProductList(
String categorySlug, String brandSlug, int pageNo) {
if (totalPages >= pageNo) { //for pagination
StreamSubscription subscription = _application.productListRepository
.getBrandWiseProductList(categorySlug, brandSlug, pageNo)
.listen((ProductListResponse response) {
if (_productList.value == null) {
totalPages = response.totalPage;
_productList.add(response.productList);
filterName.add('all');
_filteredProductList = Observable.combineLatest2(
_filterName, _productList, applyModelFilter)
.asBroadcastStream();
}
});
}
}
List<Product> applyModelFilter(
String filter,
List<Product> products,
) {
if (filter == 'all') {
return products;
} else {
return products
.where((seriesSLug) => seriesSLug.series.slug == filter)
.toList();
}
}
UI Widget Class
class _AllSeriesModelListScreenState extends State<AllSeriesModelListScreen> {
AllSeriesModelListScreenArguments allSeriesModelListScreenArguments;
ProductListScreenBloc bloc;
int _selectedSeriesChipValue = -1;
int _pageNo = 1;
#override
void initState() {
super.initState();
}
#override
void dispose() {
super.dispose();
bloc.dispose();
}
#override
Widget build(BuildContext context) {
RouteSettings settings = ModalRoute.of(context).settings;
allSeriesModelListScreenArguments = settings.arguments;
_init();
return Scaffold(
body: CustomScrollView(
slivers: <Widget>[
StreamBuilder(
stream: bloc.filteredProductList,
builder: (context, snapshot) {
if (snapshot.hasData) {
List<Product> productList = snapshot.data;
return SliverPadding(
padding: EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 10.0,
),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 0.0,
mainAxisSpacing: 8.0,
),
delegate: SliverChildListDelegate(
buildModelGridList(productList),
),
),
);
} else {
return SliverList(
delegate: SliverChildListDelegate([
PaddingWithTitle(
title: 'No Model Available',
),
]),
);
}
})
],
),
);
}
void _init() {
if (null == bloc) {
bloc = ProductListScreenBloc(
AppProvider.getApplication(context),
);
bloc.loadBrandWiseProductList(
allSeriesModelListScreenArguments.categorySlug,
allSeriesModelListScreenArguments.brandSlug,
_pageNo);
}
}
}

I believe you have missed something in these 2 lines.
final _filterName = BehaviorSubject<String>();
Sink<String> get filterName => _filterName;
You are not exposing the sink. BehaviorSubject is just a StreamController with default value and cache for last value. So as every Stream controller it has 2 props - sink and stream. to push data you need to access the sink.
To do that you need to type
StreamSink<String> get filterName => _filterName.sink;
Plus why you do not have a seed value in the behavior subject?
It is required to have that "default" value
final _filterName = BehaviorSubject<String>(seedValue: '');

Just had to change the code into this
void loadBrandWiseProductList(
String categorySlug, String brandSlug, int pageNo) {
if (totalPages >= pageNo) { //for pagination
StreamSubscription subscription = _application.productListRepository
.getBrandWiseProductList(categorySlug, brandSlug, pageNo)
.listen((ProductListResponse response) {
if (_productList.value == null) {
totalPages = response.totalPage;
_productList.add(response.productList);
}
_filteredProductList = Observable.combineLatest2(
_filterName, _productList, applyModelFilter)
.asBroadcastStream();
});
}
}

Related

Flutter + syncfusion charts: Is it possible to have a dataSource that is not a List? (type 'BanditData' is not a subtype of type 'List<BanditData>')

I am trying to display a bar chart in my app with the syncfusion library. It contains 6 bars where the height is defined by a score and the name is a player name. I have the following methods: getBanditBarData() which gets the data from a database in creates a list of BanditData-objects (BanditData class shown below), and barChart() which creates a List of ChartSeries that I can return in the series parameter of my SfCartesianChart.
My problem is that the item of the dataSource: item-line in my barChart()-method gives the following exception:
_TypeError (type 'BanditData' is not a subtype of type 'List<BanditData>')
I've tried nesting an additional List around each BanditData object in the list, and even removing the for-loop of the method. Both changes result in similar errors somewhere in the same method.
Future<List<BanditData>> getBanditBarData() async {
var scores = await database.totalScore();
List<BanditData> banditData = [];
for (var score in scores) {
BanditData bandit = BanditData(score['name'], "", score['score']);
banditData.add(bandit);
}
return banditData;
}
List<ChartSeries> barChart(data) {
var barList = <ChartSeries>[];
for (var item in data) {
barList.add(BarSeries<BanditData, String>(
dataSource: item,
xValueMapper: (BanditData b, _) => removeBanditSuffix(b.name),
yValueMapper: (BanditData b, _) => b.score,
animationDuration: 2000));
}
return barList;
}
The BanditData-class is very simple and looks like this:
class BanditData {
BanditData(this.name, this.date, this.score);
final String name;
final String date;
final int score;
}
The setup shown above works when I render my line chart. The methods are very similar:
Future<List<List<BanditData>>> getBanditLineData() async {
var dates = await database.getDistinctDatesList();
var scores = await database.createScoreDataStruct();
List<List<BanditData>> banditData = [];
for (var i = 0; i < scores.length; i++) {
List<BanditData> temp = [];
var intList = scores[i]['scores'];
for (var j = 0; j < scores[i]['scores'].length; j++) {
BanditData bandit = BanditData(scores[i]['name'], dates[j], intList[j]);
temp.add(bandit);
}
banditData.add(temp);
}
return banditData;
}
List<ChartSeries> lineChart(data) {
var lineList = <ChartSeries>[];
for (var item in data) {
lineList.add(LineSeries<BanditData, String>(
dataSource: item,
xValueMapper: (BanditData b, _) => b.date,
yValueMapper: (BanditData b, _) => b.score,
enableTooltip: true,
name: removeBanditSuffix(item[1].name),
width: 3.0,
animationDuration: 2000,
));
}
return lineList;
}
If necessary, here is some more code showing how I build the chart. The above methods is placed inside MyStatsPageState, but figured it would be better to split it up for readability.
Ideally, I should be able to replace series: lineChart(lineData) with series: barChart(barData):
import 'database.dart';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
class MyStatsPage extends StatefulWidget {
const MyStatsPage({Key? key}) : super(key: key);
#override
MyStatsPageState createState() {
return MyStatsPageState();
}
}
class MyStatsPageState extends State<MyStatsPage> {
late Future<List<List<BanditData>>> _banditLineData;
late Future<List<BanditData>> _banditBarData;
final database = Database();
bool displayLineChart = true;
#override
void initState() {
_banditLineData = getBanditLineData();
_banditBarData = getBanditBarData();
super.initState();
getBanditBarData();
}
#override
Widget build(BuildContext context) {
const appTitle = "Stats";
return Scaffold(
appBar: AppBar(
title: const Text(
appTitle,
style: TextStyle(fontSize: 25, fontWeight: FontWeight.w700),
)),
body: FutureBuilder(
future: Future.wait([_banditLineData, _banditBarData]),
builder: (context, AsyncSnapshot snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
} else {
if (snapshot.hasError) {
return ErrorWidget(Exception(
'Error occured when fetching data from database'));
} else if (!snapshot.hasData) {
return const Center(child: Text('No data found.'));
} else {
final lineData = snapshot.data![0];
final barData = snapshot.data![1];
return Padding(
padding: const EdgeInsets.all(5.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(
child: SfCartesianChart(
primaryXAxis: CategoryAxis(),
enableAxisAnimation: true,
series: lineChart(lineData),
)),
],
));
}
}
}));
}
We have checked the code snippet attached in the query and found that it is a sample-level issue, it occurs due to you have passed the BanditData instead of List. Because dataSource property always supports the list value only. To resolve this, convert the barData to nested lists, or assign a value to the BarSeries dataSource like below.
Code snippet:
List<ChartSeries> barChart(data) {
var barList = <ChartSeries>[];
for (var item in data) {
barList.add(BarSeries<BanditData, String>(
dataSource: [item],
// Other required properties
));
}
return barList;
}

Fetching multiple pages from an API and adding to stream sink

I'm fetching this API https://rickandmortyapi.com/api/character and putting the data inside a Stream so I can infinite scroll over a Gridview of Cards with every character.
Fetching the first page with a FutureBuilder it works, but trying to use a StreamBuilder just doesn't update anything as if it wasn't receiving any data.
Here's the the Provider.dart
class CharacterProvider {
final _url = 'rickandmortyapi.com';
final _characterStream = StreamController<List<Character>>.broadcast();
List<Character> _characters = [];
int currentPage = 1;
Function(List<Character>) get characterSink => _characterStream.sink.add;
Stream<List<Character>> get characterStream => _characterStream.stream;
void dispose() {
_characterStream?.close();
}
Future<Map<String, dynamic>> fetchData(
String path, Map<String, dynamic> header) async {
print(header);
final response = await http.get(
Uri.https(_url, 'api/$path', header),
);
if (response.statusCode == 200) {
final results = jsonDecode(response.body);
return results;
} else {
throw Exception('Fallo al cargar personajes');
}
}
Future<List<Character>> fetchCharacters() async {
final path = 'character';
final header = {
'page': currentPage.toString(),
};
final data = await fetchData(path, header);
final characterFetched = Characters.fromJsonList(data['results']);
_characters.addAll(characterFetched.character);
characterSink(_characters);
if (currentPage < data['info']['pages']) {
currentPage++;
}
return characterFetched.character;
}
}
The stream of StreamBuilder in the widget is subscribed to characterStream but it is always on null.
class _CharacterCardsState extends State<CharacterCards> {
final _scrollController = ScrollController();
Future<List<Character>> _characters;
int cards;
bool loading;
#override
void initState() {
super.initState();
print('Cards: init');
_characters = initFetch();
loading = true;
cards = 6;
_scrollController.addListener(updateCards);
}
Future<List<Character>> initFetch() async {
final fetch = await CharacterProvider().fetchCharacters();
return fetch;
}
#override
Widget build(BuildContext context) {
CharacterProvider().fetchCharacters();
print('Cards: build');
return GridView.builder(
itemCount: cards,
controller: _scrollController,
itemBuilder: (context, index) {
return StreamBuilder(
stream: CharacterProvider().characterStream,
builder: (BuildContext context,
AsyncSnapshot<List<Character>> snapshot) {
if (snapshot.hasData) {
loading = false;
final character = snapshot.data;
return GestureDetector(
onTap: () {
cardView(context, character, index);
},
child: ofCard(character, index),
);
} else {
return ofLoading(widget.size);
}
},
);
});
}
On debug, the values added to the sink are non-null. The data is fetching correctly but the sink.add() doesn't seem to be working.
I believe you're trying to use provider package (that's why you named your class CharacterProvider() I think), either way the problem is you're not saving a reference of that class, you're creating them anew each time you call CharacterProvider().someMethod so the initFetch CharacterProvider().fetchCharacters() and the stream CharacterProvider().characterStream are not related
Just like your scrollController you should create a final characterProvider = CharacterProvider() and call it in all your methods that requires it
PS: don't call a future CharacterProvider().fetchCharacters(); inside build like that, it's an antipattern
Try this.
class _CharacterCardsState extends State<CharacterCards> {
final _scrollController = ScrollController();
Future<List<Character>> _characters;
int cards;
bool loading;
#override
void initState() {
super.initState();
_characters = CharacterProvider();
_characters.fetchCharacters();
loading = true;
cards = 6;
_scrollController.addListener(updateCards);
}
#override
void dispose(){
_characters.dispose();
super.dispose();
}
#override
Widget build(BuildContext context) {
return GridView.builder(
itemCount: cards,
controller: _scrollController,
itemBuilder: (context, index) {
return StreamBuilder(
stream: _characters.characterStream,
builder: (BuildContext context,
AsyncSnapshot<List<Character>> snapshot) {
if (snapshot.hasData) {
setState(()=>loading=false);
final character = snapshot.data;
return GestureDetector(
onTap: () {
cardView(context, character, index);
},
child: ofCard(character, index),
);
} else {
return ofLoading(widget.size);
}
},
);
});
}
I don't know why you are putting streambuilder inside gridview but logically above code should work.

Loading data on init with BLoC

New to Flutter with BLoC. Building off of a search template, looking to have data (items) load on app load instead of on state change.
The method getCrystals() returns the correct data when the search intent .isEmpty but how can it be done on app load?
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_bloc.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.dart
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();
},
);
}
}
Ended up solving this by passing in an empty query in the app.dart
home: Consumer2<FavoritedCrystalsRepo, CrystalRepo>(
builder: (BuildContext context, FavoritedCrystalsRepo sharedPref, CrystalRepo crystalRepo) {
final searchBloc = SearchBloc(crystalRepo, sharedPref);
// Do the first search to get first result on init
searchBloc.changeQuery('');
return BlocProvider<SearchBloc>(
child: SearchPage(),
initBloc: () => searchBloc,
);
},
An alternative would be to convert your search page into a StatefulWidget and then calling searchBloc.changeQuery(''); inside of initState

Flutter StreamBuilder ListView not reloading when stream data changes

I`m trying to build an app that loads an endless feed from a blog in a ListView. At the top, the user has a choice of filtering the feed according to a certain category through the "categorias" menu. When the user taps on the "categorias" menu, another ListView appears with all the available categories. When the user taps on the desired category, the app should return to the feed ListView display only the posts under that category.
Expecter Result:
App call API and retrieves the 10 latest posts
As user scrolls, the next 10 posts are retrieved through successive API calls
User taps on the "categorias" menu and ListView with categories opens.
User taps on the desired category and app returns to the feed Listview, makes an API
call to retrieve the first 10 posts of that category.
As user scrolls, the next 10 posts of that category are retrieved through successive API
calls.
Observed Result:
App call API and retrieves the 10 latest posts
As user scrolls, the next 10 posts are retrieved through successive API calls
User taps on the "categorias" menu and ListView with categories opens.
User taps on the desired category and app returns to the feed Listview, makes an API
call to retrieve the first 10 posts of that category.
Posts of the desired category are appended to the ListView and appear only after the posts
that had been loaded previously.
My question:
How do I have to modify my states or my Bloc, so that I can get the desired result?
Relevant Screenshots
My structure:
PostBloc - My bloc component, which contains the stream definition for Articles and ArticleCategory StreamBuilders. Also contains the methods for making the API calls to
get the articles and article categories.
class PostBloc extends Bloc<PostEvent, PostState> {
final http.Client httpClient;
int _currentPage = 1;
int _limit = 10;
int _totalResults = 0;
int _numberOfPages = 0;
int _categoryId;
bool hasReachedMax = false;
var cachedData = new Map<int, Article>();
PostBloc({#required this.httpClient}) {
//Listen to when user taps a category in the ArticleCategory ListView
_articleCategoryController.stream.listen((articleCategory) {
if (articleCategory.id != null) {
_categoryId = articleCategory.id;
_articlesSubject.add(UnmodifiableListView(null));
_currentPage = 1;
_fetchPosts(_currentPage, _limit, _categoryId)
.then((articles) {
_articlesSubject.add(UnmodifiableListView(articles));
});
_currentPage++;
dispatch(Fetch());
}
});
_currentPage++;
}
List<Article> _articles = <Article>[];
// Category Sink for listening to the tapped category
final _articleCategoryController = StreamController<ArticleCategory>();
Sink<ArticleCategory> get getArticleCategory =>
_articleCategoryController.sink;
//Article subject for populating articles ListView
Stream<UnmodifiableListView<Article>> get articles => _articlesSubject.stream;
final _articlesSubject = BehaviorSubject<UnmodifiableListView<Article>>();
//Categories subjet for the article categories
Stream<UnmodifiableListView<ArticleCategory>> get categories => _categoriesSubject.stream;
final _categoriesSubject = BehaviorSubject<UnmodifiableListView<ArticleCategory>>();
void dispose() {
_articleCategoryController.close();
}
#override
Stream<PostState> transform(
Stream<PostEvent> events,
Stream<PostState> Function(PostEvent event) next,
) {
return super.transform(
(events as Observable<PostEvent>).debounceTime(
Duration(milliseconds: 500),
),
next,
);
}
#override
get initialState => PostUninitialized();
#override
Stream<PostState> mapEventToState(PostEvent event) async* {
//This event is triggered when user taps on categories menu
if (event is ShowCategory) {
_currentPage = 1;
await _fetchCategories(_currentPage, _limit).then((categories) {
_categoriesSubject.add(UnmodifiableListView(categories));
});
yield PostCategories();
}
// This event is triggered when user taps on a category
if(event is FilterCategory){
yield PostLoaded(hasReachedMax: false);
}
// This event is triggered when app loads and when user scrolls to the bottom of articles
if (event is Fetch && !_hasReachedMax(currentState)) {
try {
//First time the articles feed opens
if (currentState is PostUninitialized) {
_currentPage = 1;
await _fetchPosts(_currentPage, _limit).then((articles) {
_articlesSubject.add(UnmodifiableListView(articles)); //Send to stream
});
this.hasReachedMax = false;
yield PostLoaded(hasReachedMax: false);
_currentPage++;
return;
}
//User scrolls to bottom of ListView
if (currentState is PostLoaded) {
await _fetchPosts(_currentPage, _limit, _categoryId)
.then((articles) {
_articlesSubject.add(UnmodifiableListView(articles));//Append to stream
});
_currentPage++;
// Check if last page has been reached or not
if(_currentPage > _numberOfPages){
this.hasReachedMax = true;
}
else{
this.hasReachedMax = false;
}
yield (_currentPage > _numberOfPages)
? (currentState as PostLoaded).copyWith(hasReachedMax: true)
: PostLoaded(
hasReachedMax: false,
);
}
} catch (e) {
print(e.toString());
yield PostError();
}
}
}
bool _hasReachedMax(PostState state) =>
state is PostLoaded && this.hasReachedMax;
Article _getArticle(int index) {
if (cachedData.containsKey(index)) {
Article data = cachedData[index];
return data;
}
throw Exception("Article could not be fetched");
}
/**
* Fetch all articles
*/
Future<List<Article>> _fetchPosts(int startIndex, int limit,
[int categoryId]) async {
String query =
'https://www.batatolandia.de/api/batatolandia/articles?page=$startIndex&limit=$limit';
if (categoryId != null) {
query += '&category_id=$categoryId';
}
final response = await httpClient.get(query);
if (response.statusCode == 200) {
final data = json.decode(response.body);
ArticlePagination res = ArticlePagination.fromJson(data);
_totalResults = res.totalResults;
_numberOfPages = res.numberOfPages;
for (int i = 0; i < res.data.length; i++) {
_articles.add(res.data[i]);
}
return _articles;
} else {
throw Exception('error fetching posts');
}
}
/**
* Fetch article categories
*/
Future<List<ArticleCategory>> _fetchCategories(int startIndex, int limit,
[int categoryId]) async {
String query =
'https://www.batatolandia.de/api/batatolandia/articles/categories?page=$startIndex&limit=$limit';
final response = await httpClient.get(query);
if (response.statusCode == 200) {
final data = json.decode(response.body);
ArticleCategoryPagination res = ArticleCategoryPagination.fromJson(data);
_totalResults = res.totalResults;
_numberOfPages = res.numberOfPages;
List<ArticleCategory> categories = <ArticleCategory>[];
categories.add(ArticleCategory(id: 0 , title: 'Todos', color: '#000000'));
for (int i = 0; i < res.data.length; i++) {
categories.add(res.data[i]);
}
return categories;
} else {
throw Exception('error fetching categories');
}
}
}
Articles - contains a BlocProvider to read the current state set in PostBloc and displays
the corresponding view.
class Articles extends StatelessWidget{
PostBloc _postBloc;
#override
Widget build(BuildContext context) {
return BlocProvider(
builder: (context) =>
PostBloc(httpClient: http.Client())..dispatch(Fetch()),
child: BlocBuilder<PostBloc, PostState>(
builder: (context, state){
_postBloc = BlocProvider.of<PostBloc>(context);
// Displays circular progress indicator while posts are being retrieved
if (state is PostUninitialized) {
return Center(
child: CircularProgressIndicator(),
);
}
// Shows the feed Listview when API responds with the posts data
if (state is PostLoaded) {
return ArticlesList(postBloc:_postBloc );
}
// Shows the Article categories Listview when user clicks on menu
if(state is PostCategories){
return ArticlesCategoriesList(postBloc: _postBloc);
}
//Shows error if there are any problems while fetching posts
if (state is PostError) {
return Center(
child: Text('Failed to fetch posts'),
);
}
return null;
}
)
);
}
}
ArticlesList - Contains a StreamBuilder, which reads the articles data from PostBloc and loads into the feed ListView.
class ArticlesList extends StatelessWidget {
ScrollController _scrollController = new ScrollController();
int currentPage = 1;
int _limit = 10;
int totalResults = 0;
int numberOfPages = 0;
final _scrollThreshold = 200.0;
Completer<void> _refreshCompleter;
PostBloc postBloc;
ArticlesList({Key key, this.postBloc}) : super(key: key);
#override
Widget build(BuildContext context) {
_scrollController.addListener(_onScroll);
_refreshCompleter = Completer<void>();
return Scaffold(
appBar: AppBar(
title: Text("Posts"),
),
body: StreamBuilder<UnmodifiableListView<Article>>(
stream: postBloc.articles,
initialData: UnmodifiableListView<Article>([]),
builder: (context, snapshot) {
if(snapshot.hasData && snapshot != null) {
if(snapshot.data.length > 0){
return Column(
mainAxisSize: MainAxisSize.max,
children: <Widget>[
ArticlesFilterBar(),
Expanded(
child: RefreshIndicator(
child: ListView.builder(
itemBuilder: (BuildContext context,
int index) {
return index >= snapshot.data.length
? BottomLoader()
: ArticlesListItem(
article: snapshot.data.elementAt(
index));
},
itemCount: postBloc.hasReachedMax
? snapshot.data.length
: snapshot.data.length + 1,
controller: _scrollController,
),
onRefresh: _refreshList,
),
)
],
);
}
else if (snapshot.data.length==0){
return Center(
child: CircularProgressIndicator(),
);
}
}
else{
Text("Error!");
}
return CircularProgressIndicator();
}
)
);
}
#override
void dispose() {
_scrollController.dispose();
}
void _onScroll() {
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.position.pixels;
if (maxScroll - currentScroll <= _scrollThreshold) {
postBloc.dispatch(Fetch());
}
}
Future<void> _refreshList() async {
postBloc.dispatch(Fetch());
return null;
}
}
ArticlesCategoriesList - a StreamBuilder, which reads the categories from PostBloc and loads into a ListView.
class ArticlesCategoriesList extends StatelessWidget {
PostBloc postBloc;
ArticlesCategoriesList({Key key, this.postBloc}) : super(key: key);
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Categorias"),
),
body:
SafeArea(
child: StreamBuilder<UnmodifiableListView<ArticleCategory>>(
stream: postBloc.categories,
initialData: UnmodifiableListView<ArticleCategory>([]),
builder: (context, snapshot) {
return ListView.separated(
itemBuilder: (BuildContext context, int index) {
return new Container(
decoration: new BoxDecoration(
color: Colors.white,
),
child: ListTile(
dense: true,
leading: Icon(Icons.fiber_manual_record,color: HexColor(snapshot.data[index].color)),
trailing: Icon(Icons.keyboard_arrow_right),
title: Text(snapshot.data[index].title),
onTap: () {
postBloc.getArticleCategory.add(snapshot.data[index]);
},
));
},
separatorBuilder: (context, index) => Divider(
color: Color(0xff666666),
height: 1,
),
itemCount: snapshot.data.length);
},
)));
}
}
And here I am answering my own question...
In the end, I got everything to run smoothly by clearing the _articles List whenever a category tap event was detected.
So here is the new PostBloc
class PostBloc extends Bloc<PostEvent, PostState> {
final http.Client httpClient;
int _currentPage = 1;
int _limit = 10;
int _totalResults = 0;
int _numberOfPages = 0;
int _categoryId;
bool hasReachedMax = false;
var cachedData = new Map<int, Article>();
List<Article> _articles = <Article>[];
PostBloc({#required this.httpClient}) {
//Listen to when user taps a category in the ArticleCategory ListView
_articleCategoryController.stream.listen((articleCategory) {
if (articleCategory.id != null) {
_categoryId = articleCategory.id;
_currentPage = 1;
_articles.clear();
_fetchPosts(_currentPage, _limit, _categoryId)
.then((articles) {
_articlesSubject.add(UnmodifiableListView(articles));
});
_currentPage++;
dispatch(FilterCategory());
}
});
}
// Category Sink for listening to the tapped category
final _articleCategoryController = StreamController<ArticleCategory>();
Sink<ArticleCategory> get getArticleCategory =>
_articleCategoryController.sink;
//Article subject for populating articles ListView
Stream<UnmodifiableListView<Article>> get articles => _articlesSubject.stream;
final _articlesSubject = BehaviorSubject<UnmodifiableListView<Article>>();
//Categories subjet for the article categories
Stream<UnmodifiableListView<ArticleCategory>> get categories => _categoriesSubject.stream;
final _categoriesSubject = BehaviorSubject<UnmodifiableListView<ArticleCategory>>();
void dispose() {
_articleCategoryController.close();
}
#override
Stream<PostState> transform(
Stream<PostEvent> events,
Stream<PostState> Function(PostEvent event) next,
) {
return super.transform(
(events as Observable<PostEvent>).debounceTime(
Duration(milliseconds: 500),
),
next,
);
}
#override
get initialState => PostUninitialized();
#override
Stream<PostState> mapEventToState(PostEvent event) async* {
//This event is triggered when user taps on categories menu
if (event is ShowCategory) {
_currentPage = 1;
await _fetchCategories(_currentPage, _limit).then((categories) {
_categoriesSubject.add(UnmodifiableListView(categories));
});
yield PostCategories();
}
// This event is triggered when user taps on a category
if(event is FilterCategory){
yield PostLoaded(hasReachedMax: false);
}
// This event is triggered when app loads and when user scrolls to the bottom of articles
if (event is Fetch && !_hasReachedMax(currentState)) {
try {
//First time the articles feed opens
if (currentState is PostUninitialized) {
_currentPage = 1;
await _fetchPosts(_currentPage, _limit).then((articles) {
_articlesSubject.add(UnmodifiableListView(articles)); //Send to stream
});
this.hasReachedMax = false;
yield PostLoaded(hasReachedMax: false);
_currentPage++;
return;
}
//User scrolls to bottom of ListView
if (currentState is PostLoaded) {
await _fetchPosts(_currentPage, _limit, _categoryId)
.then((articles) {
_articlesSubject.add(UnmodifiableListView(_articles));//Append to stream
});
_currentPage++;
// Check if last page has been reached or not
if(_currentPage > _numberOfPages){
this.hasReachedMax = true;
}
else{
this.hasReachedMax = false;
}
yield (_currentPage > _numberOfPages)
? (currentState as PostLoaded).copyWith(hasReachedMax: true)
: PostLoaded(
hasReachedMax: false,
);
}
} catch (e) {
print(e.toString());
yield PostError();
}
}
}
bool _hasReachedMax(PostState state) =>
state is PostLoaded && this.hasReachedMax;
Article _getArticle(int index) {
if (cachedData.containsKey(index)) {
Article data = cachedData[index];
return data;
}
throw Exception("Article could not be fetched");
}
/**
* Fetch all articles
*/
Future<List<Article>> _fetchPosts(int startIndex, int limit,
[int categoryId]) async {
String query =
'https://www.batatolandia.de/api/batatolandia/articles?page=$startIndex&limit=$limit';
if (categoryId != null) {
query += '&category_id=$categoryId';
}
final response = await httpClient.get(query);
if (response.statusCode == 200) {
final data = json.decode(response.body);
ArticlePagination res = ArticlePagination.fromJson(data);
_totalResults = res.totalResults;
_numberOfPages = res.numberOfPages;
List<Article> posts = <Article>[];
for (int i = 0; i < res.data.length; i++) {
_articles.add(res.data[i]);
posts.add(res.data[i]);
}
return posts;
} else {
throw Exception('error fetching posts');
}
}
/**
* Fetch article categories
*/
Future<List<ArticleCategory>> _fetchCategories(int startIndex, int limit,
[int categoryId]) async {
String query =
'https://www.batatolandia.de/api/batatolandia/articles/categories?page=$startIndex&limit=$limit';
final response = await httpClient.get(query);
if (response.statusCode == 200) {
final data = json.decode(response.body);
ArticleCategoryPagination res = ArticleCategoryPagination.fromJson(data);
_totalResults = res.totalResults;
_numberOfPages = res.numberOfPages;
List<ArticleCategory> categories = <ArticleCategory>[];
categories.add(ArticleCategory(id: 0 , title: 'Todos', color: '#000000'));
for (int i = 0; i < res.data.length; i++) {
categories.add(res.data[i]);
}
return categories;
} else {
throw Exception('error fetching categories');
}
}
}

Flutter infinite/long list - memory issue and stack overflow error

my use case is to create a list view of articles (each item have the same look, there could be huge amount of articles, e.g. > 10000). I tried with
- ListView with ListView.builder: it supposes only to render the item when the item is displayed
- ScrollController: to determine when to load the next items (pagination)
- then I use List to store the data fetched from restful API using http, by adding the data from http to the List instance
this approach is OK, but in case the user keeps on scrolling pages, the List instance will have more and more items, it can crash with stack Overflow error.
If I don't call List.addAll(), instead I assign the data fetched from api, like: list = data;
I have problem that when the user scroll up, he/she won't be able to see the previous items.
Is there a good approach to solve this? Thanks!
import 'package:flutter/material.dart';
import 'package:app/model.dart';
import 'package:app/components/item.dart';
abstract class PostListPage extends StatefulWidget {
final String head;
DealListPage(this.head);
}
abstract class PostListPageState<T extends PostListPage> extends State<PostListPage> {
final int MAX_PAGE = 2;
DealListPageState(String head) {
this.head = head;
}
final ScrollController scrollController = new ScrollController();
void doInitialize() {
page = 0;
try {
list.clear();
fetchNextPage();
}
catch(e) {
print("Error: " + e.toString());
}
}
#override
void initState() {
super.initState();
this.fetchNextPage();
scrollController.addListener(() {
double maxScroll = scrollController.position.maxScrollExtent;
double currentScroll = scrollController.position.pixels;
double delta = 200.0; // or something else..
if ( maxScroll - currentScroll <= delta) {
fetchNextPage();
}
});
}
#override
void dispose() {
scrollController.dispose();
super.dispose();
}
void mergeNewResult(List<PostListItem> result) {
list.addAll(result);
}
Future fetchNextPage() async {
if (!isLoading && mounted) {
page++;
setState(() {
isLoading = true;
});
final List<PostListItem> result = await doFetchData(page);
setState(() {
if (result != null && result.length > 0) {
mergeNewResult(result);
} else {
//TODO show notification
}
isLoading = false;
});
}
}
Future doFetchData(final int page);
String head;
List<PostListItem> list = new List();
var isLoading = false;
int page = 0;
int pageSize = 20;
final int scrollThreshold = 10;
Widget buildProgressIndicator() {
return new Padding(
padding: const EdgeInsets.all(8.0),
child: new Center(
child: new Opacity(
opacity: isLoading ? 1.0 : 0.0,
child: new CircularProgressIndicator(),
),
),
);
}
#override
Widget build(BuildContext context) {
ListView listView = ListView.builder(
padding: const EdgeInsets.all(16.0),
itemBuilder: (BuildContext context, int index) {
if (index == list.length) {
return buildProgressIndicator();
}
if (index > 0) {
return Column(
children: [Divider(), PostListItem(list[index])]
);
}
return PostListItem(list[index]);
},
controller: scrollController,
itemCount: list.length
);
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: Text(head),
actions: <Widget>[
IconButton(
icon: Icon(Icons.search),
onPressed: () {
},
),
// action button
IconButton(
icon: Icon(Icons.more_horiz),
onPressed: () {
},
),
]
),
body: new RefreshIndicator(
onRefresh: handleRefresh,
child: listView
),
);
}
Future<Null> handleRefresh() async {
doInitialize();
return null;
}
}
in my case, when the list length is 600, I start to get stack overflow error like:
I/flutter ( 8842): Another exception was thrown: Stack Overflow
I/flutter ( 8842): Another exception was thrown: Stack Overflow
screen:
enter image description here
somehow flutter doesn't show any more details of the error.
I wrote some sample code for a related question about paginated scrolling, which you could check out.
I didn't implement cache invalidation there, but it would easily be extendable using something like the following in the getPodcast method to remove all items that are more than 100 indexes away from the current location:
for (key in _cache.keys) {
if (abs(key - index) > 100) {
_cache.remove(key);
}
}
An even more sophisticated implementation could take into consideration the scroll velocity and past user behavior to lay out a probability curve (or a simpler Gaussian curve) to fetch content more intelligently.