Merging streams together to create paginated ListView - firebase

I'm building a ListView containing a list of Hour items stored in Firestore. I was able to create it with StreamBuilder, however given the fact that Hours collections contains thousands of documents, it is necessary to introduce pagination to reduce unnecessary reads.
My idea is to create a stream (mainStream) with queried items for the last 24 hours. If user scrolls down 20 items, I will expand the mainStream with 20 additional elements by merging it with a new stream. However, it doesn't seem to work - only first 24 items are in the stream, merging doesn't seem to have any impact.
Why I want to have paginated Stream, not a list? Hour items can be changed on other devices and I need streams to notice changes.
I'm stuck with it for a more than a day and other questions do not help - any help is much appreciated.
class TodayPage extends StatelessWidget {
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Today'),
),
body: _buildContents(context),
);
}
Widget _buildContents(BuildContext context) {
final database = Provider.of<Database>(context, listen: true);
DateTime startingHour =
dateHourFromDate(DateTime.now()).subtract(Duration(hours: 24));
DateTime beforeHour;
// Stream with items for last 24 hours
// hoursStream returns Stream<List<Hour>> from startingHour till beforeHour
Stream mainStream = database.hoursStream(startingHour: startingHour.toString());
return StreamBuilder<List<Hour>>(
stream: mainStream,
builder: (context, snapshot) {
if (snapshot.data != null) {
final List<Hour> hours = snapshot.data;
final allHours = hours.map((hour) => hour.id).toList();
return ListView.builder(
// chacheExtent to make sure that itemBuilder will not build items for which we have not yet fetched data in the new stream
cacheExtent: 5,
itemBuilder: (context, index) {
// if itemBuilder is reaching till the end of items in mainStream, extend mainStream by items for the following 24 hours
if (index % 20 == 0) {
beforeHour = startingHour;
startingHour = startingHour.subtract(Duration(hours: 20));
// doesn't seem to work - snapshot contains elements only from the original mainStream
mainStream.mergeWith([
database.hoursStream(
beforeHour: beforeHour.toString(),
startingHour: startingHour.toString())
]);
}
return Container(); // placeholder for a widget with content based on allHours
});
} else {
return Center(child: CircularProgressIndicator());
}
},
);
}
}
Implementation of hoursStream:
Stream<List<Hour>> hoursStream({
String startingHour,
String beforeHour,
}) =>
_service.collectionStream(
path: APIPath.hours(uid),
builder: (data, documentId) => Hour.fromMap(data, documentId),
queryBuilder: (query) => query
.where('id', isGreaterThanOrEqualTo: startingHour)
.where('id', isLessThan: beforeHour));
and finally collectionStream
Stream<List<T>> collectionStream<T>({
#required String path,
#required T Function(Map<String, dynamic> data, String documentId) builder,
Query Function(Query query) queryBuilder,
int Function(T lhs, T rhs) sort,
}) {
Query query = FirebaseFirestore.instance.collection(path);
if (queryBuilder != null) {
query = queryBuilder(query);
}
final snapshots = query.snapshots();
return snapshots.map((snapshot) {
final result = snapshot.docs
.map((snapshot) => builder(snapshot.data(), snapshot.id))
.where((value) => value != null)
.toList();
if (sort != null) {
result.sort(sort);
}
return result;
});
}

If you want to combine the streams you need to use rxdart package
import the package:
import 'package:rxdart/rxdart.dart';
then use the following code to combine the streams
CombineLatestStream.list([stream1, stream2,.....]);
want to listen for changes use the following code
CombineLatestStream.list([stream1, stream2,.....]).listen((data) {
data.elementAt(0); // stream1
data.elementAt(1); // stream2
});
If the listen method does not work as you intended there are other methods available
for more check out this link: https://pub.dev/packages/rxdart

