I am trying to implement pagination in my application but I have not been successful in doing so.
I am using Firebase, specifically Firestore with the BLOC pattern alongside Built Value which I started using recently to make pagination easier.
I would really appreciate any help or referral links how to use these technologies together.
My application architecture is as follows:
https://i.stack.imgur.com/MgNjx.png
I have tried to keep to the BLOC pattern as much as possible but this in turn has made it really difficult to paginate largely ,because of using built value as built value make it really difficult to use Streams and Futures. I have looked all over the Internet but I could not find any tutorial or docs to use built value with Firestore and BLOC specifically to paginate.
The problem is that when I do any of the CRUD functions for example delete an Category from a list, the Stream Builder is not updating the list despite the pagination and everything else working.
Currently I have tried using the a Listview builder by itself which obviously didn't work at all,so I moved to a Stream Builder and tryed both Streams and Futures(.asStream) but it is not updating.
Below is some of the code:
The model:
abstract class CategoryCard
implements Built<CategoryCard, CategoryCardBuilder> {
String get category;
String get icon;
double get budget;
double get spent;
String get categoryRef;
DocumentSnapshot get document;
CategoryCard._();
factory CategoryCard([updates(CategoryCardBuilder b)]) = _$CategoryCard;
static Serializer<CategoryCard> get serializer => _$categoryCardSerializer;
}
The query:
Future<Stream<fs.QuerySnapshot>> getMoreCategoryAmounts(
fs.DocumentSnapshot documentSnapshot) async {
var user = await getCurrentUser();
print(currentMonth);
fs.Query categoryAmountQuery = _instance
.collection('users')
.document(user.uid)
.collection('amounts')
.where('year', isEqualTo: currentYear)
.where('month', isEqualTo: currentMonth)
.orderBy('category', descending: false)
.limit(7);
return documentSnapshot != null
? categoryAmountQuery.startAfterDocument(documentSnapshot).snapshots()
: categoryAmountQuery.snapshots();
}
The BLOC:
class CategoryCardBloc extends Bloc<CategoryCardEvents, CategoryCardState> {
final BPipe bPipe;
final FirebaseRepository firebaseRepository;
CategoryCardBloc({@required this.bPipe, @required this.firebaseRepository})
: assert(bPipe != null),
assert(firebaseRepository != null);
@override
CategoryCardState get initialState => CategoryCardState.intial();
@override
Stream<CategoryCardState> mapEventToState(CategoryCardEvents event) async* {
if (event is LoadCategoryCardEvent) {
yield* _mapToEventLoadCategoryCard(event);
}
}
Stream<CategoryCardState> _mapToEventLoadCategoryCard(
LoadCategoryCardEvent event) async* {
if (event.amountDocumentSnapshot == null) {
yield CategoryCardState.loading();
}
try {
Future<BuiltList<CategoryCard>> _newCategoryCards =
bPipe.getMoreCategoryCards(event.amountDocumentSnapshot);
yield CategoryCardState.loaded(
FutureMerger()
.merge<CategoryCard>(state.categoryCards, _newCategoryCards));
} on NullException catch (err) {
print('NULL_EXCEPTION');
yield CategoryCardState.failed(err.objectExceptionMessage,
state?.categoryCards ?? Stream<BuiltList<CategoryCard>>.empty());
} on NoValueException catch (_) {
print('NO VALUE EXCEPTION');
yield state.rebuild((b) => b..hasReachedEndOfDocuments = true);
} catch (err) {
print('UNKNOWN EXCEPTION');
yield CategoryCardState.failed(
err != null ? err.toString() : NullException.exceptionMessage,
state.categoryCards);
}
}
}
The state:
abstract class CategoryCardState
implements Built<CategoryCardState, CategoryCardStateBuilder> {
Future<BuiltList<CategoryCard>> get categoryCards;
//*Reached end indicator
bool get hasReachedEndOfDocuments;
//*Error state
String get exception;
//*Loading state
@nullable
bool get isLoading;
//*Success state
@nullable
bool get isSuccessful;
//*Loaded state
@nullable
bool get isLoaded;
CategoryCardState._();
factory CategoryCardState([updates(CategoryCardStateBuilder b)]) =
_$CategoryCardState;
factory CategoryCardState.intial() {
return CategoryCardState((b) => b
..exception = ''
..isSuccessful = false
..categoryCards =
Future<BuiltList<CategoryCard>>.value(BuiltList<CategoryCard>())
..hasReachedEndOfDocuments = false);
}
factory CategoryCardState.loading() {
return CategoryCardState((b) => b
..exception = ''
..categoryCards =
Future<BuiltList<CategoryCard>>.value(BuiltList<CategoryCard>())
..hasReachedEndOfDocuments = false
..isLoading = true);
}
factory CategoryCardState.loaded(Future<BuiltList<CategoryCard>> cards) {
return CategoryCardState((b) => b
..exception = ''
..categoryCards = cards
..hasReachedEndOfDocuments = false
..isLoading = false
..isLoaded = true);
}
factory CategoryCardState.success(Future<BuiltList<CategoryCard>> cards) {
return CategoryCardState((b) => b
..exception = ''
..categoryCards =
Future<BuiltList<CategoryCard>>.value(BuiltList<CategoryCard>())
..hasReachedEndOfDocuments = false
..isSuccessful = true);
}
factory CategoryCardState.failed(
String exception, Future<BuiltList<CategoryCard>> cards) {
return CategoryCardState((b) => b
..exception = exception
..categoryCards = cards
..hasReachedEndOfDocuments = false);
}
}
The event:
abstract class CategoryCardEvents extends Equatable {}
class LoadCategoryCardEvent extends CategoryCardEvents {
final DocumentSnapshot amountDocumentSnapshot;
LoadCategoryCardEvent({@required this.amountDocumentSnapshot});
@override
List<Object> get props => [amountDocumentSnapshot];
}
The pagination screen(Contained inside a stateful widget):
//Notification Handler
bool _scrollNotificationHandler(
ScrollNotification notification,
DocumentSnapshot amountDocumentSnapshot,
bool hasReachedEndOfDocuments,
Future<BuiltList<CategoryCard>> cards) {
if (notification is ScrollEndNotification &&
_scollControllerHomeScreen.position.extentAfter == 0 &&
!hasReachedEndOfDocuments) {
setState(() {
_hasReachedEnd = true;
});
_categoryCardBloc.add(LoadCategoryCardEvent(
amountDocumentSnapshot: amountDocumentSnapshot));
}
return false;
}
BlocListener<CategoryCardBloc, CategoryCardState>(
bloc: _categoryCardBloc,
listener: (context, state) {
if (state.exception != null &&
state.exception.isNotEmpty) {
if (state.exception == NullException.exceptionMessage) {
print('Null Exception');
} else {
ErrorDialogs.customAlertDialog(
context,
'Failed to load',
'Please restart app or contact support');
print(state.exception);
}
}
},
child: BlocBuilder<CategoryCardBloc, CategoryCardState>(
bloc: _categoryCardBloc,
builder: (context, state) {
if (state.isLoading != null && state.isLoading) {
return Center(
child: CustomLoader(),
);
}
if (state.isLoaded != null && state.isLoaded) {
return StreamBuilder<BuiltList<CategoryCard>>(
stream: state.categoryCards.asStream(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Center(
child: CustomLoader(),
);
} else {
BuiltList<CategoryCard> categoryCards =
snapshot.data;
_hasReachedEnd = false;
print(state.hasReachedEndOfDocuments &&
state.hasReachedEndOfDocuments != null);
return Container(
height: Mquery.screenHeight(context),
width: Mquery.screenWidth(context),
child: NotificationListener<
ScrollNotification>(
onNotification: (notification) =>
_scrollNotificationHandler(
notification,
categoryCards.last.document,
state.hasReachedEndOfDocuments,
state.categoryCards),
child: SingleChildScrollView(
controller: _scollControllerHomeScreen,
child: Column(
children: [
CustomAppBar(),
Padding(
padding: EdgeInsets.all(
Mquery.padding(context, 2.0)),
child: Row(
children: [
Expanded(
flex: 5,
child: Padding(
padding: EdgeInsets.all(
Mquery.padding(
context, 1.0)),
child:Container(
width: Mquery.width(
context, 50.0),
height: Mquery.width(
context, 12.5),
decoration:
BoxDecoration(
color: middle_black,
borderRadius: BorderRadius
.circular(Constants
.CARD_BORDER_RADIUS),
boxShadow: [
BoxShadow(
color: Colors
.black54,
blurRadius:
4.0,
spreadRadius:
0.5)
],
),
child: Padding(
padding: EdgeInsets.fromLTRB(
Mquery.padding(
context,
4.0),
Mquery.padding(
context,
4.0),
Mquery.padding(
context,
2.0),
Mquery.padding(
context,
1.0)),
child: TextField(
textInputAction:
TextInputAction
.done,
style: TextStyle(
color: white,
fontSize: Mquery
.fontSize(
context,
4.25)),
controller:
searchController,
decoration:
InputDecoration(
border:
InputBorder
.none,
hintText: Constants
.SEARCH_MESSAGE,
hintStyle: TextStyle(
fontSize: Mquery
.fontSize(
context,
4.25),
color:
white),
),
),
),
),
),
Expanded(
flex: 1,
child: Padding(
padding: EdgeInsets.all(
Mquery.padding(
context, 1.0)),
child: Container(
decoration:
BoxDecoration(
boxShadow: [
BoxShadow(
color: Colors
.black54,
blurRadius:
4.0,
spreadRadius:
0.5)
],
color: middle_black,
borderRadius: BorderRadius
.circular(Constants
.CARD_BORDER_RADIUS),
),
width: Mquery.width(
context, 12.5),
height: Mquery.width(
context, 12.5),
child: IconButton(
splashColor: Colors
.transparent,
highlightColor:
Colors
.transparent,
icon: Icon(
Icons.search,
color: white,
),
onPressed: () {
_onSearchButtonPressed();
},
),
),
))
],
),
),
ListView.builder(
shrinkWrap: true,
itemCount: categoryCards.length,
physics:
NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
return GestureDetector(
onTap: () {
//Navigate
},
child:
CategoryCardWidget(
categoryCount:
categoryCards
.length,
categoryCard:
categoryCards[
index]));
},
),
_hasReachedEnd
? Padding(
padding: EdgeInsets.all(
Mquery.padding(
context, 4.0)),
child: CustomLoader(),
)
: Container()
],
),
),
),
);
}
},
);
}
return Container();
}))
Thank you for you time and sorry for being so verbose
-Matt
Hi @MatthewCoetzee412 ๐
Thanks for opening an issue!
I'm guessing the issue is the related to the fact the new state being emitted is considered equal to the previous state as described by the FAQs. Are you able to share a link to a sample app which illustrates the issue? It would be much easier for me to help/provide suggestions if I am able to reproduce/debug the issue locally, thanks! ๐
Hi Felangel, thank you for the swift response! I attempted to make a mock of my app to reproduce the problem but the problem comes from the Streambuilder not refreshing with the updated Firestore data after a BLOC event which I couldn't model accurately enough to reproduce the problem, so I would have to make an app using Firebase. I will post the link tomorrow to the repository, thanks for your help!
Hi Falangel,
Below is a replication of the problem:
https://github.com/MatthewCoetzee412/built_value_bloc.git
If you add a Food item, the Streambuilder does not update itself.
I hypothesise this might be due to my architecture with the BVPipe. That is a necessary part of my architecture as it converts the QuerySnapshot to a custom model and checks for errors. I'm not sure how else to make an effective, robust error handling layer.
Any help would be appreciated!
Thanks
Hi @MatthewCoetzee412 ๐
you're extending equatable on your events, but you're also using a field of type DocumentSnapshot
; your equatable is not gonna work here since DocumentSnapshot
doesn't extend equatable, not to mention that a data source type shouldn't be present inside your model.
the last observation also applies to state classes containing BuiltList<Food>
.
instead of using BVPipe
, your repo should expose a Stream<YourModel>
, since as already mentioned, you don't want to have your data source models leaking inside your other layers.
error handling is easily achievable using Stream
operators like handleError
, rxdart
's onErrorReturnWith
or even create your own custom StreamTransformer
based on your requirements. You would either throw app specific exceptions or even opt in to return valid values(this being done inside repos).
I highly recommend you think twice whether built_value
bring something valuable to you, since I find it quite verbose; have a look at freezed
, at least you're gaining things like pattern matching, unions.
Hope this gives you a couple of ideas โ
Hi @RollyPeres and @felangel , thank you so much for the advice! ๐ ๐ฏ
I have switched from built_value to freezed and its made it a lot easier to implement everything I need too plus much less boiler plate. I also toke your advice for handling errors in Streams and I am using the dartz package now which makes it really easy and lastly I removed the Pipe Layer completely. I have managed to implement everything but I just have one caveat which I'm not sure how to fix, which is the Food Items are not updating once I add an Item after I have scroll down and fetched more results. I don't mind closing the issue and I don't want to waste your time but if you wouldn't mind taking a look at the updated repository and provide any suggestions perhaps on how to fix it I would appreciate it a lot. Any help would be appreciated but I don't mind closing and figuring it out for myself.
Repo Link:
https://github.com/MatthewCoetzee412/built_value_bloc.git
My thanks again!
I've opened a PR with some minor updates mostly related to updating bloc to latest version.
The problem you're facing is called firestore live pagination ๐คฆโโ๏ธ
The thing is that when you're loading more items you're only getting live updates on the latest batch: startAfterDocument(lastSnapshot)
. So firestore will only send you updates for that batch.
In order to get real time updates on all your items, you need to keep subscriptions alive for all your batches instead of cancelling them: await _streamSubscription?.cancel();
.
Thanks @RollyPeres ๐ ๐ ๐ ,
Really, genuinely , appreciate all the help, thank you very much!
Kind Regards
-Matt
Hi, for anyone in the future wanting to know how to fix the problem of realtime pagination as metioned above,
Below is the solution:
class FoodLoadBloc extends Bloc<FoodLoadEvent, FoodLoadState> {
final FirebaseRepository _repository;
FoodLoadBloc(this._repository);
//Create a list of stream subscriptions
List<StreamSubscription> _subscriptions = [];
@override
FoodLoadState get initialState => FoodLoadState.intial();
@override
Stream<FoodLoadState> mapEventToState(FoodLoadEvent event) async* {
yield* event.map(load: (_) async* {
StreamSubscription<Either<ItemFailure, List<Food>>> _streamSubscription =
_repository.getMoreItems(_.items, _.documentSnapshot).listen(
(foodItems) => add(FoodLoadEvent.itemRecieved(foodItems)));
//Add a new stream subscription to the list each time the event is called
_subscriptions.add(_streamSubscription);
}, itemRecieved: (event) async* {
yield event.items
.fold((l) => FoodLoadState.error(l), (r) => FoodLoadState.success(r));
});
}
@override
Future<void> close() {
//Lastly, dispose the stream subscriptions
for (StreamSubscription sub in _subscriptions) sub.cancel();
return super.close();
}
}
Hope that helps someone!
Most helpful comment
I've opened a PR with some minor updates mostly related to updating bloc to latest version.
The problem you're facing is called firestore live pagination ๐คฆโโ๏ธ
The thing is that when you're loading more items you're only getting live updates on the latest batch:
startAfterDocument(lastSnapshot)
. So firestore will only send you updates for that batch.In order to get real time updates on all your items, you need to keep subscriptions alive for all your batches instead of cancelling them:
await _streamSubscription?.cancel();
.