Combining multiple queries into one list with pagination - flutter

I am building a social network type product where users have followers and "workout_posts". I have created a similar model to the one outlined by Alex Mamo in this their answer to Firestore - how to structure a feed and follow system.
I have a collection of users, a collection of followers, and a collection of workout posts.
I have effectively set up a query to display all of a single users posts via pagination with the following code. This would work great for showing all of a single users posts in the profile view of the app.
class Workouts extends StatefulWidget {
Workouts({Key key, this.user}) : super(key: key);
final User user;
#override
_WorkoutsState createState() => new _WorkoutsState(
user: user
);
}
class _WorkoutsState extends State<Workouts> {
_WorkoutsState({this.user});
final User user;
Firestore _firestore = Firestore.instance;
List<DocumentSnapshot> _workouts = [];
bool _loadingWorkouts = true;
int _per_page = 8;
DocumentSnapshot _lastWorkout;
ScrollController _scrollController = ScrollController();
bool _gettingMoreWorkouts = false;
bool _moreWorkoutsAvailable = true;
_getWorkouts() async {
Query q = _firestore
.collection('workouts_posts')
.document(user.user_id)
.collection('posts')
.orderBy("post")
.limit(_per_page);
setState(() {
_loadingWorkouts = true;
});
QuerySnapshot querySnapshot = await q.getDocuments();
_workouts = querySnapshot.documents;
_lastWorkout = querySnapshot.documents[querySnapshot.documents.length - 1];
setState(() {
_loadingWorkouts = false;
});
}
_getMoreProducts() async {
print("get more products called");
if (_moreWorkoutsAvailable == false) {
return;
}
if (_gettingMoreWorkouts == true) {
return;
}
_gettingMoreWorkouts = true;
Query q = _firestore
.collection('workouts_posts')
.document(user.user_id)
.collection('posts')
.orderBy("post")
.startAfter([_lastWorkout.data['post']]).limit(_per_page);
QuerySnapshot querySnapshot = await q.getDocuments();
if (querySnapshot.documents.length < _per_page) {
_moreWorkoutsAvailable = false;
}
_lastWorkout = querySnapshot.documents[querySnapshot.documents.length - 1];
_workouts.addAll(querySnapshot.documents);
setState(() {});
_gettingMoreWorkouts = false;
}
#override
void initState() {
super.initState();
_getWorkouts();
_scrollController.addListener(() {
double maxScroll = _scrollController.position.maxScrollExtent;
double currentScroll = _scrollController.position.pixels;
double delta = MediaQuery.of(context).size.height * 0.25;
if (maxScroll - currentScroll < delta) {
_getMoreProducts();
}
});
}
#override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
new Expanded(
child: _loadingWorkouts == true
? Container(
child: Center(
child: Text("Loading..."),
),
)
: Container(
child: Center(
child: _workouts.length == 0
? Center(
child: Text("No Workouts to show"),
)
: ListView.builder(
controller: _scrollController,
itemCount: _workouts.length,
itemBuilder: (BuildContext ctx, int index) {
return new PostWidget(
postText: _workouts[index].data['post'],
pictureURL: _workouts[index].data['url'],
goalText: _workouts[index].data['goalText'],
userOfPost: _workouts[index].data['user'],
currentUser: user,
goalType: "workouts",
postID: _workouts[index].documentID,
);
}),
),
),
)
],
);
}
}
My challenge is how to query the posts of all followers for each user, combine them into one list, and then sort by timestamp--all while maintaining the pagination. Does anyone have any experience doing this or advice on the approach? Should I change my data model to denormalize it?

Related

Where to prevent re-animation of ListView.builder items when scrolling back to previously animated items?