As far as I know, Stream does not have a mergeWith method, so I don't know if it's an extension you created or from library, but one thing you are forgetting is the setState.
if (index % 20 == 0) {
beforeHour = startingHour;
startingHour = startingHour.subtract(Duration(hours: 20));
setState(() {
mainStream.mergeWith([
database.hoursStream(
beforeHour: beforeHour.toString(),
startingHour: startingHour.toString())
]);
});
}
By the way, I like what you are doing with your collectionStream method. Very beautiful coding.

Related

Adding up values stored in Firebase and displaying total in a FutureBuilder

Im trying to retrieve a list of monthly expenses from Firebase, add up the amount of each monthly expense and show it in a FutureBuilder.
In the Text widget i'm simply getting null. Been trying to google an answer for the past half hour but i don't even know where to begin as in what to search for.
Tried throwing in some print statement to see whats going on but any print statement I put in after the provider call doesn't show.
EDIT: Added Future casts to the commented lines. still no luck. but ignored the variable and called the future function directly from the FutureBuilder. Now all print statements are working and it is returning an instance of 'MonthlyExpense'. It is null still.
class SummaryView extends StatefulWidget {
#override
_SummaryViewState createState() => _SummaryViewState();
}
class _SummaryViewState extends State<SummaryView> {
/* Removed future variable and initState()
Future<double> _totalExpensesAmount; //Future<double> added
#override
void initState() {
super.initState();
print("init");
_totalExpensesAmount = _getTotalExpensesAmount();
}
*/
Future<double> _getTotalExpensesAmount() async { //Future<double> added
print("started");
final user = Provider.of<BPUser>(context);
print("user: $user");
double expensesTotal = 0.0;
var snapshot = await FirebaseFirestore.instance
.collection("BPUsers/${user.uid}/monthlyexpenses")
.get();
print(snapshot);
List<MonthlyExpense> searchedProducts =
DatabaseService().monthlyExpenseListFromSnapshot(snapshot);
print(searchedProducts);
for (var i = searchedProducts.length; i >= 1; i--) {
expensesTotal += double.parse(searchedProducts[i].amount);
}
return Future<double>expensesTotal; //Future<double> added
}
#override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: mainBackgroundColor,
body: Column(
children: [
Container(
child: FutureBuilder(
future: _getTotalExpensesAmount(), // called the function directly
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return Text(
"${snapshot.data}",
style: nameStyle,
);
} else {
return Text("Loading...");
}
}),
)
],
),
);
}
}
The monthlyExpenseListFromSnapshot function (which works perfectly in another widget I use it in):
List<MonthlyExpense> monthlyExpenseListFromSnapshot(QuerySnapshot snapshot) {
return snapshot.docs.map((doc) {
return MonthlyExpense(
name: doc.data()['name'] ?? '',
amount: doc.data()['amount'] ?? '',
isActive: doc.data()['isActive']);
}).toList();
}
The firebase database:
You're getExpensesTotal doesn't return a future. Because of this, the widgetvwill never rebuild since the data is already calculated. FutureBuilder and StreamBuilder only cause a rebuild AFTER data been loaded after the initial build. Try surrounding your expensesTotal with Future.value.
You need to define the signature of the function to represent what it is expected, in this case Future <double>.
// ....
Future<double> _getTotalExpensesAmount() async {
First issue was to skip the variable and the init call and call the _getTotalExpensesAmount() future function (adding the Future signature to it).
Secondly my for loop was wrong. needed to change the conditions to:
for (var i = searchedProducts.length - 1; i >= 0; i--) {
Everything is working fine now!

Get a concatenated list of items from multiple documents in flutter / firebase

I have a firebase database which has a collection called "quizzes" where each quiz is a document with several questions in it.
Now, I am trying to create a list questions from multiple "quiz" documents, and pass them on to a pageview builder in flutter.
However, I am having trouble getting a list of items from multiple documents in flutter. The code works till the point where I can get a list of quizzes from various topics as I indicated below, but when I try to get the list of items using two functions below, I still get an empty list at the end.
I am new to flutter and asynchronous programming. Any help is greatly appreciated.
class PracticeScreen extends StatelessWidget {
List<Question> questions = [];
#override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
builder: (_) => QuizState2(),
child: FutureBuilder(
future: Global.topicsRef.getData(),
builder: (BuildContext context, AsyncSnapshot snap) {
var state = Provider.of<QuizState2>(context);
if (!snap.hasData || snap.hasError) {
return LoadingScreen();
} else {
List<Topic> topics = snap.data;
List<Quiz> quizzes =
topics.map((topic) => topic.quizzes).expand((x) => x).toList();
print(quizzes.length);
print(quizzes.map((quiz) => quiz.description).toList());
// the code works till this point where I get a list of quizzes from multiple topics
quizzes.map((quiz) => _getQuestions3(quiz.id));
// the questions array still remains empty at this point.
print(questions.map((question) => question.text).toList());
return Scaffold(
body: PageView.builder(
physics: NeverScrollableScrollPhysics(),
scrollDirection: Axis.vertical,
controller: state.controller,
onPageChanged: (int idx) =>
state.progress = (idx / (questions.length + 1)),
itemBuilder: (BuildContext context, int idx) {
//return Text('Sample');
return QuestionPage2(question: questions[idx]);
},
itemCount: questions.length - 1,
),
bottomNavigationBar: AppBottomNav(),
);
}
},
),
);
}
_getQuestions2(String quizId) async {
Future quizData = Document<Quiz>(path: 'quizzes/$quizId').getData();
quizData.then((quizdata) {
questions = questions..addAll(quizdata.questions.toList());
});
}
_getQuestions3(String quizId) async {
await _getQuestions2(quizId);
}
}
}
I think the following line is wrong:
questions = questions..addAll(quizdata.questions.toList());
Per Dart/Flutter documentation addAll function is a so-called void function. This means it only does the job and does not return anything after that.
questions..addAll() adds quizdata to the questions list and returns nothing (null). By calling questions = questions..addAll() you essentially overwrite your questions list with null.
Replace
questions = questions..addAll(quizdata.questions.toList());
with the following line to fix this issue:
questions.addAll(quizdata.questions.toList());
The whole function should then look like:
_getQuestions2(String quizId) async {
Future quizData = Document<Quiz>(path: 'quizzes/$quizId').getData();
quizData.then((quizdata) {
questions.addAll(quizdata.questions.toList());
});
}
This is what ended up working for me.
I think having multiple firestone queries within one async function was easier than trying to go through multiple functions.
Future<List<Question>> getQuestions() async {
var firestore = Firestore.instance;
var topicsSnap = await firestore.collection('topics').getDocuments();
var quizzes =
(topicsSnap.documents.map((topic) => topic.data['quizzes'])).toList();
var quizids = quizzes.map((quizitem) => quizitem[0]['id']).toList();
var questions = [];
for (int i = 0; i < quizids.length; i++) {
var quizid = quizids[i];
var quizsnap = await firestore.document('quizzes/$quizid').get();
var quizQuestions = quizsnap.data['questions'];
questions.addAll(quizQuestions);
}
var question2 = questions.map((v) => Question.fromMap(v)).toList();
return (question2);
}

