I'm trying to make an auto suggest input which fetches the results from a backend API. Here is my code:
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hello_world/api.dart';
import 'package:hello_world/autocomplete_item.dart';
class Debouncer {
final int milliseconds;
VoidCallback? action;
Timer? _timer;
Debouncer({this.milliseconds = 250});
run(VoidCallback action) {
_timer?.cancel();
_timer = Timer(Duration(milliseconds: milliseconds), action);
}
}
class AutoCompleteInput extends StatefulWidget {
const AutoCompleteInput({
Key? key,
this.label = 'Suggest',
this.textInputAction,
this.validator,
this.errorMessage,
}) : super(key: key);
final String label;
final TextInputAction? textInputAction;
final FormFieldValidator<String>? validator;
final String? errorMessage;
#override
_AutoCompleteInputState createState() => _AutoCompleteInputState();
}
class _AutoCompleteInputState extends State<AutoCompleteInput> {
final _debouncer = Debouncer(milliseconds: 500);
List<AutoCompleteItem> _options = [];
bool _isLoading = false;
#override
Widget build(BuildContext context) {
return Autocomplete<AutoCompleteItem>(
displayStringForOption: (AutoCompleteItem item) => item.value,
optionsBuilder: _optionsBuilder,
fieldViewBuilder: (
BuildContext context,
TextEditingController fieldTextEditingController,
FocusNode fieldFocusNode,
VoidCallback onFieldSubmitted,
) {
return TextFormField(
controller: fieldTextEditingController,
focusNode: fieldFocusNode,
autocorrect: false,
maxLength: 50,
maxLines: 1,
keyboardType: TextInputType.text,
textInputAction: widget.textInputAction,
decoration: InputDecoration(
labelText: widget.label,
contentPadding: EdgeInsets.zero,
errorText: widget.errorMessage,
counterText: '',
suffix: _isLoading
? Padding(
padding: const EdgeInsets.only(right: 1),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: null,
),
validator: widget.validator,
onFieldSubmitted: (String value) {
onFieldSubmitted();
},
onChanged: (text) {
_debouncer.run(() async {
setState(() => _isLoading = true);
await _fetchResults(text);
setState(() => _isLoading = false);
});
},
);
},
optionsViewBuilder: (
BuildContext context,
AutocompleteOnSelected<AutoCompleteItem> onSelected,
Iterable<AutoCompleteItem> options,
) {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 2,
child: Container(
width: 300,
constraints: BoxConstraints(maxHeight: 250),
child: ListView.builder(
padding: EdgeInsets.all(10.0),
itemCount: options.length,
shrinkWrap: true,
itemBuilder: (BuildContext context, int index) {
final AutoCompleteItem option = options.elementAt(index);
return GestureDetector(
onTap: () {
onSelected(option);
},
child: ListTile(
dense: true,
title: Text(option.value),
),
);
},
),
),
),
);
},
onSelected: (AutoCompleteItem selection) {
print('${selection.type} => ${selection.value}');
},
);
}
Future<void> _fetchResults(String text) async {
// WE PERFORM THE HTTP REQUEST HERE AND THEN ASSIGN THE RESPONSE TO `_options`
setState(() async {
_options = await Api.fetchSuggestions(text);
});
}
Iterable<AutoCompleteItem> _optionsBuilder(
TextEditingValue textEditingValue) {
if (textEditingValue.text == '') {
return const Iterable<AutoCompleteItem>.empty();
}
return _options;
}
}
As you can see, I call _fetchResults method (which fetches the data from the API) inside the onChanged method of TextFormField. But when the data is fetched and the state is updated, there is no overlay of suggestions! I checked the length of _options and it has all of the results but still setState did not forced Autocomplete widget to rebuild it's overlay. Although when I remove the last character of the TextFormField, it immediately shows the overlay. How can I fix this issue?
In the TextField's onChanged method calls the textEditingController.notifyListeners() and ignores the warnings
example
TextField(
onChanged: (text)async{
timer?.cancel();
timer = Timer(const Duration(milliseconds: 700), () async {
await getData(textEditingController).whenComplete((){
setState(() {
// ignore: invalid_use_of_protected_member
//invalid_use_of_visible_for_testing_member
textEditingController.notifyListeners();
});
});
});
},
)
I have a textfield (see image) that when a user selects an address it is copied into the textfield. The issue is, even when the textfield loses focus, it is that you cannot see the beginning of the text. In this example, the street number is to the left and cut off. I want the textfield, when losing focus or when text is set programmatically, to show the far left side of the text, not the right side. I am guessing the cursor is at the end. But when losing focus there is no cursor, a textfield below has focus.
I am using AutoComplete package. But here is the code.
library auto_complete_text_view;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:rxdart/rxdart.dart';
typedef void OnTapCallback(String value);
class AutoCompleteTextView extends StatefulWidget
with AutoCompleteTextInterface {
final double maxHeight;
final TextEditingController controller;
//AutoCompleteTextField properties
final tfCursorColor;
final tfCursorWidth;
final tfStyle;
final tfTextDecoration;
final tfTextAlign;
//Suggestiondrop Down properties
final suggestionStyle;
final suggestionTextAlign;
final onTapCallback;
final Function getSuggestionsMethod;
final Function focusGained;
final Function focusLost;
final int suggestionsApiFetchDelay;
final Function onValueChanged;
AutoCompleteTextView(
{#required this.controller,
this.onTapCallback,
this.maxHeight = 0,
this.tfCursorColor = Colors.white,
this.tfCursorWidth = 2.0,
this.tfStyle = const TextStyle(color: Colors.black),
this.tfTextDecoration = const InputDecoration(),
this.tfTextAlign = TextAlign.left,
this.suggestionStyle = const TextStyle(color: Colors.black),
this.suggestionTextAlign = TextAlign.left,
#required this.getSuggestionsMethod,
this.focusGained,
this.suggestionsApiFetchDelay = 0,
this.focusLost,
this.onValueChanged});
#override
_AutoCompleteTextViewState createState() => _AutoCompleteTextViewState();
//This funciton is called when a user clicks on a suggestion
#override
void onTappedSuggestion(String suggestion) {
onTapCallback(suggestion);
}
}
class _AutoCompleteTextViewState extends State<AutoCompleteTextView> {
ScrollController scrollController = ScrollController();
FocusNode _focusNode = FocusNode();
OverlayEntry _overlayEntry;
LayerLink _layerLink = LayerLink();
final suggestionsStreamController = new BehaviorSubject<List<String>>();
List<String> suggestionShowList = List<String>();
Timer _debounce;
bool isSearching = true;
#override
void initState() {
super.initState();
_focusNode.addListener(() {
if (_focusNode.hasFocus) {
this._overlayEntry = this._createOverlayEntry();
Overlay.of(context).insert(this._overlayEntry);
(widget.focusGained != null) ? widget.focusGained() : () {};
} else {
this._overlayEntry.remove();
(widget.focusLost != null) ? widget.focusLost() : () {};
}
});
widget.controller.addListener(_onSearchChanged);
}
_onSearchChanged() {
if (_debounce?.isActive ?? false) _debounce.cancel();
_debounce =
Timer(Duration(milliseconds: widget.suggestionsApiFetchDelay), () {
if (isSearching == true) {
_getSuggestions(widget.controller.text);
}
});
}
_getSuggestions(String data) async {
if (data.length > 0 && data != null) {
List<String> list = await widget.getSuggestionsMethod(data);
suggestionsStreamController.sink.add(list);
}
}
OverlayEntry _createOverlayEntry() {
RenderBox renderBox = context.findRenderObject();
var size = renderBox.size;
return OverlayEntry(
builder: (context) => Positioned(
width: size.width,
child: CompositedTransformFollower(
link: this._layerLink,
showWhenUnlinked: false,
offset: Offset(0.0, size.height + 5.0),
child: Material(
elevation: 4.0,
child: StreamBuilder<Object>(
stream: suggestionsStreamController.stream,
builder: (context, suggestionData) {
if (suggestionData.hasData &&
widget.controller.text.isNotEmpty) {
suggestionShowList = suggestionData.data;
return ConstrainedBox(
constraints: new BoxConstraints(
maxHeight: 0,
),
child: ListView.builder(
controller: scrollController,
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: suggestionShowList.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(
suggestionShowList[index],
style: widget.suggestionStyle,
textAlign: widget.suggestionTextAlign,
),
onTap: () {
isSearching = false;
widget.controller.text =
suggestionShowList[index];
suggestionsStreamController.sink.add([]);
widget.onTappedSuggestion(
widget.controller.text);
},
);
}),
);
} else {
return Container();
}
}),
),
),
));
}
#override
Widget build(BuildContext context) {
return CompositedTransformTarget(
link: this._layerLink,
child: TextField(
controller: widget.controller,
decoration: widget.tfTextDecoration,
style: widget.tfStyle,
cursorColor: widget.tfCursorColor,
cursorWidth: widget.tfCursorWidth,
textAlign: widget.tfTextAlign,
focusNode: this._focusNode,
onChanged: (text) {
if (text.trim().isNotEmpty) {
(widget.onValueChanged != null)
? widget.onValueChanged(text)
: () {};
isSearching = true;
scrollController.animateTo(
0.0,
curve: Curves.easeOut,
duration: const Duration(milliseconds: 300),
);
} else {
isSearching = false;
suggestionsStreamController.sink.add([]);
}
},
),
);
}
#override
void dispose() {
suggestionsStreamController.close();
scrollController.dispose();
widget.controller.dispose();
super.dispose();
}
}
abstract class AutoCompleteTextInterface {
void onTappedSuggestion(String suggestion);
}
Using a ScrollController we can make the text on the far left side visible.
Create and initialize final scrollController = ScrollController();.
Create a method for showing left side text contents, method contains scrollController.jumpTo(0.0);.
In the TextField add scrollController: property and set its value to scrollController from step 1.
In the TextField add onEditingComplete: property and set its value to method created in step 2.
Made a Small Code sample for the above mentioned points: pastebin
Note:
This code does not move the cursor. It only makes the left side visible.
This code relies on the onEditingComplete callback, in different situations you may need to use another callback.
I could fetch the JSON response from the server and make a list. Now I wanted to add a filter to that list.
To do so, I followed an online tutorial. in that tutorial, the "duplicateItems" variable has created as final. See the code:
final duplicateItems = List<String>.generate(10000, (i) => "Item $i");
var items = List<String>();
but in my case, as I'm using Future method to get the list from a server, I can't or I don't know to make that variable as final. See the code:
class _ListServiceProvidersState extends State<ListServiceProviders> {
List items;
List duplicateItems;
#override
void initState() {
super.initState();
getSharedValues();
}
getSharedValues() async {
bool value = await sharedPreferenceService.getSharedPreferencesInstance();
if (value) {
token = await sharedPreferenceService.token;
fetchServices();
} else {
commonModelServices.showMessage(
'You must log in before use the services', _scaffoldKey, Colors.red);
Navigator.pushNamed(
context,
'/LoginPage',
);
}
}
Future<String> fetchServices() async {
SchedulerBinding.instance.addPostFrameCallback((_) {
commonModelServices.onLoading(context);
});
final response = await http.get(
'API URL?category_id=$catId&city=$cityId&latitude=$latitude&longitude=$longitude');
setState(() {
commonModelServices.offLoading(context);
var resBody = json.decode(response.body);
if (resBody['success'] == true) {
setState(() {
duplicateItems = resBody['data']['data'];
items = duplicateItems;
});
}
});
return "Success";
}
This is my filtering function
void filterSearchResults(String query) {
List dummySearchList = List();
print('Original List');
dummySearchList.addAll(duplicateItems);
print(dummySearchList);
print('Search Query');
print(query);
if (query.isNotEmpty) {
print('search query not empty');
List dummyListData = List();
print('Start of the loop');
dummySearchList.forEach((item) {
print('List single item');
print(item);
if (item['name'].contains(query)) {
print('Item contain search query');
dummyListData.add(item);
}
});
print('End of the loop');
setState(() {
print('Clear duplicated List');
items.clear();
print('Set searched results');
items.addAll(dummyListData);
});
return;
} else {
print('Search query empty');
setState(() {
print('Clear prevoius searched in duplicate list');
items.clear();
print('Add Original List to duplicate');
items.addAll(duplicateItems);
});
}
}
The filter works as expected for the first letter in the search field. but then for the next key press, when the filterSearchResults function execute, the original List also get updated to the searched result of the first key press. So it search the search term from the previous searched results but not from the Original List. So, the result is wrong.
I want to keep the original Variable "duplicateItems" unchanged after a search.
Can you tell me what is missing here?
For your information, I'll put the full code below.
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_healthcare_app/src/theme/extention.dart';
import 'package:flutter_healthcare_app/src/theme/light_color.dart';
import 'package:flutter_healthcare_app/src/theme/text_styles.dart';
import 'package:flutter_healthcare_app/src/theme/theme.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_healthcare_app/src/model/shared_pref_model.dart';
import 'package:flutter_healthcare_app/src/model/common_model.dart';
class ListServiceProviders extends StatefulWidget {
ListServiceProviders({Key key}) : super(key: key);
#override
_ListServiceProvidersState createState() => _ListServiceProvidersState();
}
class _ListServiceProvidersState extends State<ListServiceProviders> {
TextEditingController editingController = TextEditingController();
final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>();
SharedPreferenceService sharedPreferenceService = SharedPreferenceService();
CommonModel commonModelServices = CommonModel();
List items;
List duplicateItems;
String token;
String cityId;
int catId;
String latitude;
String longitude;
#override
void initState() {
super.initState();
getSharedValues();
}
getSharedValues() async {
bool value = await sharedPreferenceService.getSharedPreferencesInstance();
if (value) {
token = await sharedPreferenceService.token;
cityId = await sharedPreferenceService.cityId;
catId = await sharedPreferenceService.catId;
latitude = await sharedPreferenceService.latitude;
longitude = await sharedPreferenceService.longitude;
fetchServices();
} else {
commonModelServices.showMessage(
'You must log in before use the services', _scaffoldKey, Colors.red);
Navigator.pushNamed(
context,
'/LoginPage',
);
}
}
Future<String> fetchServices() async {
SchedulerBinding.instance.addPostFrameCallback((_) {
commonModelServices.onLoading(context);
});
final response = await http.get(
'API URL?category_id=$catId&city=$cityId&latitude=$latitude&longitude=$longitude');
setState(() {
commonModelServices.offLoading(context);
var resBody = json.decode(response.body);
if (resBody['success'] == true) {
setState(() {
duplicateItems = resBody['data']['data'];
items = duplicateItems;
});
}
});
return "Success";
}
void filterSearchResults(String query) {
List dummySearchList = List();
print('Original List');
dummySearchList.addAll(duplicateItems);
print(dummySearchList);
print('Search Query');
print(query);
if (query.isNotEmpty) {
print('search query not empty');
List dummyListData = List();
print('Start of the loop');
dummySearchList.forEach((item) {
print('List single item');
print(item);
if (item['name'].contains(query)) {
print('Item contain search query');
dummyListData.add(item);
}
});
print('End of the loop');
setState(() {
print('Clear duplicated List');
items.clear();
print('Set searched results');
items.addAll(dummyListData);
});
return;
} else {
print('Search query empty');
setState(() {
print('Clear prevoius searched in duplicate list');
items.clear();
print('Add Original List to duplicate');
items.addAll(duplicateItems);
});
}
}
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: commonModelServices.appBar(context),
body: SingleChildScrollView(
child: Column(
children: <Widget>[
Container(
child: _header(),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
onChanged: (value) {
filterSearchResults(value);
},
controller: editingController,
decoration: InputDecoration(
labelText: "Search",
hintText: "Search",
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(25.0)))),
),
),
Container(
height: AppTheme.fullHeight(context),
child: new ListView.builder(
itemCount: items == null ? 0 : items.length,
itemBuilder: (BuildContext context, int index) {
return _categoryListView(index, items, context);
})),
],
)));
}
Widget _header() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text("Categories", style: TextStyles.titleM),
],
).p16;
}
/* Widget _doctorsList(index, data, context) {
}*/
Widget _categoryListView(index, model, context) {
return Container(
margin: EdgeInsets.symmetric(vertical: 8, horizontal: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(20)),
boxShadow: <BoxShadow>[
BoxShadow(
offset: Offset(4, 4),
blurRadius: 10,
color: LightColor.grey.withOpacity(.2),
),
BoxShadow(
offset: Offset(-3, 0),
blurRadius: 15,
color: LightColor.grey.withOpacity(.1),
)
],
),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 18, vertical: 8),
child: ListTile(
contentPadding: EdgeInsets.all(0),
leading: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(13)),
child: Container(
height: 55,
width: 55,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
color: randomColor(),
image: DecorationImage(
image: NetworkImage(model[index]['image'].toString()),
fit: BoxFit.cover,
),
),
/*child: Image.asset(
model[index]['image'].toString(),
height: 50,
width: 50,
fit: BoxFit.contain,
),*/
),
),
title: Text(model[index]['name'], style: TextStyles.title.bold),
subtitle: Text(
model[index]['description'],
style: TextStyles.bodySm.subTitleColor.bold,
),
trailing: Icon(
Icons.keyboard_arrow_right,
size: 30,
color: Theme.of(context).primaryColor,
),
),
).ripple(() {
Navigator.pushNamed(context, "/DetailPage", arguments: model);
}, borderRadius: BorderRadius.all(Radius.circular(20))),
);
}
void _showCategoryListPage(selectedCategory, catId, context) {
sharedPreferenceService.setCatId(catId);
Navigator.pushNamed(
context,
'/SelectServiceProvider',
);
}
Color randomColor() {
var random = Random();
final colorList = [
Theme.of(context).primaryColor,
LightColor.orange,
LightColor.green,
LightColor.grey,
LightColor.lightOrange,
LightColor.skyBlue,
LightColor.titleTextColor,
Colors.red,
Colors.brown,
LightColor.purpleExtraLight,
LightColor.skyBlue,
];
var color = colorList[random.nextInt(colorList.length)];
return color;
}
}
I got the answer in the google flutter community. I'm sharing it here for the future use of someone else.
there is only one line to update.
As I just do this
setState(() {
duplicateItems = resBody['data']['data'];
items = duplicateItems;
});
it only makes a reference variable. So when I update the items variable it automatically updates the duplicateItems variable as well.
so I have to update that like below. So both are working as two different lists.
duplicateItems = List.from(items);
So full code is like below.
setState(() {
commonModelServices.offLoading(context);
var resBody = json.decode(response.body);
if (resBody['success'] == true) {
setState(() {
items = resBody['data']['data'];
});
}
//items = duplicateItems;
duplicateItems = List.from(items);
});
The same thing was happening to me as to you. Matching variables is referencing them with each other, not copying their data. Thank you very much for posting your solution.
I had
items = jsonResponse.data;
duplicateItems = items;
That references the items variable but doesn't copy the data.
What is correct is what you say
items = jsonResponse.data;
duplicateItems = List.from(items);
So the filter works.
The original tutorial is
https://karthikponnam.medium.com/flutter-search-in-listview-1ffa40956685
Thank you very much for posting your solution.
Solution elementary dear Watson
I'm making a chat app that messages should be shown on screen with nice animation and my backend is Firestore, so I decided to use this (https://pub.dev/packages/firestore_ui) plugin for animating messages.
Now I want to implement pagination to prevent expensive works and bills.
Is there any way?
How should I implement it?
main problem is making a firestore animated list with pagination,
It's easy to make simple ListView with pagination.
as you can see in below code, this plugin uses Query of snapShots to show incoming messages (documents) with animation:
FirestoreAnimatedList(
query: query,
itemBuilder: (
BuildContext context,
DocumentSnapshot snapshot,
Animation<double> animation,
int index,
) => FadeTransition(
opacity: animation,
child: MessageListTile(
index: index,
document: snapshot,
onTap: _removeMessage,
),
),
);
if we want to use AnimatedList widget instead, we will have problem because we should track realtime messages(documents) that are adding to our collection.
I put together an example for you: https://gist.github.com/slightfoot/d936391bfb77a5301335c12e3e8861de
// MIT License
//
// Copyright (c) 2020 Simon Lightfoot
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ScrollDirection;
import 'package:provider/provider.dart';
///
/// Firestore Chat List Example - by Simon Lightfoot
///
/// Setup instructions:
///
/// 1. Create project on console.firebase.google.com.
/// 2. Add firebase_auth package to your pubspec.yaml.
/// 3. Add cloud_firestore package to your pubspec.yaml.
/// 4. Follow the steps to add firebase to your application on Android/iOS.
/// 5. Go to the authentication section of the firebase console and enable
/// anonymous auth.
///
/// Now run the example on two or more devices and start chatting.
///
///
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final user = await FirebaseAuth.instance.currentUser();
runApp(ExampleChatApp(user: user));
}
class ExampleChatApp extends StatefulWidget {
const ExampleChatApp({
Key key,
this.user,
}) : super(key: key);
final FirebaseUser user;
static Future<FirebaseUser> signIn(BuildContext context, String displayName) {
final state = context.findAncestorStateOfType<_ExampleChatAppState>();
return state.signIn(displayName);
}
static Future<void> postMessage(ChatMessage message) async {
await Firestore.instance
.collection('messages')
.document()
.setData(message.toJson());
}
static Future<void> signOut(BuildContext context) {
final state = context.findAncestorStateOfType<_ExampleChatAppState>();
return state.signOut();
}
#override
_ExampleChatAppState createState() => _ExampleChatAppState();
}
class _ExampleChatAppState extends State<ExampleChatApp> {
StreamSubscription<FirebaseUser> _userSub;
FirebaseUser _user;
Future<FirebaseUser> signIn(String displayName) async {
final result = await FirebaseAuth.instance.signInAnonymously();
await result.user.updateProfile(
UserUpdateInfo()..displayName = displayName,
);
final user = await FirebaseAuth.instance.currentUser();
setState(() => _user = user);
return user;
}
Future<void> signOut() {
return FirebaseAuth.instance.signOut();
}
#override
void initState() {
super.initState();
_user = widget.user;
_userSub = FirebaseAuth.instance.onAuthStateChanged.listen((user) {
print('changed ${user?.uid} -> ${user?.displayName}');
setState(() => _user = user);
});
}
#override
void dispose() {
_userSub.cancel();
super.dispose();
}
#override
Widget build(BuildContext context) {
return Provider<FirebaseUser>.value(
value: _user,
child: MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Firestore Chat List',
home: _user == null ? LoginScreen() : ChatScreen(),
),
);
}
}
class LoginScreen extends StatefulWidget {
static Route<dynamic> route() {
return MaterialPageRoute(
builder: (BuildContext context) {
return LoginScreen();
},
);
}
#override
_LoginScreenState createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
TextEditingController _displayName;
bool _loading = false;
#override
void initState() {
super.initState();
_displayName = TextEditingController();
}
#override
void dispose() {
_displayName.dispose();
super.dispose();
}
Future<void> _onSubmitPressed() async {
setState(() => _loading = true);
try {
final user = await ExampleChatApp.signIn(context, _displayName.text);
if (mounted) {
await ExampleChatApp.postMessage(
ChatMessage.notice(user, 'has entered the chat'));
Navigator.of(context).pushReplacement(ChatScreen.route());
}
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
#override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Text('Firestore Chat List'),
),
body: SizedBox.expand(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Login',
style: theme.textTheme.headline4,
textAlign: TextAlign.center,
),
SizedBox(height: 32.0),
if (_loading)
CircularProgressIndicator()
else ...[
TextField(
controller: _displayName,
decoration: InputDecoration(
hintText: 'Display Name',
border: OutlineInputBorder(),
isDense: true,
),
onSubmitted: (_) => _onSubmitPressed(),
textInputAction: TextInputAction.go,
),
SizedBox(height: 12.0),
RaisedButton(
onPressed: () => _onSubmitPressed(),
child: Text('ENTER CHAT'),
),
],
],
),
),
),
);
}
}
class ChatScreen extends StatelessWidget {
static Route<dynamic> route() {
return MaterialPageRoute(
builder: (BuildContext context) {
return ChatScreen();
},
);
}
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Firestore Chat List'),
actions: [
IconButton(
onPressed: () async {
final user = Provider.of<FirebaseUser>(context, listen: false);
ExampleChatApp.postMessage(
ChatMessage.notice(user, 'has left the chat.'));
Navigator.of(context).pushReplacement(LoginScreen.route());
await ExampleChatApp.signOut(context);
},
icon: Icon(Icons.exit_to_app),
),
],
),
body: Column(
children: [
Expanded(
child: FirestoreChatList(
listenBuilder: () {
return Firestore.instance
.collection('messages')
.orderBy('posted', descending: true);
},
pagedBuilder: () {
return Firestore.instance
.collection('messages')
.orderBy('posted', descending: true)
.limit(15);
},
itemBuilder: (BuildContext context, int index,
DocumentSnapshot document, Animation<double> animation) {
final message = ChatMessage.fromDoc(document);
return SizeTransition(
key: Key('message-${document.documentID}'),
axis: Axis.vertical,
axisAlignment: -1.0,
sizeFactor: animation,
child: Builder(
builder: (BuildContext context) {
switch (message.type) {
case ChatMessageType.notice:
return ChatMessageNotice(message: message);
case ChatMessageType.text:
return ChatMessageBubble(message: message);
}
throw StateError('Bad message type');
},
),
);
},
),
),
SendMessagePanel(),
],
),
);
}
}
class ChatMessageNotice extends StatelessWidget {
const ChatMessageNotice({
Key key,
#required this.message,
}) : super(key: key);
final ChatMessage message;
#override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(24.0),
alignment: Alignment.center,
child: Text(
'${message.displayName} ${message.message}',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.grey.shade700,
fontStyle: FontStyle.italic,
),
),
);
}
}
class ChatMessageBubble extends StatelessWidget {
const ChatMessageBubble({
Key key,
#required this.message,
}) : super(key: key);
final ChatMessage message;
MaterialColor _calculateUserColor(String uid) {
final hash = uid.codeUnits.fold(0, (prev, el) => prev + el);
return Colors.primaries[hash % Colors.primaries.length];
}
#override
Widget build(BuildContext context) {
final isMine = message.isMine(context);
return Container(
padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
width: double.infinity,
child: Column(
crossAxisAlignment:
isMine ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
FractionallySizedBox(
widthFactor: 0.6,
child: Container(
decoration: BoxDecoration(
color: _calculateUserColor(message.uid).shade200,
borderRadius: isMine
? const BorderRadius.only(
topLeft: Radius.circular(24.0),
topRight: Radius.circular(24.0),
bottomLeft: Radius.circular(24.0),
)
: const BorderRadius.only(
topLeft: Radius.circular(24.0),
topRight: Radius.circular(24.0),
bottomRight: Radius.circular(24.0),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (message.displayName?.isNotEmpty ?? false) ...[
const SizedBox(width: 8.0),
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _calculateUserColor(message.uid),
),
padding: EdgeInsets.all(8.0),
child: Text(
message.displayName.substring(0, 1),
style: TextStyle(
color: Colors.white,
fontSize: 24.0,
),
),
),
],
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(message.message),
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Text(
message.infoText(context),
style: TextStyle(
fontSize: 12.0,
color: Colors.grey.shade600,
),
),
),
],
),
);
}
}
class SendMessagePanel extends StatefulWidget {
#override
_SendMessagePanelState createState() => _SendMessagePanelState();
}
class _SendMessagePanelState extends State<SendMessagePanel> {
final _controller = TextEditingController();
FirebaseUser _user;
#override
void didChangeDependencies() {
super.didChangeDependencies();
_user = Provider.of<FirebaseUser>(context);
}
#override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onSubmitPressed() {
if (_controller.text.isEmpty) {
return;
}
ExampleChatApp.postMessage(ChatMessage.text(_user, _controller.text));
_controller.clear();
}
#override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: Colors.grey.shade200,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
offset: Offset(0.0, -3.0),
blurRadius: 4.0,
spreadRadius: 3.0,
)
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: 160.0),
child: TextField(
controller: _controller,
decoration: InputDecoration(
border: OutlineInputBorder(),
filled: true,
fillColor: Colors.grey.shade300,
isDense: true,
),
onSubmitted: (_) => _onSubmitPressed(),
maxLines: null,
textInputAction: TextInputAction.send,
),
),
),
IconButton(
onPressed: () => _onSubmitPressed(),
icon: Icon(Icons.send),
),
],
),
);
}
}
enum ChatMessageType {
notice,
text,
}
class ChatMessage {
const ChatMessage._({
this.type,
this.posted,
this.message = '',
this.uid,
this.displayName,
this.photoUrl,
}) : assert(type != null && posted != null);
final ChatMessageType type;
final DateTime posted;
final String message;
final String uid;
final String displayName;
final String photoUrl;
String infoText(BuildContext context) {
final timeOfDay = TimeOfDay.fromDateTime(posted);
final localizations = MaterialLocalizations.of(context);
final date = localizations.formatShortDate(posted);
final time = localizations.formatTimeOfDay(timeOfDay);
return '$date at $time from $displayName';
}
bool isMine(BuildContext context) {
final user = Provider.of<FirebaseUser>(context);
return uid == user?.uid;
}
factory ChatMessage.notice(FirebaseUser user, String message) {
return ChatMessage._(
type: ChatMessageType.notice,
posted: DateTime.now().toUtc(),
message: message,
uid: user.uid,
displayName: user.displayName,
photoUrl: user.photoUrl,
);
}
factory ChatMessage.text(FirebaseUser user, String message) {
return ChatMessage._(
type: ChatMessageType.text,
posted: DateTime.now().toUtc(),
message: message,
uid: user.uid,
displayName: user.displayName,
photoUrl: user.photoUrl,
);
}
factory ChatMessage.fromDoc(DocumentSnapshot doc) {
return ChatMessage._(
type: ChatMessageType.values[doc['type'] as int],
posted: (doc['posted'] as Timestamp).toDate(),
message: doc['message'] as String,
uid: doc['user']['uid'] as String,
displayName: doc['user']['displayName'] as String,
photoUrl: doc['user']['photoUrl'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'type': type.index,
'posted': Timestamp.fromDate(posted),
'message': message,
'user': {
'uid': uid,
'displayName': displayName,
'photoUrl': photoUrl,
},
};
}
}
// ---- CHAT LIST IMPLEMENTATION ----
typedef Query FirestoreChatListQueryBuilder();
typedef Widget FirestoreChatListItemBuilder(
BuildContext context,
int index,
DocumentSnapshot document,
Animation<double> animation,
);
typedef Widget FirestoreChatListLoaderBuilder(
BuildContext context,
int index,
Animation<double> animation,
);
class FirestoreChatList extends StatefulWidget {
const FirestoreChatList({
Key key,
this.controller,
#required this.listenBuilder,
#required this.pagedBuilder,
#required this.itemBuilder,
this.loaderBuilder = defaultLoaderBuilder,
this.scrollDirection = Axis.vertical,
this.reverse = true,
this.primary,
this.physics,
this.shrinkWrap = false,
this.initialAnimate = false,
this.padding,
this.duration = const Duration(milliseconds: 300),
}) : super(key: key);
final FirestoreChatListQueryBuilder listenBuilder;
final FirestoreChatListQueryBuilder pagedBuilder;
final FirestoreChatListItemBuilder itemBuilder;
final FirestoreChatListLoaderBuilder loaderBuilder;
final ScrollController controller;
final Axis scrollDirection;
final bool reverse;
final bool primary;
final ScrollPhysics physics;
final bool shrinkWrap;
final bool initialAnimate;
final EdgeInsetsGeometry padding;
final Duration duration;
static Widget defaultLoaderBuilder(
BuildContext context, int index, Animation<double> animation) {
return FadeTransition(
opacity: animation,
child: Container(
padding: EdgeInsets.all(32.0),
alignment: Alignment.center,
child: CircularProgressIndicator(),
),
);
}
#override
_FirestoreChatListState createState() => _FirestoreChatListState();
}
class _FirestoreChatListState extends State<FirestoreChatList> {
final _animatedListKey = GlobalKey<AnimatedListState>();
final _dataListen = List<DocumentSnapshot>();
final _dataPaged = List<DocumentSnapshot>();
Future _pageRequest;
StreamSubscription<QuerySnapshot> _listenSub;
ScrollController _controller;
ScrollController get controller =>
widget.controller ?? (_controller ??= ScrollController());
#override
void initState() {
super.initState();
controller.addListener(_onScrollChanged);
_requestNextPage();
}
#override
void dispose() {
controller.removeListener(_onScrollChanged);
_controller?.dispose();
_listenSub?.cancel();
super.dispose();
}
void _onScrollChanged() {
if (!controller.hasClients) {
return;
}
final position = controller.position;
if ((position.pixels >=
(position.maxScrollExtent - position.viewportDimension)) &&
position.userScrollDirection == ScrollDirection.reverse) {
_requestNextPage();
}
}
void _requestNextPage() {
_pageRequest ??= () async {
final loaderIndex = _addLoader();
// await Future.delayed(const Duration(seconds: 3));
var pagedQuery = widget.pagedBuilder();
if (_dataPaged.isNotEmpty) {
pagedQuery = pagedQuery.startAfterDocument(_dataPaged.last);
}
final snapshot = await pagedQuery.getDocuments();
if (!mounted) {
return;
}
final insertIndex = _dataListen.length + _dataPaged.length;
_dataPaged.addAll(snapshot.documents);
_removeLoader(loaderIndex);
for (int i = 0; i < snapshot.documents.length; i++) {
_animateAdded(insertIndex + i);
}
if (_listenSub == null) {
var listenQuery = widget.listenBuilder();
if (_dataPaged.isNotEmpty) {
listenQuery = listenQuery.endBeforeDocument(_dataPaged.first);
}
_listenSub = listenQuery.snapshots().listen(_onListenChanged);
}
_pageRequest = null;
}();
}
void _onListenChanged(QuerySnapshot snapshot) {
for (final change in snapshot.documentChanges) {
switch (change.type) {
case DocumentChangeType.added:
_dataListen.insert(change.newIndex, change.document);
_animateAdded(change.newIndex);
break;
case DocumentChangeType.modified:
if (change.oldIndex == change.newIndex) {
_dataListen.removeAt(change.oldIndex);
_dataListen.insert(change.newIndex, change.document);
setState(() {});
} else {
final oldDoc = _dataListen.removeAt(change.oldIndex);
_animateRemoved(change.oldIndex, oldDoc);
_dataListen.insert(change.newIndex, change.document);
_animateAdded(change.newIndex);
}
break;
case DocumentChangeType.removed:
final oldDoc = _dataListen.removeAt(change.oldIndex);
_animateRemoved(change.oldIndex, oldDoc);
break;
}
}
}
int _addLoader() {
final index = _dataListen.length + _dataPaged.length;
_animatedListKey?.currentState
?.insertItem(index, duration: widget.duration);
return index;
}
void _removeLoader(int index) {
_animatedListKey?.currentState?.removeItem(
index,
(BuildContext context, Animation<double> animation) {
return widget.loaderBuilder(context, index, animation);
},
duration: widget.duration,
);
}
void _animateAdded(int index) {
final animatedListState = _animatedListKey.currentState;
if (animatedListState != null) {
animatedListState.insertItem(index, duration: widget.duration);
} else {
setState(() {});
}
}
void _animateRemoved(int index, DocumentSnapshot old) {
final animatedListState = _animatedListKey.currentState;
if (animatedListState != null) {
animatedListState.removeItem(
index,
(BuildContext context, Animation<double> animation) {
return widget.itemBuilder(context, index, old, animation);
},
duration: widget.duration,
);
} else {
setState(() {});
}
}
#override
Widget build(BuildContext context) {
if (_dataListen.length == 0 &&
_dataPaged.length == 0 &&
!widget.initialAnimate) {
return SizedBox();
}
return AnimatedList(
key: _animatedListKey,
controller: controller,
scrollDirection: widget.scrollDirection,
reverse: widget.reverse,
primary: widget.primary,
physics: widget.physics,
shrinkWrap: widget.shrinkWrap,
padding: widget.padding ?? MediaQuery.of(context).padding,
initialItemCount: _dataListen.length + _dataPaged.length,
itemBuilder: (
BuildContext context,
int index,
Animation<double> animation,
) {
if (index < _dataListen.length) {
return widget.itemBuilder(
context,
index,
_dataListen[index],
animation,
);
} else {
final pagedIndex = index - _dataListen.length;
if (pagedIndex < _dataPaged.length) {
return widget.itemBuilder(
context, index, _dataPaged[pagedIndex], animation);
} else {
return widget.loaderBuilder(
context,
pagedIndex,
AlwaysStoppedAnimation<double>(1.0),
);
}
}
},
);
}
}
you can check this github project by simplesoft-duongdt3;
TLDR this is how to go about it
StreamController<List<DocumentSnapshot>> _streamController =
StreamController<List<DocumentSnapshot>>();
List<DocumentSnapshot> _products = [];
bool _isRequesting = false;
bool _isFinish = false;
void onChangeData(List<DocumentChange> documentChanges) {
var isChange = false;
documentChanges.forEach((productChange) {
print(
"productChange ${productChange.type.toString()} ${productChange.newIndex} ${productChange.oldIndex} ${productChange.document}");
if (productChange.type == DocumentChangeType.removed) {
_products.removeWhere((product) {
return productChange.document.documentID == product.documentID;
});
isChange = true;
} else {
if (productChange.type == DocumentChangeType.modified) {
int indexWhere = _products.indexWhere((product) {
return productChange.document.documentID == product.documentID;
});
if (indexWhere >= 0) {
_products[indexWhere] = productChange.document;
}
isChange = true;
}
}
});
if(isChange) {
_streamController.add(_products);
}
}
#override
void initState() {
Firestore.instance
.collection('products')
.snapshots()
.listen((data) => onChangeData(data.documentChanges));
requestNextPage();
super.initState();
}
#override
void dispose() {
_streamController.close();
super.dispose();
}
#override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification scrollInfo) {
if (scrollInfo.metrics.maxScrollExtent == scrollInfo.metrics.pixels) {
requestNextPage();
}
return true;
},
child: StreamBuilder<List<DocumentSnapshot>>(
stream: _streamController.stream,
builder: (BuildContext context,
AsyncSnapshot<List<DocumentSnapshot>> snapshot) {
if (snapshot.hasError) return new Text('Error: ${snapshot.error}');
switch (snapshot.connectionState) {
case ConnectionState.waiting:
return new Text('Loading...');
default:
log("Items: " + snapshot.data.length.toString());
return //your grid here
ListView.separated(
separatorBuilder: (context, index) => Divider(
color: Colors.black,
),
itemCount: snapshot.data.length,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: new ListTile(
title: new Text(snapshot.data[index]['name']),
subtitle: new Text(snapshot.data[index]['description']),
),
),
);
}
},
));
}
void requestNextPage() async {
if (!_isRequesting && !_isFinish) {
QuerySnapshot querySnapshot;
_isRequesting = true;
if (_products.isEmpty) {
querySnapshot = await Firestore.instance
.collection('products')
.orderBy('index')
.limit(5)
.getDocuments();
} else {
querySnapshot = await Firestore.instance
.collection('products')
.orderBy('index')
.startAfterDocument(_products[_products.length - 1])
.limit(5)
.getDocuments();
}
if (querySnapshot != null) {
int oldSize = _products.length;
_products.addAll(querySnapshot.documents);
int newSize = _products.length;
if (oldSize != newSize) {
_streamController.add(_products);
} else {
_isFinish = true;
}
}
_isRequesting = false;
}
}