I have a listview in which the text of items are animated when they first appear - and when they reappear after enough scrolling. When the list grows to certain size and the user scrolls back far enough items are animated again - presumably they've been removed from the widget tree and are now being re-inserted and thus get re-initiated etc. I want to prevent this from happening so that they only animate the first time they appear.
I think this means I need to have state stored somewhere per item that keeps track and tells the individual items whether they should animate on them being built or not. I am not sure where to put and how to connect that though, partly because it seems to overlap between presentation and business logic layers. I think perhaps it should be a variable in the list items contained in the list object that the listview builder is constructing from - or should it somehow be in the actual widgets in the listview?
class _StockListViewBuilderState extends State<StockListViewBuilder> with asc_alertBar {
final ScrollController _scrollController = ScrollController();
late double _scrollPosition;
late double _maxScrollExtent;
late bool isThisTheEnd = false;
_scrollListener() async {
setState(() {
_scrollPosition = _scrollController.position.pixels;
_maxScrollExtent = _scrollController.position.maxScrollExtent;
});
if (!isThisTheEnd && _scrollPosition / _maxScrollExtent > 0.90) {
isThisTheEnd = true;
if (widget.stockListicle.getIsRemoteEmpty()) {
alertBar('No more items available', /* null,*/ context: context);
} else {
await widget.stockListicle.fetch(numberToFetch: 5);
}
}
if (isThisTheEnd && _scrollPosition / _maxScrollExtent <= 0.90) {
isThisTheEnd = false;
}
}
#override
void initState() {
super.initState();
late String? userFullName = GetIt.I.get<Authenticate>().user?.fullName;
developer.log('Authenticated user $userFullName', name: '_StockListViewBuilderState');
developer.log("init ", name: "_StockListViewBuilderState ");
int listCount;
_scrollController.addListener(_scrollListener);
WidgetsBinding.instance.addPostFrameCallback((_) async {
//developer.log("stckLtcl init pf con ");
listCount = widget.stockListicle.items.length;
if (listCount < 10 && !widget.stockListicle.getIsRemoteEmpty()) {
try {
await widget.stockListicle.fetch(numberToFetch: 10);
} catch (e) {
super.setState(() {
//developer.log("Can't load stock:$e");
alertBar(
"Couldn't load from the internet.",
context: context,
backgroundColor: Colors.purple,
);
});
}
}
});
WidgetsBinding.instance.addPostFrameCallback((_) async {
final ConnectionNotifier connectionNotifier = context.read<ConnectionNotifier>();
if (connectionNotifier.isConnected() != true) {
await connectionNotifier.check();
if (connectionNotifier.isConnected() != true) {
alertBar("Please check the internet connection.", context: context);
}
}
});
}
#override
Widget build(BuildContext context) {
return ListView.builder(
scrollDirection: Axis.vertical,
controller: _scrollController,
shrinkWrap: true,
key: widget.theKey,
itemCount: widget.stockListicle.items.length + 1,
itemBuilder: (context, index) {
if (index <= widget.stockListicle.items.length - 1) {
return InkWell(
onTap: (() => Navigator.pushNamed(
context,
'/stocks/stock',
arguments: ScreenArguments(widget.stockListicle.items[index] as Stock),
)),
child: StockListItem(
stock: widget.stockListicle.items[index] as Stock,
));
} else {
return LoadingItemNotifier(
isLoading: widget.stockListicle.getIsBusyLoading(),
);
}
},
);
}
}
//...
Currently StockListItem extends StatelessWidget and returns a 'ListTile' which as its title parameter has ...title: AnimatedText(textContent: stock.title),...
I was trying to keep track of first-time-animation inside AnimatedText widget until I realized from an OOP & Flutter perspective, it's probably wrong place...
class AnimatedText extends StatefulWidget {
final bool doShowMe;
final String textContent;
final Duration hideDuration;
final double durationFactor;
const AnimatedText({
Key? key,
this.doShowMe = true,
this.textContent = '',
this.hideDuration = const Duration(milliseconds: 500),
this.durationFactor = 1,
}) : super(key: key);
#override
State<AnimatedText> createState() => _AnimatedTextState();
}
class _AnimatedTextState extends State<AnimatedText> with SingleTickerProviderStateMixin {
late AnimationController _appearanceController;
late String displayText;
late String previousText;
late double durationFactor;
late Duration buildDuration = Duration(
milliseconds: (widget.textContent.length / 15 * widget.durationFactor * 1000).round());
#override
void initState() {
super.initState();
developer.log('init ${widget.textContent}', name: '_AnimatedTextState');
displayText = '';
previousText = widget.textContent;
_appearanceController = AnimationController(
vsync: this,
duration: buildDuration,
)..addListener(
() => updateText(),
);
if (widget.doShowMe) {
_doShowMe();
}
}
void updateText() {
String payload = widget.textContent;
int numCharsToShow = (_appearanceController.value * widget.textContent.length).ceil();
if (widget.doShowMe) {
// make it grow
displayText = payload.substring(0, numCharsToShow);
// developer.log('$numCharsToShow / ${widget.textContent.length} ${widget.textContent}');
} else {
// make it shrink
displayText = payload.substring(payload.length - numCharsToShow, payload.length);
}
}
#override
void didUpdateWidget(AnimatedText oldWidget) {
super.didUpdateWidget(oldWidget);
if ((widget.doShowMe != oldWidget.doShowMe) || (widget.textContent != oldWidget.textContent)) {
if (widget.doShowMe) {
_doShowMe();
} else {
_doHideMe();
}
}
if (widget.doShowMe && widget.textContent != previousText) {
previousText = widget.textContent;
developer.log('reset');
_appearanceController
..reset()
..forward();
}
}
#override
void dispose() {
_appearanceController.dispose();
displayText = '';
super.dispose();
}
#override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _appearanceController,
builder: (context, child) {
return Text(displayText);
});
}
void _doShowMe() {
_appearanceController
..duration = buildDuration
..forward();
}
void _doHideMe() {
_appearanceController
..duration = widget.hideDuration
..reverse();
}
}

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.

Why adding data in Flutter Sink is not working?

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();
});
}
}

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- detect memory leak