How to cache Firebase data in Flutter?

In my app I build a list of objects using data from Firebase. Inside a StreamBuilder, I check if the snapshot has data. If it doesen't, I am returning a simple Text widget with "Loading...". My problem is that if I go to another page in the app, and then come back, you can see for a split second that it says 'Loading...' in the middle of the screen, and it is a bit irritating. I am pretty sure it is downloading the data from Firebase, and building the widget every time I come back to that page. And if I don't do the check for data, it gives me a data that I am trying to access data from null.
Is there a way to cache the data that was already downloaded, and if there has been no change in the data from Firebase, then just use the cached data?
Heres a redacted version of my code:
class Schedule extends StatefulWidget implements AppPage {
final Color color = Colors.green;
#override
_ScheduleState createState() => _ScheduleState();
}
class _ScheduleState extends State<Schedule> {
List<Event> events;
List<Event> dayEvents;
int currentDay;
Widget itemBuilder(BuildContext context, int index) {
// Some Code
}
#override
Widget build(BuildContext context) {
return Center(
child: StreamBuilder(
stream: Firestore.instance.collection('events').snapshots(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Text("Loading...");
}
events = new List(snapshot.data.documents.length);
for (int i = 0; i < snapshot.data.documents.length; i++) {
DocumentSnapshot doc = snapshot.data.documents.elementAt(i);
events[i] = Event(
name: doc["name"],
start: DateTime(
doc["startTime"].year,
doc["startTime"].month,
doc["startTime"].day,
doc["startTime"].hour,
doc["startTime"].minute,
),
end: DateTime(
doc["endTime"].year,
doc["endTime"].month,
doc["endTime"].day,
doc["endTime"].hour,
doc["endTime"].minute,
),
buildingDoc: doc["location"],
type: doc["type"],
);
}
events.sort((a, b) => a.start.compareTo(b.start));
dayEvents = events.where((Event e) {
return e.start.day == currentDay;
}).toList();
return ListView.builder(
itemBuilder: itemBuilder,
itemCount: dayEvents.length,
);
},
),
);
}
}
You can use the the following code to define the source you want to retrieve data from. This will search either in local cache or on the server, not both. It works for all get() parameters, no matter if it is a search or document retrieval.
import 'package:cloud_firestore/cloud_firestore.dart';
FirebaseFirestore.instance.collection("collection").doc("doc").get(GetOptions(source: Source.cache))
To check if the search has data in cache, you need to first run the search against cache and if there is no result, run it against the server.
I found project firestore_collection to use a neat extension that can greatly simplify this process.
import 'package:cloud_firestore/cloud_firestore.dart';
// https://github.com/furkansarihan/firestore_collection/blob/master/lib/firestore_document.dart
extension FirestoreDocumentExtension on DocumentReference {
Future<DocumentSnapshot> getSavy() async {
try {
DocumentSnapshot ds = await this.get(GetOptions(source: Source.cache));
if (ds == null) return this.get(GetOptions(source: Source.server));
return ds;
} catch (_) {
return this.get(GetOptions(source: Source.server));
}
}
}
// https://github.com/furkansarihan/firestore_collection/blob/master/lib/firestore_query.dart
extension FirestoreQueryExtension on Query {
Future<QuerySnapshot> getSavy() async {
try {
QuerySnapshot qs = await this.get(GetOptions(source: Source.cache));
if (qs.docs.isEmpty) return this.get(GetOptions(source: Source.server));
return qs;
} catch (_) {
return this.get(GetOptions(source: Source.server));
}
}
If you add this code, you can simply change the .get() command for both documents and queries to .getSavy() and it will automatically try the cache first and only contact the server if no data can be locally found.
FirebaseFirestore.instance.collection("collection").doc("doc").getSavy();
To be sure whether the data is coming from Firestore's local cache or from the network, you can do this:
for (int i = 0; i < snapshot.data.documents.length; i++) {
DocumentSnapshot doc = snapshot.data.documents.elementAt(i);
print(doc.metadata.isFromCache ? "NOT FROM NETWORK" : "FROM NETWORK");
In the case you described you are probably going to still see the loading screen when its "NOT FROM NETWORK". This is because it does take some time to get it from the local cache. Soon you will be able to ask for the query's metadata for cases with empty results.
Like others suggested, you can cache the results and you won't see this. First you can try to cache it in the Widget using something like:
QuerySnapshot cache; //**
#override
Widget build(BuildContext context) {
return Center(
child: StreamBuilder(
initialData: cache, //**
stream: Firestore.instance.collection('events').snapshots(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Text("Loading...");
}
cache = snapshot.data; //**
This will make your widget remember the data. However, if this does not solve your problem, you would have to save it not in this widget but somewhere else. One option is to use the Provider widget to store it in a variable that lives beyond the scope of this particular widget.
Probably not related, but it's also a good idea to move the Firestore.instance.collection('events').snapshots() to initState(), save the reference to the stream in a private field and use that it StreamBuilder. Otherwise, at every build() you may be creating a new stream. You should be ready for build() calls that happen many times per second, whatever the reason.
Using Generics
Appending to #James Cameron's answer above; I found myself in a situation where, said implementation removed my typecast from withConverter. So, the below adds the generic types back into the functions.
main.dart
import 'package:cloud_firestore/cloud_firestore.dart';
extension FirestoreDocumentExtension<T> on DocumentReference<T> {
Future<DocumentSnapshot<T>> getCacheFirst() async {
try {
var ds = await get(const GetOptions(source: Source.cache));
if (!ds.exists) return get(const GetOptions(source: Source.server));
return ds;
} catch (_) {
return get(const GetOptions(source: Source.server));
}
}
}
extension FirestoreQueryExtension<T> on Query<T> {
Future<QuerySnapshot<T>> getCacheFirst() async {
try {
var qs = await get(const GetOptions(source: Source.cache));
if (qs.docs.isEmpty) return get(const GetOptions(source: Source.server));
return qs;
} catch (_) {
return get(const GetOptions(source: Source.server));
}
}
}
use_case.dart
The implementation below would not compile with James example, as DocumentSnapshot<Object?> is not a subset of DocumentSnapshot<UserModel>. So, by adding the generic parameters back in, we can ensure that this extension maintains any type casts.
Future<DocumentSnapshot<UserModel>> userInfo() async {
return await FirebaseFirestore.instance
.doc("${path_to_user_model_doc}")
.withConverter<UserModel>(
fromFirestore: (snapshot, _) => UserModel.fromJson(snapshot.data()!),
toFirestore: (userModel, _) => userModel.toJson(),
)
.getCacheFirst();
}
pubspec.yaml
environment:
sdk: ">=2.17.1 <3.0.2"
dependencies:
cloud_firestore: ^3.1.17

Flutter/Firestore - Streambuilder and Firestore with realtime listener

I'm trying to set up a Firestore stream inside a stream builder like how they showed in their doc but when I update a doc or delete one it doesn't get reflected on the screen. The logs gets updated but not the actual screen.
Code:
// Chats API file
Stream<List<Message>> messagesStream({String chatID, String orderBy, bool descending, List<dynamic> startAfter, int limit}) {
return this
.collection
.document(chatID)
.collection(Config.messages)
.orderBy(orderBy, descending: descending)
.startAfter(startAfter)
.limit(limit)
.snapshots()
.map((event) => event.documents.map((documentSnapshot) => Message().model(id: documentSnapshot.documentID, map: documentSnapshot.data)).toList().reversed.toList());
}
// Chat file
Widget _streamBuilderWidget;
#override
void initState() {
super.initState();
this._streamBuilder();
if (Platform.isIOS) {
this._scrollController.addListener(() {
if (this._scrollController.position.pixels <= -135.0) {
this._checkForOldMessages();
}
});
}
}
#override
Widget build(BuildContext context) {
…
Expanded(child: this._streamBuilderWidget),
…
}
Widget _streamBuilder() {
return this._streamBuilderWidget = StreamBuilder<List<Message>>(
initialData: this._messages,
stream: APIs().chats.messagesStream(chatID: widget.chat.chatID, orderBy: 'createdAt', descending: true, startAfter: this._startAfter, limit: this._messagesLimit),
builder: (context, snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.waiting:
{
return PAIndicator();
}
default:
{
if (snapshot.data.length < this._messagesLimit) {
this._hasMoreMessages = false;
} else {
this._hasMoreMessages = true;
}
snapshot.data.forEach((m) {
print(m.message); // Prints 10 values even when 1 file is changed
});
this._messages.insertAll(0, snapshot.data);
return Platform.isIOS
? MessagesList(mKey: this._listKey, scrollController: this._scrollController, messages: this._messages, aUser: widget.aUser)
: RefreshIndicator(
child: MessagesList(mKey: this._listKey, scrollController: this._scrollController, messages: this._messages, aUser: widget.aUser),
onRefresh: () async {
await Future.delayed(Duration(seconds: 1));
this._checkForOldMessages();
return null;
});
}
}
});
}
Also, when I send a message it doesn't get added to the list, I have to go back and enter the chatroom again to see the latest message.
How can I have it so that when I open the chat room I can see the new messages coming in while just showing the most recent messages to reduce costs, I don't want to show all 10000 messages. Just the most recent ones and the ones that are being added to it while users are in the chatroom.
Note: I have it like this because for somereason whenever I'm typing it keeps reloading and when I scroll the array just multiplies itself and what not. This is the only way I have add older messages to the array as I scroll that I know of.
The new messages do not show until I re-enter the chatroom.
With regards to how to only show the most recent messages, you should be implementing paginated queries and lazy load the older messages after a certain scroll point.
https://firebase.google.com/docs/firestore/query-data/query-cursors

Flutter can't read from Clipboard

I come asking for quite a specific question regarding Flutter and the Future and await mechanism, which seems to be working, but my Clipboard does not really function while operating with my editable text fields, even following Google's advice on implementation...
This is my code for pasting:
onPressed: () async {
await getMyData('text');
_encodingController.text = clipData;
Scaffold.of(context).showSnackBar(
new SnackBar(
content: new Text(
"Pasted from Clipboard"),
),
);
},
what doesnt work is my paste functionality... While debugging the result of this following function is null, wth?????????
static Future<ClipboardData> getMyData(String format) async {
final Map<String, dynamic> result =
await SystemChannels.platform.invokeMethod(
'Clipboard.getData',
format,
);
if (result == null) {
return null;
} else {
clipData = ClipboardData(text: result['text']).text;
return ClipboardData(text: result['text'].text);
}
}
I am probably using the Futures and async await wrong, would love some guidance!!! Copying is working using the Clipboard Manager plugin! Thanks very much!
You can simply re-use Flutter's existing library code to getData from Clipboard.
ClipboardData data = await Clipboard.getData('text/plain');
First create a method
Future<String> getClipBoardData() async {
ClipboardData data = await Clipboard.getData(Clipboard.kTextPlain);
return data.text;
}
Then in build method
FutureBuilder(
future: getClipBoardData(),
initialData: 'nothing',
builder: (context, snapShot){
return Text(snapShot.data.toString());
},
),
It's works for me:
_getFromClipboard() async {
Map<String, dynamic> result =
await SystemChannels.platform.invokeMethod('Clipboard.getData');
if (result != null) {
return result['text'].toString();
}
return '';
}
Also can be useful if you want to listen for periodic updates from the system clipboard.
Originally I replied here, just re-posting the solution:
#creating a listening Stream:
final clipboardContentStream = StreamController<String>.broadcast();
#creating a timer for updates:
Timer clipboardTriggerTime;
clipboardTriggerTime = Timer.periodic(
# you can specify any duration you want, roughly every 20 read from the system
const Duration(seconds: 5),
(timer) {
Clipboard.getData('text/plain').then((clipboarContent) {
print('Clipboard content ${clipboarContent.text}');
# post to a Stream you're subscribed to
clipboardContentStream.add(clipboarContent.text);
});
},
);
# subscribe your view with
Stream get clipboardText => clipboardController.stream
# and don't forget to clean up on your widget
#override
void dispose() {
clipboardContentStream.close();
clipboardTriggerTime.cancel();
}

Resources