I'm little bit confused because I was thinking there are no memory leak in flutter since there is no concept of weak (if I'm correct).
I'm running this on iOS device.
I'm trying to play videos and initialize some videos beforehand so that user can see it without delay.
To do that I prepared six VideoPlayerController and make those always being initialized while current video is playing.
There are three more initialized VideoPlayerController next to current one and two more initialized ones before current one like image below.
With this logic I play video very smoothly back and forth. But after play about ten videos, app crush because of memory issue.
I tried every function Future, async, await but still eats lots of memories.
I'm not sure but it might be NotificationListener?
Since onNotification returns bool not Future or
is this something to do with main thread or something?
Does anyone know how to fix this memory issue?
Code:
class _SwiperScreenState extends State<SwiperScreen> {
VideoPlayerController _firstController;
VideoPlayerController _secondController;
VideoPlayerController _thirdController;
VideoPlayerController _fourthController;
VideoPlayerController _fifthController;
VideoPlayerController _sixthController;
List<VideoPlayerController> _controllers;
List<String> urls = [
'https://firebasestorage.googleapis.com/v0/b/waitingboy-34497.appspot.com/o/video%2F8-21%2F1534825377992OsfJfKsdf90K8sf?alt=media&token=12245ee4-1598-4f7e-ba28-a9eb72ca474e',
'http://www.sample-videos.com/video/mp4/720/big_buck_bunny_720p_20mb.mp4',
'https://firebasestorage.googleapis.com/v0/b/waitingboy-34497.appspot.com/o/video%2F8-21%2F1534825377992OsfJfKsdf90K8sf?alt=media&token=12245ee4-1598-4f7e-ba28-a9eb72ca474e',
'http://www.sample-videos.com/video/mp4/720/big_buck_bunny_720p_20mb.mp4',
];
int currentIndex = 0; //refer to current playing controller index
int videosIndex = 0; //refer to current playing urls index
bool _didGetNotification(ScrollNotification notification) {
if (notification is UserScrollNotification) {
if (notification.direction.toString() == 'ScrollDirection.reverse') {
//swipe to left so add one more video
videosIndex++;
//modify index so that always in the range of 0 ~ 5.
if (currentIndex <= 2) {
final int prepareIndex = currentIndex + 3;
urls.add(
'https://firebasestorage.googleapis.com/v0/b/waitingboy-34497.appspot.com/o/video%2F8-21%2F1534825377992OsfJfKsdf90K8sf?alt=media&token=12245ee4-1598-4f7e-ba28-a9eb72ca474e');
_initVideo(urls[videosIndex], prepareIndex);
} else {
final int prepareIndex = (currentIndex + 3) - 6;
urls.add(
'http://www.sample-videos.com/video/mp4/720/big_buck_bunny_720p_20mb.mp4');
_initVideo(urls[videosIndex], prepareIndex);
}
}
if (notification.direction.toString() == 'ScrollDirection.forward') {
//swipe to right so back one more video
videosIndex--;
//modify index so that always in the range of 0 ~ 5 .
if (currentIndex >= 2) {
final int videoIndex = videosIndex - 2;
final int prepareIndex = currentIndex - 2;
_initVideo(urls[videoIndex], prepareIndex);
} else {
final int videoIndex = videosIndex - 2;
final int prepareIndex = 4 + currentIndex;
_initVideo(urls[videoIndex], prepareIndex);
}
}
}
return true;
}
Future _initVideo(String url, int initIndex) async {
if (_controllers[initIndex] != null) {
await _controllers[initIndex].dispose();
}
_controllers[initIndex] = new VideoPlayerController.network(url);
await _controllers[initIndex].initialize().then((_) async => await _controllers[initIndex].setLooping(true));
setState(() {});
}
Future _initFirstThree() async {
for (int i = 1; i < urls.length; i++) {
await _initVideo(urls[i], i);
}
}
#override
void initState() {
_controllers = [
_firstController,
_secondController,
_thirdController,
_fourthController,
_fifthController,
_sixthController
];
_initVideo(urls[0], 0).then((_) => _controllers[0].play());
_initFirstThree();
super.initState();
}
#override
void deactivate() {
_controllers[currentIndex].setVolume(0.0);
_controllers[currentIndex].pause();
super.deactivate();
}
#override
void dispose() {
_controllers.forEach((con) {
con.dispose();
});
super.dispose();
}
#override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
title: new Text('Swiper'),
actions: <Widget>[
new IconButton(
icon: new Icon(Icons.disc_full),
onPressed: () {
Navigator
.of(context)
.push(MaterialPageRoute(builder: (context) => Dissmiss()));
},
)
],
),
body: new NotificationListener(
onNotification: _didGetNotification,
child: new Swiper(
itemCount: 6,
itemBuilder: (BuildContext context, int index) {
return _controllers[index].value.initialized
? new AspectRatio(
aspectRatio: _controllers[index].value.aspectRatio,
child: new VideoPlayer(_controllers[index]),
)
: new Center(child: new CircularProgressIndicator());
},
loop: urls.length > 6 ? true : false,
onIndexChanged: (i) async {
currentIndex = i;
final int pauseIndex = i == 0 ? 5 : i - 1;
await _controllers[pauseIndex].pause().then((_) async {
await _controllers[i].play();
});
},
),
),
);
}
}