diff --git a/.gitignore b/.gitignore index 24476c5..3f3ca0f 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Hide key from version control +lib/api_key.dart \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ed53d24..fd1afc0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,7 +4,7 @@ (); @@ -51,9 +55,9 @@ class MainPage extends StatelessWidget { } else if (snapshot.hasError) { return const Center(child: Text('Something went wrong!')); } else if (snapshot.hasData) { - return TabsScreen(); + return const TabsScreen(); } else { - return AuthPage(); + return const AuthPage(); } }, ), diff --git a/lib/models/listing.dart b/lib/models/listing.dart index 11ec031..df05b11 100644 --- a/lib/models/listing.dart +++ b/lib/models/listing.dart @@ -70,7 +70,6 @@ enum DietaryNeeds { containsNuts, containShellfish, containsSoy, - others, none, } @@ -114,7 +113,7 @@ Map> categorySubcategoryMap = { MainCategory.frozen: [ SubCategory.desserts, SubCategory.meat, - SubCategory.seafood, + SubCategory.seafoods, ], MainCategory.fruitsAndVegetables: [ SubCategory.fruits, @@ -137,11 +136,13 @@ class UserLocation { required this.latitude, required this.longitude, required this.address, + required this.addressImageUrl, }); final double latitude; final double longitude; final String address; + final String addressImageUrl; } class Listing { @@ -160,14 +161,17 @@ class Listing { required this.isAvailable, required this.userId, required this.userName, + required this.userPhoto, + required this.addressImageUrl, }); // : isAvailable = DateTime.now().isAfter(expiryDate), // userId = FirebaseAuth.instance.currentUser!.email.toString(); final String id; final String userName; - final String userId; //not necessary? + final String userId; + final String userPhoto; final String itemName; - final String image; //change type // + final String image; final String mainCategory; // final String subCategory; // final String dietaryNeeds; // @@ -176,23 +180,27 @@ class Listing { final bool isAvailable; final double lat; final double lng; - final String address; // + final String address; + final String addressImageUrl; // static Listing fromJson(Map json) => Listing( - id: json['id'], - itemName: json['itemName'], - userName: json['userName'], - userId: json['userId'], - image: json['image'], - mainCategory: json['mainCategory'], - subCategory: json['subCategory'], - dietaryNeeds: json['dietaryNeeds'], - expiryDate: (json['expiryDate'] as Timestamp).toDate(), - isAvailable: json['isAvailable'], - lat: json['lat'], - lng: json['lng'], - address: json['address'], - additionalNotes: json['additionalNotes']); + id: json['id'], + itemName: json['itemName'], + userName: json['userName'], + userId: json['userId'], + userPhoto: json['userPhoto'], + image: json['image'], + mainCategory: json['mainCategory'], + subCategory: json['subCategory'], + dietaryNeeds: json['dietaryNeeds'], + expiryDate: (json['expiryDate'] as Timestamp).toDate(), + isAvailable: json['isAvailable'], + lat: json['lat'], + lng: json['lng'], + address: json['address'], + addressImageUrl: json['addressImageUrl'], + additionalNotes: json['additionalNotes'], + ); Map toJson() => { 'id': id, @@ -208,6 +216,8 @@ class Listing { 'isAvailable': isAvailable, 'lat': lat, 'lng': lng, - 'address': address + 'address': address, + 'addressImageUrl': addressImageUrl, + 'userPhoto' : userPhoto, }; } diff --git a/lib/providers/all_listings_provider.dart b/lib/providers/all_listings_provider.dart new file mode 100644 index 0000000..e777c0b --- /dev/null +++ b/lib/providers/all_listings_provider.dart @@ -0,0 +1,7 @@ +// import 'package:flutter_riverpod/flutter_riverpod.dart'; +// import 'package:cloud_firestore/cloud_firestore.dart'; + +// class ListingsNotifier extends StateNotifier { +// ListingsNotifier(): super([]); + +// } \ No newline at end of file diff --git a/lib/providers/current_user_provider.dart b/lib/providers/current_user_provider.dart new file mode 100644 index 0000000..9ee30b8 --- /dev/null +++ b/lib/providers/current_user_provider.dart @@ -0,0 +1,6 @@ +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final currentUser = Provider((ref) { + return FirebaseAuth.instance.currentUser!; +}); diff --git a/lib/screens/chat_list_screen.dart b/lib/screens/chat/chat_list_screen.dart similarity index 63% rename from lib/screens/chat_list_screen.dart rename to lib/screens/chat/chat_list_screen.dart index e5821cf..1718a5a 100644 --- a/lib/screens/chat_list_screen.dart +++ b/lib/screens/chat/chat_list_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:foodbridge_project/widgets/chat_widgets/chat_list.dart'; class ChatListScreen extends StatefulWidget { const ChatListScreen({super.key}); @@ -8,13 +9,17 @@ class ChatListScreen extends StatefulWidget { } class _ChatListScreenState extends State { + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Your chats'), - backgroundColor: Colors.orange, + title: Text( + 'Chats', + style: Theme.of(context).textTheme.titleLarge, + ), ), + body: const AllChatList(), ); } -} \ No newline at end of file +} diff --git a/lib/screens/chat/chatroom_screen.dart b/lib/screens/chat/chatroom_screen.dart new file mode 100644 index 0000000..c8dc0f9 --- /dev/null +++ b/lib/screens/chat/chatroom_screen.dart @@ -0,0 +1,130 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:flutter/material.dart'; +import '../../widgets/chat_widgets/chat_messages.dart'; +import '../../widgets/chat_widgets/new_message.dart'; + +class ChatScreen extends StatelessWidget { + const ChatScreen({Key? key, required this.chatId, required this.listingId}) + : super(key: key); + + final String chatId; + final String listingId; + + @override + Widget build(BuildContext context) { + //Retrieves information about listing + return FutureBuilder>>( + future: FirebaseFirestore.instance + .collection('Listings') + .doc(listingId) + .get(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + if (!snapshot.hasData || snapshot.data!.data() == null) { + return const Center(child: Text('Data not available')); + } + + final listing = snapshot.data!.data()!; + + return Scaffold( + appBar: AppBar( + title: Text( + 'Chat', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + body: Column( + children: [ + //Brief summary of listing details + //displayed at the top of chatroom + Card( + margin: const EdgeInsets.all(16), + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + Container( + height: 120, + width: 120, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + image: DecorationImage( + image: NetworkImage(listing['image']), + fit: BoxFit.cover, + ), + ), + ), + const SizedBox( + width: 8, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + listing['itemName'], + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Main category: ${listing['mainCategory'].split('.').last}', + style: const TextStyle(fontSize: 12), + ), + const SizedBox(height: 8), + Text( + 'Subcategory: ${listing['subCategory'].split('.').last}', + style: const TextStyle(fontSize: 12), + ), + const SizedBox(height: 8), + Text( + 'Location: ${listing['address'].split('.').last}', + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.visible, + ), + const SizedBox(height: 8), + Text( + 'Available: ${listing['isAvailable'].toString()}', + style: const TextStyle(fontSize: 12), + ), + const SizedBox(height: 8), + Text( + 'Donor: ${listing['userName'].toString()}', + style: const TextStyle(fontSize: 12), + ), + ], + ), + ) + ], + ), + ), + ), + //All text messages/ text bubbles + Expanded( + child: ChatMessages( + chatId: chatId, + ), + ), + //text field to key in new + //message or text + NewMessage( + chatId: chatId, + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/screens/edit_listing_screen.dart b/lib/screens/edit_listing_screen.dart index 7d15665..61a5e1d 100644 --- a/lib/screens/edit_listing_screen.dart +++ b/lib/screens/edit_listing_screen.dart @@ -5,9 +5,9 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:foodbridge_project/models/listing.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; - -import '../widgets/edit_image_input.dart'; -import '../widgets/edit_location_input.dart'; +import '../widgets/image_input/edit_image_input.dart'; +import '../widgets/location_input/edit_location_input.dart'; +import '../widgets/utils.dart'; class EditListingScreen extends StatefulWidget { const EditListingScreen( @@ -32,6 +32,15 @@ class _EditListingScreenState extends State { MainCategory? selectedMainCategory; SubCategory? selectedSubCategory; + @override + void dispose() { + dateInputController.dispose(); + super.dispose(); + } + + //initialize fields to original values. In the event that + //user does not make any edits, the original values + //will be retained in the database. @override void initState() { selectedMainCategory = MainCategory.values.firstWhere( @@ -48,6 +57,7 @@ class _EditListingScreenState extends State { _editedAddress = _address; _editedLng = _lng; _editedLat = _lat; + _editedAddressImageUrl = _addressImageUrl; _editedSelectedImage = _image; _editedChosenDate = _expiryDate; _editedChosenMainCategory = _mainCategory; @@ -101,6 +111,10 @@ class _EditListingScreenState extends State { return widget.listing.lng; } + String get _addressImageUrl { + return widget.listing.addressImageUrl; + } + String get _mainCategory { return widget.listing.mainCategory; } @@ -120,15 +134,17 @@ class _EditListingScreenState extends State { var _editedChosenDate; var _editedAdditionalInfo; var _editedSelectedImage; - var _editedLat; - var _editedLng; - var _editedAddress; + double? _editedLat; + double? _editedLng; + String? _editedAddress; + String? _editedAddressImageUrl; var _unchangedId; var _unchangeduserId; var _unchangedIsAvailable; var _urlLink; bool _isSaving = false; + //upload new image to storage Future uploadImage(File file) async { String fileName = DateTime.now().millisecondsSinceEpoch.toString(); firebase_storage.Reference ref = @@ -138,6 +154,7 @@ class _EditListingScreenState extends State { return imageUrl; } + //If user uploads new photo, delete old photo in storage Future deleteFileByUrl(String fileUrl) async { try { // Extract the file name from the URL @@ -162,6 +179,7 @@ class _EditListingScreenState extends State { await ref.delete(); } + //save updates made by user, if any void _saveItem() async { if (_editedSelectedImage == null) { showDialog( @@ -171,10 +189,10 @@ class _EditListingScreenState extends State { 'Picture missing', textAlign: TextAlign.center, ), - content: Container( + content: const SizedBox( height: 16, width: 32, - child: const Center( + child: Center( child: Text('Add a picture'), ), ), @@ -198,10 +216,10 @@ class _EditListingScreenState extends State { 'Address missing', textAlign: TextAlign.center, ), - content: Container( + content: const SizedBox( height: 16, width: 32, - child: const Center( + child: Center( child: Text('Click on get address'), ), ), @@ -222,6 +240,7 @@ class _EditListingScreenState extends State { _isSaving = true; }); _formKey.currentState!.save(); + try { if (_editedSelectedImage == _image) { _urlLink = _editedSelectedImage; } else { @@ -237,6 +256,7 @@ class _EditListingScreenState extends State { 'itemName': _editedItemName, 'lat': _editedLat, 'lng': _editedLng, + 'addressImageUrl' :_editedAddressImageUrl, 'mainCategory': _editedChosenMainCategory, 'subCategory': _editedChosenSubCategory, }); @@ -247,11 +267,15 @@ class _EditListingScreenState extends State { content: Text('Edits are saved'), ), ); + } catch (e) { + Utils.showSnackBar('error: $e'); + } Navigator.pop(context); Navigator.pop(context); } } + //date picker, formatted void _presentDatePicker() async { final now = DateTime.now(); final firstDate = DateTime( @@ -280,15 +304,9 @@ class _EditListingScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - centerTitle: true, - backgroundColor: Colors.orange, - title: const Text( - 'EDIT LISTING', - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.white, - fontSize: 24, - ), + title: Text( + 'Edit Listing', + style: Theme.of(context).textTheme.titleLarge, ), ), body: Padding( @@ -440,10 +458,11 @@ class _EditListingScreenState extends State { ), EditLocationInput( listing: widget.listing, - chosenLocation: (UserLocation location) { - _editedLat = location.latitude; - _editedLng = location.longitude; - _editedAddress = location.address; + chosenLocation: (UserLocation? location) { + _editedLat = location?.latitude; + _editedLng = location?.longitude; + _editedAddress = location?.address; + _editedAddressImageUrl = location?.addressImageUrl; }, ), const SizedBox( diff --git a/lib/screens/likes_screen.dart b/lib/screens/favorites_screen.dart similarity index 82% rename from lib/screens/likes_screen.dart rename to lib/screens/favorites_screen.dart index e91a726..0036208 100644 --- a/lib/screens/likes_screen.dart +++ b/lib/screens/favorites_screen.dart @@ -12,8 +12,10 @@ class _LikesScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Your likes'), - backgroundColor: Colors.orange, + title: Text( + 'Favorites', + style: Theme.of(context).textTheme.titleLarge, + ), ), // body: ListingsScreen( // toList: [], //to be used as a consumer to listen for changes in likes diff --git a/lib/screens/filter.dart b/lib/screens/filter.dart deleted file mode 100644 index 3400c89..0000000 --- a/lib/screens/filter.dart +++ /dev/null @@ -1,176 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:foodbridge_project/models/listing.dart'; -import 'package:foodbridge_project/screens/tabs_screen.dart'; - -List selectedOptions = []; // Stores selection - -class FilterWidget extends StatefulWidget { - const FilterWidget({super.key}); - @override - State createState() { - return _FilterWidgetState(); - } -} - -class _FilterWidgetState extends State { - List categories = [ - Category( - 'Food Types', - [ - Subcategory('Baby Food'), - Subcategory('Baked Goods'), - Subcategory( - 'Dairy, Chilled and Eggs', - [ - 'Eggs', - 'Milk', - 'Cheese', - 'Butter, Margarine and Spreads', - 'Cream', - 'Yoghurt', - ], - ), - Subcategory( - 'Beverages', - [ - 'Coffee', - 'Tea', - 'Juices', - 'Soft Drinks', - ], - ), - Subcategory('Pantry Essentials', [ - 'Canned', - 'Rice', - 'Pasta', - 'noodles', - 'Cereal', - 'Condiments', - 'Baking Needs', - 'Oil', - ]), - //Subcategory('Subcategory 1.2'), - //Subcategory('Subcategory 1.2'), - //Subcategory('Subcategory 1.2'), - //Subcategory('Subcategory 1.2'), - ], - ), - Category( - 'Dietary Needs', - [ - Subcategory('Subcategory 2.1', ['Option 2.1', 'Option 2.2']), - Subcategory('Subcategory 2.2'), - ], - ), - Category( - 'Category 3', - [ - Subcategory('Subcategory 3.1', ['Option 3.1', 'Option 3.2']), - Subcategory('Subcategory 3.2', ['Option 3.3']), - ], - ), - ]; - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Expanded( - child: ListView.builder( - itemCount: categories.length, - itemBuilder: (context, categoryIndex) { - return ExpansionTile( - title: Text(categories[categoryIndex].name), - children: - categories[categoryIndex].subcategories.map((subcategory) { - if (subcategory.options != null && - subcategory.options!.isNotEmpty) { - return _buildExpandableSubcategoryTile(subcategory); - } else { - return _buildCheckboxSubcategoryTile(subcategory); - } - }).toList(), - ); - }, - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - ElevatedButton( - onPressed: () { - selectedOptions.clear(); - setState(() {}); - }, - child: Text('Clear'), - ), - const SizedBox( - width: 30, - ), - ElevatedButton( - onPressed: () { - Navigator.pop(context); - setState(() { - const TabsScreen(); - }); - }, - child: Text('Save'), - ), - ], - ), - ], - ); - } - - Widget _buildExpandableSubcategoryTile(Subcategory subcategory) { - return ExpansionTile( - title: Text(subcategory.name), - children: subcategory.options!.map((option) { - return CheckboxListTile( - title: Text(option), - value: selectedOptions.contains(option), - onChanged: (value) { - setState(() { - if (value != null && value) { - selectedOptions.add(option); - } else { - selectedOptions.remove(option); - } - }); - }, - ); - }).toList(), - ); - } - - Widget _buildCheckboxSubcategoryTile(Subcategory subcategory) { - return CheckboxListTile( - title: Text(subcategory.name), - value: selectedOptions.contains(subcategory.name), - onChanged: (value) { - setState(() { - if (value != null && value) { - selectedOptions.add(subcategory.name); - } else { - selectedOptions.remove(subcategory.name); - } - }); - }, - ); - } -} - -class Category { - final String name; - final List subcategories; - - Category(this.name, this.subcategories); -} - -class Subcategory { - final String name; - final List? options; - - Subcategory(this.name, [this.options]); -} diff --git a/lib/screens/listing_screen.dart b/lib/screens/listing_screen.dart index fa6e748..d141be2 100644 --- a/lib/screens/listing_screen.dart +++ b/lib/screens/listing_screen.dart @@ -1,21 +1,31 @@ import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; import 'package:foodbridge_project/models/listing.dart'; +import 'package:foodbridge_project/screens/chat/chatroom_screen.dart'; import 'package:foodbridge_project/screens/edit_listing_screen.dart'; +import 'package:foodbridge_project/screens/profile_screens/others_profile_screen.dart'; +import 'package:foodbridge_project/widgets/loading.dart'; import 'package:intl/intl.dart'; import 'package:firebase_storage/firebase_storage.dart'; final formatter = DateFormat.yMd(); -class ListingScreen extends StatelessWidget { +class ListingScreen extends StatefulWidget { const ListingScreen( {super.key, required this.listing, required this.isYourListing}); final Listing listing; final bool isYourListing; + @override + State createState() => _ListingScreenState(); +} + +class _ListingScreenState extends State { + bool isLoading = false; String get formattedDate { - return formatter.format(listing.expiryDate); + return formatter.format(widget.listing.expiryDate); } FirebaseStorage get storage { @@ -23,9 +33,12 @@ class ListingScreen extends StatelessWidget { } DocumentReference get docListing { - return FirebaseFirestore.instance.collection('Listings').doc(listing.id); + return FirebaseFirestore.instance + .collection('Listings') + .doc(widget.listing.id); } + //Deletes image in firebase Future deleteFileByUrl(String fileUrl) async { try { // Extract the file name from the URL @@ -50,12 +63,35 @@ class ListingScreen extends StatelessWidget { await ref.delete(); } - void _deleteLisiting() async { - String fileUrl = listing.image; + //Deletes listing in firestore + void _deleteListing() async { + String fileUrl = widget.listing.image; await deleteFileByUrl(fileUrl); + String listingId = widget.listing.id; + deleteChatDocuments(listingId); docListing.delete(); } + void deleteChatDocuments(String listingId) async { + // Get a reference to the 'chat' collection + CollectionReference chatCollection = + FirebaseFirestore.instance.collection('chat'); + + // Query the collection to find the documents with the specified listingId + QuerySnapshot snapshot = + await chatCollection.where('listing', isEqualTo: listingId).get(); + + // Loop through each document and delete it + for (DocumentSnapshot doc in snapshot.docs) { + // Get the document reference + DocumentReference documentRef = doc.reference; + + // Delete the document + await documentRef.delete(); + } + } + + //updates listing isAvailable field to true in firestore void _markDonatedLisiting() { docListing.update({ 'isAvailable': false, @@ -64,286 +100,702 @@ class ListingScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Colors.orange, - title: Text( - 'LISTING: ${listing.itemName.toUpperCase()}', - style: const TextStyle( - fontWeight: FontWeight.bold, - color: Colors.white, - fontSize: 24, - ), - ), - centerTitle: true, - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Stack( - children: [ - SizedBox( - width: double.infinity, - height: 350, - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Image.network( - listing.image, - fit: BoxFit.cover, - ), - ), - ), - Positioned( - right: 0, - bottom: 0, - child: Container( - height: 80, - width: 128, - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), - borderRadius: BorderRadius.circular(16), - ), - ), - ), - Positioned( - right: 16, - bottom: 16, - child: Row( + final user = FirebaseAuth.instance.currentUser!; + + //Navigates to chatroom. Create new chatroom if it doesn't + //yet exist between 2 specified people for a specific + //listing. Else, go to existing chatroom. + //If user leaves the new chatroom created + //and there are no messages, + //delete the chatroom doc from firestore. + //Else, save it in firestore. + void goToChat() async { + QuerySnapshot> querySnapshot = + await FirebaseFirestore.instance + .collection('chat') + .where('participants', + isEqualTo: [widget.listing.userId, user.email]) + .where('listing', isEqualTo: widget.listing.id) + .get(); + + if (querySnapshot.docs.isNotEmpty) { + print('chat found'); + setState(() { + isLoading = true; + }); + DocumentSnapshot> chatDocSnapshot = + querySnapshot.docs[0]; + Map chatData = chatDocSnapshot.data()!; + String chatId = chatData['chatId'].trim(); + String listingId = chatData['listing'].trim(); + setState(() { + isLoading = false; + }); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatScreen( + chatId: chatId, + listingId: listingId, + )), + ); + } else { + print('No chat found'); + setState(() { + isLoading = true; + }); + Map chatData = { + 'participants': [widget.listing.userId, user.email], + 'listing': widget.listing.id, + 'chatId': '', + 'hasMessages': false, + }; + CollectionReference> chatCollection = + FirebaseFirestore.instance.collection('chat'); + + DocumentReference> newChatRef = + await chatCollection.add(chatData); + +// Retrieve the generated document ID + String newChatId = newChatRef.id; + await newChatRef.update({'chatId': newChatId}); + setState(() { + isLoading = false; + }); + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatScreen( + chatId: newChatId, + listingId: widget.listing.id, + )), + ); + bool haveMessages = await _hasMessagesSubcollection(newChatId); + if (!haveMessages) { + _deleteChat(newChatId); + } + } + } + + //Placeholder to display on screen + Widget content; + + //If content is loading, display a loading screen, + //else, display the details of the listing + isLoading + ? content = const LoadingCircleScreen() + : content = Padding( + padding: const EdgeInsets.all(16.0), + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( children: [ - IconButton( - onPressed: () {}, - icon: const Icon(Icons.chat_bubble_outline), - color: Colors.grey.shade400, - iconSize: 32, - ), - IconButton( - onPressed: () {}, - icon: const Icon(Icons.favorite_border), - color: Colors.grey.shade400, - iconSize: 32, - ), - ], - ), - ), - listing.isAvailable - ? Container() - : Positioned( - top: 0, - right: 0, - left: 0, - child: Container( - alignment: Alignment.center, - height: 64, - decoration: BoxDecoration( - color: Colors.green.shade400, - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - 'MARKED AS DONATED', - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.white, - fontSize: 32), + //Listing image + SizedBox( + width: double.infinity, + height: 350, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image.network( + widget.listing.image, + fit: BoxFit.cover, ), ), ), - listing.expiryDate.isAfter(DateTime.now()) - ? Container() - : Positioned( - top: 0, + Positioned( right: 0, - left: 0, + bottom: 0, child: Container( - alignment: Alignment.center, - height: 64, + height: 80, + width: 128, decoration: BoxDecoration( - color: Colors.red.shade400, - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - 'EXPIRED', - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.white, - fontSize: 32), + color: Colors.black.withOpacity(0.5), + borderRadius: BorderRadius.circular(16), ), ), ), - ], - ), - const SizedBox( - height: 8, - ), - isYourListing - ? Wrap( - direction: Axis.horizontal, - children: [ - ElevatedButton.icon( - onPressed: (listing.isAvailable && - listing.expiryDate.isAfter(DateTime.now())) - ? () { + //Chat button and favorites button + Positioned( + right: 16, + bottom: 16, + child: Row( + children: [ + IconButton( + onPressed: + widget.listing.userId.trim() == user.email + ? null + : goToChat, + icon: const Icon(Icons.chat_bubble_outline), + color: Colors.grey.shade400, + iconSize: 32, + ), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.favorite_border), + color: Colors.grey.shade400, + iconSize: 32, + ), + ], + ), + ), + //Visibly indicate if listing has expired + //using a red label + widget.listing.expiryDate.isAfter(DateTime.now()) + ? Container() + : Positioned( + top: 0, + right: 0, + left: 0, + child: Container( + alignment: Alignment.center, + height: 64, + decoration: BoxDecoration( + color: Colors.red.shade400, + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'EXPIRED', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.white, + fontSize: 32), + ), + ), + ), + //Visibly indicate if listing is available + //for donation using a green label + widget.listing.isAvailable + ? Container() + : Positioned( + top: 0, + right: 0, + left: 0, + child: Container( + alignment: Alignment.center, + height: 64, + decoration: BoxDecoration( + color: Colors.green.shade400, + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'MARKED AS DONATED', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.white, + fontSize: 32), + ), + ), + ), + ], + ), + const SizedBox( + height: 8, + ), + //If you are the owner of the listing, + //you will get options to edit/update/delete + //the listing + widget.isYourListing + ? Wrap( + direction: Axis.horizontal, + children: [ + ElevatedButton.icon( + onPressed: (widget.listing.isAvailable && + widget.listing.expiryDate + .isAfter(DateTime.now())) + ? () { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + content: SizedBox( + height: 70, + width: double.minPositive, + child: Center( + child: Column( + children: [ + Text( + 'Mark as donated?', + style: Theme.of(context) + .textTheme + .titleLarge! + .copyWith( + color: Colors.black, + ), + ), + const SizedBox( + height: 10, + ), + const Text( + 'You will not be able to undo this action once you click yes', + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + actions: [ + ButtonBar( + alignment: MainAxisAlignment + .spaceBetween, + children: [ + TextButton( + onPressed: () { + Navigator.pop(ctx); + }, + child: const Text('No')), + TextButton( + onPressed: () { + Navigator.pop(ctx); + _markDonatedLisiting(); + Navigator.pop(context); + }, + child: const Text('Yes'), + ), + ], + ) + ], + ), + ); + } + : null, + icon: const Icon(Icons.done), + label: widget.listing.isAvailable + ? const Text('Mark as donated') + : const Text('Item has been donated'), + ), + const SizedBox( + width: 8, + ), + ElevatedButton.icon( + onPressed: (widget.listing.isAvailable && + widget.listing.expiryDate + .isAfter(DateTime.now())) + ? () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + EditListingScreen( + listing: widget.listing, + storage: storage, + docListing: docListing, + )), + ); + } + : null, + icon: const Icon(Icons.edit), + label: const Text('Edit listing'), + ), + const SizedBox( + width: 8, + ), + ElevatedButton.icon( + onPressed: () { showDialog( context: context, builder: (ctx) => AlertDialog( - content: Container( - height: 16, - width: 16, - child: const Center( - child: Text('Mark as donated?'), + content: SizedBox( + height: 70, + width: double.minPositive, + child: Center( + child: Column( + children: [ + Text('Confirm deletion?', + style: Theme.of(context) + .textTheme + .titleLarge! + .copyWith( + color: Colors.black)), + const SizedBox( + height: 10, + ), + const Text( + 'All chats associated with this listing will be deleted too', + textAlign: TextAlign.center, + ), + ], + ), ), ), actions: [ - TextButton( - onPressed: () { - Navigator.pop(ctx); - }, - child: const Text('No')), - TextButton( - onPressed: () { - Navigator.pop(ctx); - _markDonatedLisiting(); - Navigator.pop(context); - }, - child: const Text('Yes'), + ButtonBar( + alignment: MainAxisAlignment + .spaceBetween, // Align buttons to the ends + children: [ + TextButton( + onPressed: () { + Navigator.pop(ctx); + }, + child: const Text('No'), + ), + TextButton( + onPressed: () { + Navigator.pop(ctx); + _deleteListing(); + Navigator.pop(context); + }, + child: const Text('Yes'), + ), + ], ), ], ), ); - } - : null, - icon: const Icon(Icons.done), - label: listing.isAvailable - ? Text('Mark as donated') - : Text('Item has been donated'), + }, + icon: const Icon(Icons.delete), + label: const Text('Delete listing'), + ), + ], + ) + : Container(), + const SizedBox( + height: 16, + ), + //Display all details of listing + Text( + 'Item:', + style: Theme.of(context) + .textTheme + .titleMedium! + .copyWith(fontSize: 20), + ), + const SizedBox( + height: 4, + ), + Text('${widget.listing.itemName}'), + const SizedBox( + height: 8, + ), + + Wrap( + direction: Axis.horizontal, + children: [ + Container( + margin: const EdgeInsets.symmetric(vertical: 4.0), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).colorScheme.primaryContainer, + ), + child: Column( + children: [ + Text( + 'Main Category', + style: Theme.of(context) + .textTheme + .titleMedium! + .copyWith(color: Colors.white, shadows: [ + Shadow( + color: Colors.black.withOpacity( + 0.5), // Adjust the shadow color and opacity + offset: const Offset( + 1, 1), // Adjust the shadow offset + blurRadius: + 3, // Adjust the shadow blur radius + ), + ]), + ), + const SizedBox( + height: 4, + ), + Text( + '${widget.listing.mainCategory.split('.').last}', + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(color: Colors.white, shadows: [ + Shadow( + color: Colors.black.withOpacity( + 0.5), // Adjust the shadow color and opacity + offset: const Offset( + 1, 1), // Adjust the shadow offset + blurRadius: + 3, // Adjust the shadow blur radius + ), + ]), + ), + ], + ), ), const SizedBox( width: 8, ), - ElevatedButton.icon( - onPressed: (listing.isAvailable && - listing.expiryDate.isAfter(DateTime.now())) - ? () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => EditListingScreen( - listing: listing, - storage: storage, - docListing: docListing, - )), - ); - } - : null, - icon: const Icon(Icons.edit), - label: const Text('Edit listing'), + Container( + margin: const EdgeInsets.symmetric(vertical: 4.0), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: + Theme.of(context).colorScheme.tertiaryContainer, + ), + child: Column( + children: [ + Text( + 'Sub Category', + style: Theme.of(context) + .textTheme + .titleMedium! + .copyWith(color: Colors.white, shadows: [ + Shadow( + color: Colors.black.withOpacity( + 0.5), // Adjust the shadow color and opacity + offset: const Offset( + 1, 1), // Adjust the shadow offset + blurRadius: + 3, // Adjust the shadow blur radius + ), + ]), + ), + const SizedBox( + height: 4, + ), + Text( + widget.listing.subCategory.split('.').last, + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(color: Colors.white, shadows: [ + Shadow( + color: Colors.black.withOpacity( + 0.5), // Adjust the shadow color and opacity + offset: const Offset( + 1, 1), // Adjust the shadow offset + blurRadius: + 3, // Adjust the shadow blur radius + ), + ]), + ), + ], + ), ), const SizedBox( width: 8, ), - ElevatedButton.icon( - onPressed: () { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - content: const SizedBox( - height: 16, - width: 16, - child: Center( - child: Text('Confirm deletion?'), + Container( + margin: const EdgeInsets.symmetric(vertical: 4.0), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: + Theme.of(context).colorScheme.secondaryContainer, + ), + child: Column( + children: [ + Text( + 'Dietary Specifications', + style: Theme.of(context) + .textTheme + .titleMedium! + .copyWith(color: Colors.white, shadows: [ + Shadow( + color: Colors.black.withOpacity( + 0.5), // Adjust the shadow color and opacity + offset: const Offset( + 1, 1), // Adjust the shadow offset + blurRadius: + 3, // Adjust the shadow blur radius ), - ), - actions: [ - TextButton( - onPressed: () { - Navigator.pop(ctx); - }, - child: const Text('No')), - TextButton( - onPressed: () { - Navigator.pop(ctx); - _deleteLisiting(); - Navigator.pop(context); - }, - child: const Text('Yes'), + ]), + ), + const SizedBox( + height: 4, + ), + Text( + widget.listing.dietaryNeeds.split('.').last, + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(color: Colors.white, shadows: [ + Shadow( + color: Colors.black.withOpacity( + 0.5), // Adjust the shadow color and opacity + offset: const Offset( + 1, 1), // Adjust the shadow offset + blurRadius: + 3, // Adjust the shadow blur radius ), - ], + ]), ), - ); - }, - icon: const Icon(Icons.delete), - label: const Text('Delete listing'), + ], + ), ), ], - ) - : Container(), - const SizedBox( - height: 16, - ), - Expanded( - child: SingleChildScrollView( - child: DefaultTextStyle( - style: const TextStyle(fontSize: 16, color: Colors.black), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, + ), + const SizedBox( + height: 8, + ), + Row( children: [ - Text('Item: ${listing.itemName}'), + const Icon(Icons.person), const SizedBox( - height: 8, + width: 8, ), Text( - 'Main Category: ${listing.mainCategory.split('.').last}'), + 'Donor:', + style: Theme.of(context).textTheme.titleMedium!, + ), const SizedBox( - height: 8, + width: 8, ), - Text( - 'Sub Category: ${listing.subCategory.split('.').last}'), + user.email == widget.listing.userId + ? Text(widget.listing.userName) + : GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => OthersProfileScreen( + userId: widget.listing.userId, + userName: widget.listing.userName, + userPhoto: widget.listing.userPhoto, + )), + ); + }, + child: Text( + widget.listing.userName, + style: TextStyle( + decoration: TextDecoration.underline, + color: + Theme.of(context).colorScheme.secondary, + fontSize: 16, + ), + ), + ) + ], + ), + const SizedBox( + height: 8, + ), + Row( + children: [ + const Icon(Icons.calendar_month_outlined), const SizedBox( - height: 8, + width: 8, ), Text( - 'Dietary specifications: ${listing.dietaryNeeds.split('.').last}'), - const SizedBox( - height: 8, + 'Expiry Date:', + style: Theme.of(context).textTheme.titleMedium!, ), - Text('Expiry date: $formattedDate'), const SizedBox( - height: 8, + width: 8, ), - Text('Collection address: ${listing.address}'), + Text(formattedDate), + ], + ), + const SizedBox( + height: 10, + ), + Row( + children: [ + widget.listing.isAvailable + ? const Icon(Icons.check) + : const Icon(Icons.close), const SizedBox( - height: 8, + width: 8, + ), + Text( + 'Available:', + style: Theme.of(context).textTheme.titleMedium!, ), - Text('Available: ${listing.isAvailable.toString()}'), const SizedBox( - height: 8, + width: 8, ), - Text('Donor: ${listing.userName}'), + widget.listing.isAvailable + ? const Text('Yes') + : const Text('No'), + ], + ), + const SizedBox( + height: 10, + ), + Row( + children: [ + const Icon(Icons.location_on), const SizedBox( - height: 8, + width: 8, + ), + Text( + 'Collection/ Meetup point:', + style: Theme.of(context).textTheme.titleMedium!, ), - Container( - padding: const EdgeInsets.all(8), - width: double.infinity, - height: 200, - decoration: BoxDecoration( - border: Border.all(), - borderRadius: BorderRadius.circular(16), - ), - child: Text( - 'Additional notes: ${listing.additionalNotes}')), - // Add more widgets as needed ], ), - ), + const SizedBox( + height: 4, + ), + Text(widget.listing.address), + const SizedBox( + height: 8, + ), + Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + ), + child: Image.network(widget.listing.addressImageUrl), + ), + + const SizedBox( + height: 16, + ), + + Container( + padding: const EdgeInsets.all(8), + width: double.infinity, + height: 200, + decoration: BoxDecoration( + border: Border.all(), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Additional notes:', + style: Theme.of(context).textTheme.titleMedium!, + ), + const SizedBox( + height: 8, + ), + Text(widget.listing.additionalNotes), + ], + )), + ], ), ), - ], + ); + + return Scaffold( + appBar: AppBar( + title: Text( + 'Listing: ${widget.listing.itemName.toUpperCase()}', + style: Theme.of(context).textTheme.titleLarge, ), ), + body: content, ); } } + +//Checks if the chatroom has any messages +Future _hasMessagesSubcollection(String chatId) async { + CollectionReference messagesCollection = FirebaseFirestore.instance + .collection('chat') + .doc(chatId) + .collection('messages'); + + QuerySnapshot querySnapshot = await messagesCollection.get(); + return querySnapshot.docs.isNotEmpty; +} + +//Deletes chatroom, aka delete chat doc in firestore +void _deleteChat(String chatId) async { + try { + await FirebaseFirestore.instance.collection('chat').doc(chatId).delete(); + print('Document with ID $chatId deleted successfully.'); + } catch (e) { + print('Error deleting document: $e'); + } +} diff --git a/lib/screens/listings_list_screen.dart b/lib/screens/listings_list_screen.dart index ba1c9e5..e77c390 100644 --- a/lib/screens/listings_list_screen.dart +++ b/lib/screens/listings_list_screen.dart @@ -41,8 +41,7 @@ class _ListingsScreenState extends State { )); } else if (snapshot.hasData) { final allListings = snapshot.data!; - // final filteredListings = allListings.where("isAvailable", isEqualto : true); - + return Expanded( child: GridView( padding: const EdgeInsets.all(16), diff --git a/lib/screens/log_in_&_auth/login_screen.dart b/lib/screens/log_in_&_auth/login_screen.dart index 9aa5e13..cedaee9 100644 --- a/lib/screens/log_in_&_auth/login_screen.dart +++ b/lib/screens/log_in_&_auth/login_screen.dart @@ -2,7 +2,7 @@ import 'package:firebase_auth/firebase_auth.dart'; import 'package:foodbridge_project/main.dart'; import 'package:flutter/material.dart'; import 'package:flutter/gestures.dart'; -import 'package:foodbridge_project/widgets/login_registration/utils.dart'; +import 'package:foodbridge_project/widgets/utils.dart'; class LoginWidget extends StatefulWidget { final VoidCallback onClickedSignUp; diff --git a/lib/screens/log_in_&_auth/registration_screen.dart b/lib/screens/log_in_&_auth/registration_screen.dart index 86cdb6e..183582e 100644 --- a/lib/screens/log_in_&_auth/registration_screen.dart +++ b/lib/screens/log_in_&_auth/registration_screen.dart @@ -1,10 +1,15 @@ +import 'dart:io'; + import 'package:email_validator/email_validator.dart'; import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_storage/firebase_storage.dart'; import 'package:foodbridge_project/main.dart'; -import 'package:foodbridge_project/widgets/login_registration/utils.dart'; +import 'package:foodbridge_project/widgets/utils.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import '../../widgets/login_registration/user_image_picker.dart'; + class SignUpWidget extends StatefulWidget { final Function() onClickedSignIn; @@ -23,6 +28,7 @@ class _SignUpWidgetState extends State { final passwordController = TextEditingController(); final confirmPasswordController = TextEditingController(); final usernameController = TextEditingController(); + File? _selectedImage; @override void dispose() { @@ -56,6 +62,11 @@ class _SignUpWidgetState extends State { style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), const SizedBox(height: 30), + UserImagePicker( + onPickImage: (pickedImage) { + _selectedImage = pickedImage; + }, + ), TextFormField( controller: emailController, cursorColor: const Color.fromARGB(255, 0, 0, 0), @@ -95,7 +106,7 @@ class _SignUpWidgetState extends State { TextFormField( controller: confirmPasswordController, textInputAction: TextInputAction.done, - decoration: InputDecoration(labelText: 'Confirm Password'), + decoration: const InputDecoration(labelText: 'Confirm Password'), obscureText: true, validator: (value) => passwordController.text != confirmPasswordController.text @@ -143,6 +154,27 @@ class _SignUpWidgetState extends State { final isValid = formKey.currentState!.validate(); if (!isValid) return; + if (_selectedImage == null) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('No Image Selected'), + content: const Text('Please select an profile image.'), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); // Close the dialog + }, + child: const Text('OK'), + ), + ], + ); + }, + ); + return; + } + showDialog( context: context, barrierDismissible: false, @@ -156,9 +188,13 @@ class _SignUpWidgetState extends State { ); final user = FirebaseAuth.instance.currentUser!; - if (user != null) { - await user.updateDisplayName(usernameController.text.trim()); - } + + await user.updateDisplayName(usernameController.text.trim()); + final storageRef = + FirebaseStorage.instance.ref().child('profile_pictures/${user.uid}'); + await storageRef.putFile(_selectedImage!); + final imageUrl = await storageRef.getDownloadURL(); + await user.updatePhotoURL(imageUrl); } on FirebaseAuthException catch (e) { print(e); diff --git a/lib/screens/new_listing_screen.dart b/lib/screens/new_listing_screen.dart index 1481c4b..ca2dc85 100644 --- a/lib/screens/new_listing_screen.dart +++ b/lib/screens/new_listing_screen.dart @@ -2,20 +2,24 @@ import 'dart:io'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_storage/firebase_storage.dart' as firebase_storage; import 'package:flutter/material.dart'; -import 'package:foodbridge_project/widgets/image_input.dart'; -import 'package:foodbridge_project/widgets/location_input.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:foodbridge_project/widgets/image_input/image_input.dart'; +import 'package:foodbridge_project/widgets/location_input/location_input.dart'; import 'package:intl/intl.dart'; import 'package:foodbridge_project/models/listing.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; -class NewListingScreen extends StatefulWidget { +import '../providers/current_user_provider.dart'; +import '../widgets/utils.dart'; + +class NewListingScreen extends ConsumerStatefulWidget { const NewListingScreen({super.key}); @override - State createState() => _NewListingScreenState(); + ConsumerState createState() => _NewListingScreenState(); } -class _NewListingScreenState extends State { +class _NewListingScreenState extends ConsumerState { MainCategory? selectedMainCategory; SubCategory? selectedSubCategory; final _formKey = GlobalKey(); @@ -23,27 +27,54 @@ class _NewListingScreenState extends State { firebase_storage.FirebaseStorage storage = firebase_storage.FirebaseStorage.instance; + @override + void dispose() { + dateInputController.dispose(); + super.dispose(); + } + + // ignore: prefer_typing_uninitialized_variables var _itemName; + // ignore: prefer_typing_uninitialized_variables var _chosenMainCategory; + // ignore: prefer_typing_uninitialized_variables var _chosenSubCategory; + // ignore: prefer_typing_uninitialized_variables var _chosenDietaryOption; + // ignore: prefer_typing_uninitialized_variables var _chosenDate; + // ignore: prefer_typing_uninitialized_variables var _additionalInfo; + // ignore: prefer_typing_uninitialized_variables var _selectedImage; - var _lat; - var _lng; - var _address; + // ignore: prefer_typing_uninitialized_variables + double? _lat; + double? _lng; + String? _address; + String? _addressImageUrl; + bool _isSaving = false; + //takes an image file, upload it to firebase storage and returns url Future uploadImage(File file) async { String fileName = DateTime.now().millisecondsSinceEpoch.toString(); - firebase_storage.Reference ref = - storage.ref().child('listingImages/$fileName'); + + firebase_storage.Reference ref = firebase_storage.FirebaseStorage.instance + .ref() + .child('listingImages/$fileName'); + await ref.putFile(file); + String imageUrl = await ref.getDownloadURL(); return imageUrl; } + //Goes to firestore collection 'Listings'. + //Create new document and retrieve doc id. + //Put the doc id inside the doc as a field for future references + //Converts listing data to json format. + //Overwrite any data inside that document with listing data (should + //be empty in the first place by right). Future createListing({ required String itemName, required String urlLink, @@ -55,11 +86,13 @@ class _NewListingScreenState extends State { required double lat, required double lng, required String address, + required String email, + required String userName, + required String addressImageUrl, + required String userPhoto, }) async { final docListing = FirebaseFirestore.instance.collection('Listings').doc(); - final user = FirebaseAuth.instance.currentUser!; - final listing = Listing( id: docListing.id, itemName: itemName, @@ -73,14 +106,21 @@ class _NewListingScreenState extends State { lng: lng, address: address, isAvailable: true, - userId: user.email.toString(), - userName: user.displayName.toString(), + userId: email, + userName: userName, + addressImageUrl: addressImageUrl, + userPhoto: userPhoto, ); final json = listing.toJson(); await docListing.set(json); } + //Pops up date picker to pick expiry date + //Users can only select dates 1 day after the date + //at time of selecting. For example, today's date is + //22/05/2023, then you can only select 23/05/2023 onwards + //with maximum difference of 2 years. void _presentDatePicker() async { final now = DateTime.now(); final firstDate = DateTime( @@ -105,6 +145,9 @@ class _NewListingScreenState extends State { } } + //Validates user input first. + //If validation fails, prompt user to check entries. + //If validation passes, create new listing on firestore void _saveItem() async { if (_selectedImage == null) { showDialog( @@ -114,10 +157,10 @@ class _NewListingScreenState extends State { 'Picture missing', textAlign: TextAlign.center, ), - content: Container( + content: const SizedBox( height: 16, width: 32, - child: const Center( + child: Center( child: Text('Add a picture'), ), ), @@ -141,10 +184,10 @@ class _NewListingScreenState extends State { 'Address missing', textAlign: TextAlign.center, ), - content: Container( + content: const SizedBox( height: 16, width: 32, - child: const Center( + child: Center( child: Text('Click on get address'), ), ), @@ -165,19 +208,28 @@ class _NewListingScreenState extends State { _isSaving = true; }); _formKey.currentState!.save(); - final urlLink = await uploadImage(_selectedImage); - createListing( - itemName: _itemName!, - urlLink: urlLink!, - mainCat: _chosenMainCategory!, - subCat: _chosenSubCategory!, - dietaryInfo: _chosenDietaryOption!, - addInfo: _additionalInfo!, - expDate: _chosenDate!, - lat: _lat!, - lng: _lng!, - address: _address!, - ); + try { + final urlLink = await uploadImage(_selectedImage); + final user = FirebaseAuth.instance.currentUser!; + + createListing( + itemName: _itemName!, + urlLink: urlLink!, + mainCat: _chosenMainCategory!, + subCat: _chosenSubCategory!, + dietaryInfo: _chosenDietaryOption!, + addInfo: _additionalInfo!, + expDate: _chosenDate!, + lat: _lat!, + lng: _lng!, + address: _address!, + email: user.email!, + userName: user.displayName!, + userPhoto: user.photoURL!, + addressImageUrl: _addressImageUrl!); + } catch (e) { + Utils.showSnackBar('error: $e'); + } Navigator.pop(context); } } @@ -186,15 +238,9 @@ class _NewListingScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - centerTitle: true, - backgroundColor: Colors.orange, - title: const Text( - 'ADD NEW LISTING', - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.white, - fontSize: 24, - ), + title: Text( + 'Add New Listing', + style: Theme.of(context).textTheme.titleLarge, ), ), body: Padding( @@ -204,13 +250,15 @@ class _NewListingScreenState extends State { key: _formKey, child: Column( children: [ + //For selecting image ImageInput( chosenImage: (File image) { setState(() { _selectedImage = image; }); }, - ), // for selecting image + ), + //Item name TextFormField( maxLength: 50, decoration: const InputDecoration( @@ -230,6 +278,7 @@ class _NewListingScreenState extends State { const SizedBox( height: 8, ), + //Main category DropdownButtonFormField( value: selectedMainCategory, items: MainCategory.values.map((mainCat) { @@ -245,7 +294,7 @@ class _NewListingScreenState extends State { }); }, decoration: const InputDecoration( - hintText: 'Select main category', + label: Text('Main category'), ), validator: (value) { if (value == null) { @@ -259,6 +308,7 @@ class _NewListingScreenState extends State { const SizedBox( height: 8, ), + //Subcategory DropdownButtonFormField( value: selectedSubCategory, items: selectedMainCategory != null @@ -276,7 +326,7 @@ class _NewListingScreenState extends State { }); }, decoration: const InputDecoration( - hintText: 'Select subcategory', + label: Text('Sub category'), ), validator: (value) { if (value == null) { @@ -290,8 +340,11 @@ class _NewListingScreenState extends State { const SizedBox( height: 8, ), + //dietary specifications DropdownButtonFormField( - hint: const Text('Select dietary specifications'), + decoration: const InputDecoration( + label: Text('Dietary specifications'), + ), items: [ for (final category in DietaryNeeds.values) DropdownMenuItem( @@ -309,43 +362,40 @@ class _NewListingScreenState extends State { _chosenDietaryOption = value.toString(); }, ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: TextFormField( - decoration: const InputDecoration( - hintText: 'Select expiry date', - ), - controller: dateInputController, - onTap: _presentDatePicker, - readOnly: true, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please select a date'; - } - }, - onSaved: (value) { - _chosenDate = DateTime.tryParse(value!); - }, - ), - ), - ], + //Expiry date + TextFormField( + decoration: const InputDecoration( + label: Text('Expiry date'), + ), + controller: dateInputController, + onTap: _presentDatePicker, + readOnly: true, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please select a date'; + } + }, + onSaved: (value) { + _chosenDate = DateTime.tryParse(value!); + }, ), const SizedBox( height: 16, ), + //Retrieving location LocationInput( - chosenLocation: (UserLocation location) { - _lat = location.latitude; - _lng = location.longitude; - _address = location.address; + chosenLocation: (UserLocation? location) { + _lat = location?.latitude; + _lng = location?.longitude; + _address = location?.address; + _addressImageUrl = location?.addressImageUrl; + print('hello $_addressImageUrl'); }, ), const SizedBox( height: 16, ), + //Additional details TextFormField( maxLines: 5, maxLength: 200, @@ -367,6 +417,7 @@ class _NewListingScreenState extends State { _additionalInfo = value; }, ), + //cancel and save button Row( children: [ ElevatedButton.icon( diff --git a/lib/screens/notifications_screen.dart b/lib/screens/notifications_screen.dart index 7f3a244..114b3a0 100644 --- a/lib/screens/notifications_screen.dart +++ b/lib/screens/notifications_screen.dart @@ -12,8 +12,7 @@ class _NotificationsScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Notifications'), - backgroundColor: Colors.orange, + title: Text('Notifications', style: Theme.of(context).textTheme.titleLarge,), ), ); } diff --git a/lib/screens/profile_screens/others_profile_screen.dart b/lib/screens/profile_screens/others_profile_screen.dart new file mode 100644 index 0000000..8fefffc --- /dev/null +++ b/lib/screens/profile_screens/others_profile_screen.dart @@ -0,0 +1,119 @@ +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/material.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:foodbridge_project/widgets/profile_appbar.dart'; +import '../../models/listing.dart'; +import '../listings_list_screen.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class OthersProfileScreen extends ConsumerWidget { + const OthersProfileScreen({ + super.key, + required this.userId, + required this.userName, + required this.userPhoto, + }); + + final String userId; + final String userName; + final String userPhoto; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final user = FirebaseAuth.instance.currentUser!; + + Stream> readUserListings() { + return FirebaseFirestore.instance + .collection('Listings') + .where("userId", isEqualTo: userId) + .orderBy("expiryDate", descending: true) + .orderBy("isAvailable", descending: true) + .snapshots() + .map((snapshot) => snapshot.docs + .map((doc) => Listing.fromJson(doc.data())) + .toList()); + } + + return Scaffold( + appBar: ProfileAppBar(context), + body: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CircleAvatar( + radius: 64, + backgroundColor: Colors.grey, + child: CircleAvatar( + radius: 62, + backgroundImage: NetworkImage(userPhoto), + ), + ), + const SizedBox( + width: 16, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Username:', + style: Theme.of(context) + .textTheme + .titleMedium! + .copyWith(fontSize: 14), + ), + const SizedBox(width: 4), + Text(userName, + style: Theme.of(context).textTheme.bodyMedium!), + ], + ), + const SizedBox( + height: 8, + ), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(50), + ), + icon: const Icon(Icons.warning, size: 32), + label: const Text( + 'Report', + style: TextStyle(fontSize: 24), + ), + onPressed: () {}, + ) + ], + ), + ), + ], + ), + ), + const SizedBox( + height: 8, + ), + Text( + '${userName}\'s Listings', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + const SizedBox( + height: 8, + ), + Expanded( + child: ListingsScreen( + availListings: readUserListings(), + isYourListing: false, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screens/own_profile_screen.dart similarity index 66% rename from lib/screens/profile_screen.dart rename to lib/screens/profile_screens/own_profile_screen.dart index 0257a8b..ae1549a 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screens/own_profile_screen.dart @@ -1,21 +1,23 @@ import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; -import '../models/listing.dart'; -import 'listings_list_screen.dart'; +import '../../models/listing.dart'; +import '../listings_list_screen.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; - -class ProfileScreen extends StatelessWidget { +class ProfileScreen extends ConsumerWidget { const ProfileScreen({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final user = FirebaseAuth.instance.currentUser!; Stream> readUserListings() { return FirebaseFirestore.instance .collection('Listings') .where("userId", isEqualTo: user.email) + .orderBy("expiryDate", descending: true) + .orderBy("isAvailable", descending: true) .snapshots() .map((snapshot) => snapshot.docs .map((doc) => Listing.fromJson(doc.data())) @@ -32,11 +34,11 @@ class ProfileScreen extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.center, children: [ CircleAvatar( - backgroundColor: Colors.blue.shade300, radius: 64, - child: Text( - user.displayName!.substring(0, 2).toUpperCase(), - style: const TextStyle(fontSize: 40), + backgroundColor: Colors.grey, + child: CircleAvatar ( + radius: 62, + backgroundImage: NetworkImage(user.photoURL.toString()), ), ), const SizedBox( @@ -46,11 +48,23 @@ class ProfileScreen extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Signed in as: ${user.email!}'), + Row( + children: [ + Text('Signed in as:', style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 14),), + const SizedBox(width: 4), + Text(user.email!, style: Theme.of(context).textTheme.bodyMedium!), + ], + ), const SizedBox( height: 8, ), - Text('Username: ${user.displayName!}'), + Row( + children: [ + Text('Username:', style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 14),), + const SizedBox(width: 4), + Text(user.displayName!, style: Theme.of(context).textTheme.bodyMedium!), + ], + ), const SizedBox( height: 8, ), diff --git a/lib/screens/tabs_screen.dart b/lib/screens/tabs_screen.dart index 07fe1bb..85cbaf9 100644 --- a/lib/screens/tabs_screen.dart +++ b/lib/screens/tabs_screen.dart @@ -1,27 +1,40 @@ -//import 'package:firebase_auth/firebase_auth.dart'; +import 'dart:ffi'; + import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/material.dart'; import 'package:foodbridge_project/models/listing.dart'; -import 'package:foodbridge_project/screens/listings_list_screen.dart'; import 'package:foodbridge_project/screens/new_listing_screen.dart'; -import 'package:foodbridge_project/screens/profile_screen.dart'; -//import 'package:foodbridge_project/widgets/homepage_appbar.dart'; +import 'package:foodbridge_project/screens/profile_screens/own_profile_screen.dart'; +import 'package:foodbridge_project/widgets/filter_mapping.dart'; import 'package:foodbridge_project/widgets/profile_appbar.dart'; -import 'chat_list_screen.dart'; -import 'likes_screen.dart'; +import '../widgets/listings_column.dart'; +import 'listings_list_screen.dart'; +import 'chat/chat_list_screen.dart'; +import 'favorites_screen.dart'; import 'notifications_screen.dart'; -import 'filter.dart'; +import '../widgets/filter.dart'; class TabsScreen extends StatefulWidget { const TabsScreen({super.key}); - @override State createState() => _TabsScreenState(); } class _TabsScreenState extends State { String itemName = ""; - + int selectedPageIndex = 0; + List editedFoodTypes = []; + String editedDietaryNeeds = ''; + //Get snapshot from Firestore collection "Listings". + //Filters documents by expiration date and availability, converts snapshots to lists, + //then displays it on screen. + //If user has searched for something, it also filters out items that have an exact + //match for item name. + //Get snapshot from Firestore collection "Listings". + //Filters documents by expiration date and availability, converts snapshots to lists, + //then displays it on screen. + //If user has searched for something, it also filters out items that have an exact + //match for item name. Stream> readListings(String searchQuery) { DateTime currentDateTime = DateTime.now(); CollectionReference listingsRef = @@ -37,13 +50,20 @@ class _TabsScreenState extends State { query = query.where("itemName", isEqualTo: searchResult); } - if (selectedOptions.isNotEmpty) { - for (int i = 0; i < selectedOptions.length; i += 1) { - selectedOptions[i] = - selectedOptions[i].toLowerCase().replaceAll(' ', ''); - selectedOptions[i] = 'SubCategory.${selectedOptions[i]}'; + if (selectedFoodTypes.isNotEmpty) { + editedFoodTypes = []; + for (int i = 0; i < selectedFoodTypes.length; i += 1) { + editedFoodTypes.add(filterMap[selectedFoodTypes[i]]!); } - query = query.where('subCategory', whereIn: selectedOptions); + query = query.where('subCategory', whereIn: editedFoodTypes); + } + + if (selectedDietaryNeeds.isNotEmpty) { + editedDietaryNeeds = ''; + for (int i = 0; i < selectedDietaryNeeds.length; i += 1) { + editedDietaryNeeds = filterMap[selectedDietaryNeeds[i]]!; + } + query = query.where('dietaryNeeds', isEqualTo: editedDietaryNeeds); } return query.snapshots().map((snapshot) => snapshot.docs @@ -51,8 +71,7 @@ class _TabsScreenState extends State { .toList()); } - int selectedPageIndex = 0; - + //push 'add new listing screen' on top void addNewListing() { Navigator.push( context, @@ -145,6 +164,7 @@ class _TabsScreenState extends State { ], ); + //By default, active app bar is the app bar for home page AppBar activeAppBar = AppBar( leading: IconButton( onPressed: () { @@ -156,7 +176,6 @@ class _TabsScreenState extends State { }, icon: const Icon(Icons.notifications_none), ), - backgroundColor: Colors.orange, title: SizedBox( width: 200, child: TextField( @@ -202,6 +221,7 @@ class _TabsScreenState extends State { ], ); + //checks the page selected in navigation bar if (selectedPageIndex == 2) { activeScreen = const ProfileScreen(); activeAppBar = ProfileAppBar(context); diff --git a/lib/screens/testing_firebase.dart b/lib/screens/testing_firebase.dart deleted file mode 100644 index d46b736..0000000 --- a/lib/screens/testing_firebase.dart +++ /dev/null @@ -1,107 +0,0 @@ -//IGNORE THIS FILE. FOR TESTING IF FIREBASE COULD CONNECT - -// import 'package:flutter/material.dart'; -// import 'package:cloud_firestore/cloud_firestore.dart'; -// import 'package:firebase_core/firebase_core.dart'; - -// class User { -// User({ -// this.id = '', -// required this.name, -// required this.age, -// required this.birthday, -// }); - -// String id; -// final String name; -// final int age; -// final DateTime birthday; - -// Map toJson() => { -// 'id': id, -// 'name': name, -// 'age': age, -// 'birthday': birthday, -// }; - -// static User fromJson(Map json) => User( -// id: json['id'], -// name: json['name'], -// age: json['age'], -// birthday: (json['birthday'] as Timestamp).toDate(), -// ); -// } - -// class TestingPage extends StatefulWidget { -// const TestingPage({super.key}); - -// @override -// State createState() => _TestingPageState(); -// } - -// class _TestingPageState extends State { -// final controller = TextEditingController(); - -// @override -// Widget build(BuildContext context) { -// // Widget buildUser(User user) => ListTile( -// // leading: CircleAvatar(child: Text('${user.age}')), -// // title: Text(user.name), -// // subtitle: Text(user.birthday.toIso8601String()), -// // ); - -// // Stream> readUsers() => FirebaseFirestore.instance -// // .collection('testing') -// // .snapshots() -// // .map((snapshot) => -// // snapshot.docs.map((doc) => User.fromJson(doc.data())).toList()); - -// Future createUser({required String name}) async { -// final docUser = FirebaseFirestore.instance.collection('testing').doc(); - -// final user = User( -// id: docUser.id, -// name: name, -// age: 21, -// birthday: DateTime.now(), -// ); - -// final json = user.toJson(); -// await docUser.set(json); -// } - -// return Scaffold( -// appBar: AppBar( -// title: TextField( -// controller: controller, -// ), -// actions: [ -// IconButton( -// onPressed: () { -// final name = controller.text; -// createUser(name: name); -// }, -// icon: Icon(Icons.add), -// ) -// ], -// ), -// body: StreamBuilder>( -// stream: readUsers(), -// builder: (context, snapshot) { -// if (snapshot.hasError) { -// return Text('${snapshot.error}'); -// } else if (snapshot.hasData) { -// final users = snapshot.data!; -// return ListView( -// children: users.map(buildUser).toList(), -// ); -// } else { -// return Center( -// child: CircularProgressIndicator(), -// ); -// } -// }, -// ), -// ); -// } -// } diff --git a/lib/widgets/chat_widgets/chat_list.dart b/lib/widgets/chat_widgets/chat_list.dart new file mode 100644 index 0000000..d44542a --- /dev/null +++ b/lib/widgets/chat_widgets/chat_list.dart @@ -0,0 +1,201 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/material.dart'; +import 'package:foodbridge_project/screens/chat/chatroom_screen.dart'; +import 'package:foodbridge_project/widgets/loading.dart'; + +class AllChatList extends StatelessWidget { + const AllChatList({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final user = FirebaseAuth.instance.currentUser!; + return StreamBuilder( + //Filter chats that belong to current user + stream: FirebaseFirestore.instance + .collection('chat') + .where('participants', arrayContains: user.email) + .where('hasMessages', isEqualTo: true) + .snapshots(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const LoadingCircleScreen(); + } + + if (!snapshot.hasData || snapshot.data!.docs.isEmpty) { + return const Center( + child: Text('No messages found.'), + ); + } + + if (snapshot.hasError) { + return const Center( + child: Text('Something went wrong...'), + ); + } + final documents = snapshot.data!.docs; + + // Filter the documents that have the 'messages' subcollection + final loadedMessagesList = documents.where((doc) => + doc.reference.collection('messages').snapshots().isBroadcast).toList(); + + return ListView.builder( + //padding: const EdgeInsets.all(8), + itemCount: loadedMessagesList.length, + itemBuilder: (context, index) { + DocumentSnapshot messageSnapshot = loadedMessagesList[index]; + Map messageData = + messageSnapshot.data() as Map; + + // Extract the necessary IDs from messageData for referencing + String listingId = messageData['listing'].trim(); + String chatId = messageData['chatId'].trim(); + + // Extract data from messageData to display on list + //of chat cards + String latestMessage = ''; + List myArray = messageData['participants']; + DocumentReference parentDocRef = messageSnapshot.reference; + CollectionReference subcollectionRef = + parentDocRef.collection('messages'); + + subcollectionRef + .orderBy('createdAt', descending: true) + .limit(1) + .get() + .then((subcollectionQuerySnapshot) { + if (subcollectionQuerySnapshot.docs.isNotEmpty) { + // Process the most recent document from the subcollection here + DocumentSnapshot mostRecentDocument = + subcollectionQuerySnapshot.docs.first; + latestMessage = mostRecentDocument['text']; + // ... + } else { + print('no messages found'); + } + }); + + //Building each chat card + return FutureBuilder( + future: FirebaseFirestore.instance + .collection('Listings') + .doc(listingId) + .get(), + builder: (BuildContext context, + AsyncSnapshot snapshot) { + if (snapshot.hasError) { + return const Text("Something went wrong"); + } + + if (snapshot.hasData && !snapshot.data!.exists) { + print(listingId); + return const Text("Document does not exist"); + } + + if (snapshot.connectionState == ConnectionState.done) { + final String chatPartner = + myArray[0] == user.email ? myArray[1] : myArray[0]; + Map data = + snapshot.data!.data() as Map; + String? imageUrl = data['image']; + //String? donorName = data?['userId']; + return Card( + margin: const EdgeInsets.fromLTRB(16, 4, 16, 4), + child: ListTile( + leading: CircleAvatar( + backgroundColor: Colors.blue.shade300, + radius: 20, + child: Text( + chatPartner.substring(0, 2).toUpperCase(), + style: const TextStyle(fontSize: 12), + ), + ), + title: Text(chatPartner), + subtitle: Text( + latestMessage, + overflow: TextOverflow.ellipsis, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + imageUrl != null + ? Padding( + padding: const EdgeInsets.all(4.0), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + imageUrl, + fit: BoxFit.cover, + ), + ), + ) + : const SizedBox(), + IconButton( + onPressed: () { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + content: const SizedBox( + height: 16, + width: 16, + child: Center( + child: Text('Delete Chat?'), + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(ctx); + }, + child: const Text('No')), + TextButton( + onPressed: () { + Navigator.pop(ctx); + _deleteChat(chatId); + }, + child: const Text('Yes'), + ), + ], + ), + ); + }, + icon: Icon(Icons.delete)) + ], + ), + // Customize the appearance and behavior of the ListTile as needed + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatScreen( + chatId: chatId, + listingId: listingId, + )), + ); + }, + ), + ); + } + + return const Center(child: CircularProgressIndicator()); + }, + ); + }, + ); + }, + ); + } +} + +//delete chat document with doc id == chatId from firestore +void _deleteChat(String chatId) { + FirebaseFirestore.instance + .collection('chat') + .doc(chatId) + .delete() + .then((value) { + print('Document deleted successfully'); + }).catchError((error) { + print('Failed to delete document: $error'); + }); +} diff --git a/lib/widgets/chat_widgets/chat_messages.dart b/lib/widgets/chat_widgets/chat_messages.dart new file mode 100644 index 0000000..771299c --- /dev/null +++ b/lib/widgets/chat_widgets/chat_messages.dart @@ -0,0 +1,86 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/material.dart'; + +import 'message_bubble.dart'; + +class ChatMessages extends StatelessWidget { + const ChatMessages({super.key, required this.chatId}); + + final String chatId; + + @override + Widget build(BuildContext context) { + final authenticatedUser = FirebaseAuth.instance.currentUser!; + + return StreamBuilder( + //goes to subcollection 'messages' and + //retrieves all messages + stream: FirebaseFirestore.instance + .collection('chat') + .doc(chatId) + .collection('messages') + .orderBy( + 'createdAt', + descending: true, + ) + .snapshots(), + builder: (ctx, chatSnapshots) { + if (chatSnapshots.connectionState == ConnectionState.waiting) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (!chatSnapshots.hasData || chatSnapshots.data!.docs.isEmpty) { + return const Center( + child: Text('No messages found.'), + ); + } + + if (chatSnapshots.hasError) { + return const Center( + child: Text('Something went wrong...'), + ); + } + + final loadedMessages = chatSnapshots.data!.docs; + + return ListView.builder( + padding: const EdgeInsets.only( + bottom: 40, + left: 13, + right: 13, + ), + reverse: true, + itemCount: loadedMessages.length, + itemBuilder: (ctx, index) { + final chatMessage = loadedMessages[index].data(); + final nextChatMessage = index + 1 < loadedMessages.length + ? loadedMessages[index + 1].data() + : null; + + final currentMessageUserId = chatMessage['userId']; + final nextMessageUserId = + nextChatMessage != null ? nextChatMessage['userId'] : null; + final nextUserIsSame = nextMessageUserId == currentMessageUserId; + + if (nextUserIsSame) { + return MessageBubble.next( + message: chatMessage['text'], + isMe: authenticatedUser.email == currentMessageUserId, + ); + } else { + return MessageBubble.first( + userImage: chatMessage['userImage'], + username: chatMessage['userName'], + message: chatMessage['text'], + isMe: authenticatedUser.email == currentMessageUserId, + ); + } + }, + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/widgets/chat_widgets/message_bubble.dart b/lib/widgets/chat_widgets/message_bubble.dart new file mode 100644 index 0000000..053a8ed --- /dev/null +++ b/lib/widgets/chat_widgets/message_bubble.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; + +// A MessageBubble for showing a single chat message on the ChatScreen. +class MessageBubble extends StatelessWidget { + // Create a message bubble which is meant to be the first in the sequence. + const MessageBubble.first({ + super.key, + required this.userImage, + required this.username, + required this.message, + required this.isMe, + }) : isFirstInSequence = true; + + // Create a amessage bubble that continues the sequence. + const MessageBubble.next({ + super.key, + required this.message, + required this.isMe, + }) : isFirstInSequence = false, + userImage = null, + username = null; + + // Whether or not this message bubble is the first in a sequence of messages + // from the same user. + // Modifies the message bubble slightly for these different cases - only + // shows user image for the first message from the same user, and changes + // the shape of the bubble for messages thereafter. + final bool isFirstInSequence; + + // Image of the user to be displayed next to the bubble. + // Not required if the message is not the first in a sequence. + final String? userImage; + + // Username of the user. + // Not required if the message is not the first in a sequence. + final String? username; + final String message; + + // Controls how the MessageBubble will be aligned. + final bool isMe; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Stack( + children: [ + if (userImage != null) + Positioned( + top: 15, + // Align user image to the right, if the message is from me. + right: isMe ? 0 : null, + child: CircleAvatar( + backgroundImage: NetworkImage( + userImage!, + ), + backgroundColor: theme.colorScheme.primary.withAlpha(180), + radius: 23, + ), + ), + Container( + // Add some margin to the edges of the messages, to allow space for the + // user's image. + margin: const EdgeInsets.symmetric(horizontal: 46), + child: Row( + // The side of the chat screen the message should show at. + mainAxisAlignment: + isMe ? MainAxisAlignment.end : MainAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: + isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + // First messages in the sequence provide a visual buffer at + // the top. + if (isFirstInSequence) const SizedBox(height: 18), + if (username != null) + Padding( + padding: const EdgeInsets.only( + left: 13, + right: 13, + ), + child: Text( + username!, + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ), + + // The "speech" box surrounding the message. + Container( + decoration: BoxDecoration( + color: isMe + ? Colors.grey[300] + : theme.colorScheme.secondary.withAlpha(200), + // Only show the message bubble's "speaking edge" if first in + // the chain. + // Whether the "speaking edge" is on the left or right depends + // on whether or not the message bubble is the current user. + borderRadius: BorderRadius.only( + topLeft: !isMe && isFirstInSequence + ? Radius.zero + : const Radius.circular(12), + topRight: isMe && isFirstInSequence + ? Radius.zero + : const Radius.circular(12), + bottomLeft: const Radius.circular(12), + bottomRight: const Radius.circular(12), + ), + ), + // Set some reasonable constraints on the width of the + // message bubble so it can adjust to the amount of text + // it should show. + constraints: const BoxConstraints(maxWidth: 200), + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 14, + ), + // Margin around the bubble. + margin: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 12, + ), + child: Text( + message, + style: TextStyle( + // Add a little line spacing to make the text look nicer + // when multilined. + height: 1.3, + color: isMe + ? Colors.black87 + : theme.colorScheme.onSecondary, + ), + softWrap: true, + ), + ), + ], + ), + ], + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/chat_widgets/new_message.dart b/lib/widgets/chat_widgets/new_message.dart new file mode 100644 index 0000000..3f5b61f --- /dev/null +++ b/lib/widgets/chat_widgets/new_message.dart @@ -0,0 +1,80 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/material.dart'; + +class NewMessage extends StatefulWidget { + const NewMessage({super.key, required this.chatId}); + + final String chatId; + + @override + State createState() { + return _NewMessageState(); + } +} + +class _NewMessageState extends State { + final _messageController = TextEditingController(); + final user = FirebaseAuth.instance.currentUser!; + + @override + void dispose() { + _messageController.dispose(); + super.dispose(); + } + + void _submitMessage() { + final enteredMessage = _messageController.text; + + if (enteredMessage.trim().isEmpty) { + return; + } + FocusScope.of(context).unfocus(); + _messageController.clear(); + + // send to Firebase + FirebaseFirestore.instance + .collection('chat') + .doc(widget.chatId) + .collection('messages') + .add({ + 'text': enteredMessage, + 'createdAt': Timestamp.now(), + 'userId': user.email, + 'userName': user.displayName, + 'userImage': user.photoURL, + }); + + FirebaseFirestore.instance + .collection('chat') + .doc(widget.chatId) + .update({'hasMessages': true}); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 15, right: 1, bottom: 14), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _messageController, + textCapitalization: TextCapitalization.sentences, + autocorrect: true, + enableSuggestions: true, + decoration: const InputDecoration(labelText: 'Send a message...'), + ), + ), + IconButton( + color: Theme.of(context).colorScheme.primary, + icon: const Icon( + Icons.send, + ), + onPressed: _submitMessage, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/edit_location_input.dart b/lib/widgets/edit_location_input.dart deleted file mode 100644 index b620c04..0000000 --- a/lib/widgets/edit_location_input.dart +++ /dev/null @@ -1,188 +0,0 @@ -import 'dart:convert'; -import 'package:flutter/material.dart'; -import 'package:location/location.dart'; -import 'package:http/http.dart' as http; - -import '../models/listing.dart'; - -class EditLocationInput extends StatefulWidget { - const EditLocationInput({super.key, required this.chosenLocation, required this.listing}); - - final Listing listing; - final void Function(UserLocation location) chosenLocation; - - @override - State createState() => _EditLocationInputState(); -} - -class _EditLocationInputState extends State { - UserLocation? _pickedLocation; - var _isGettingLocation = false; - - String get newLocationImage { - if (_pickedLocation == null) { - return ''; - } - final lat = _pickedLocation!.latitude; - final lng = _pickedLocation!.longitude; - return 'https://maps.googleapis.com/maps/api/staticmap?center=$lat,$lng=&zoom=16&size=600x300&maptype=roadmap&markers=color:red%7Clabel:A%7C$lat,$lng&key=AIzaSyBPnh4TzaS8FEO7vnHEdobC0Z6hsJATCoE'; - } - - String get oldLocationImage { - final oldLat = widget.listing.lat; - final oldLng = widget.listing.lng; - return 'https://maps.googleapis.com/maps/api/staticmap?center=$oldLat,$oldLng=&zoom=16&size=600x300&maptype=roadmap&markers=color:red%7Clabel:A%7C$oldLat,$oldLng&key=AIzaSyBPnh4TzaS8FEO7vnHEdobC0Z6hsJATCoE'; - } - - void _getCurrentLocation() async { - Location location = Location(); - - bool serviceEnabled; - PermissionStatus permissionGranted; - LocationData locationData; - - serviceEnabled = await location.serviceEnabled(); - if (!serviceEnabled) { - serviceEnabled = await location.requestService(); - if (!serviceEnabled) { - return; - } - } - - permissionGranted = await location.hasPermission(); - if (permissionGranted == PermissionStatus.denied) { - permissionGranted = await location.requestPermission(); - if (permissionGranted != PermissionStatus.granted) { - return; - } - } - - setState(() { - _isGettingLocation = true; - }); - - locationData = await location.getLocation(); - final lat = locationData.latitude; - final lng = locationData.longitude; - - if (lat == null || lng == null) { - return; - } //TODO add error handling - - final url = Uri.parse( - 'https://maps.googleapis.com/maps/api/geocode/json?latlng=$lat,$lng&key=AIzaSyBPnh4TzaS8FEO7vnHEdobC0Z6hsJATCoE'); - - final response = await http.get(url); - final resData = json.decode(response.body); - final address = resData['results'][0]['formatted_address']; - - setState(() { - _pickedLocation = UserLocation( - latitude: lat, - longitude: lng, - address: address, - ); - _isGettingLocation = false; - widget.chosenLocation(_pickedLocation!); - }); - } - - @override - Widget build(BuildContext context) { - Widget previewContent; - - if (_pickedLocation != null) { - previewContent = Stack( - children: [ - Container( - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - newLocationImage, - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - ), - ), - ), - Container( - padding: const EdgeInsets.all(5.0), - alignment: Alignment.bottomCenter, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withAlpha(0), - Colors.black12, - Colors.black45 - ], - ), - ), - child: Text( - _pickedLocation!.address, - style: TextStyle(color: Colors.white, fontSize: 20.0), - ), - ), - ], - ); - } else { - previewContent = Stack( - children: [ - Container( - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - oldLocationImage, - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - ), - ), - ), - Container( - padding: const EdgeInsets.all(5.0), - alignment: Alignment.bottomCenter, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withAlpha(0), - Colors.black12, - Colors.black45 - ], - ), - ), - child: Text( - widget.listing.address, - style: TextStyle(color: Colors.white, fontSize: 20.0), - ), - ), - ], - ); - } - - if (_isGettingLocation) { - previewContent = const CircularProgressIndicator(); - } - - return Column( - children: [ - Container( - alignment: Alignment.center, - height: 170, - width: double.infinity, - decoration: BoxDecoration( - border: Border.all(), borderRadius: BorderRadius.circular(8)), - child: previewContent, - ), - TextButton.icon( - onPressed: _getCurrentLocation, - icon: Icon(Icons.map), - label: Text('Get location'), - ), - ], - ); - } -} \ No newline at end of file diff --git a/lib/widgets/filter.dart b/lib/widgets/filter.dart new file mode 100644 index 0000000..4071740 --- /dev/null +++ b/lib/widgets/filter.dart @@ -0,0 +1,262 @@ +import 'package:flutter/material.dart'; + +List selectedFoodTypes = []; +List selectedDietaryNeeds = []; // Stores selection + +class FilterWidget extends StatefulWidget { + const FilterWidget({Key? key}) : super(key: key); + + @override + _FilterWidgetState createState() => _FilterWidgetState(); +} + +class _FilterWidgetState extends State { + List categories = [ + Category( + 'Food Types', + [ + Subcategory('Baby Food'), + Subcategory('Baked Goods'), + Subcategory( + 'Dairy, Chilled and Eggs', + [ + 'Eggs', + 'Milk', + 'Cheese', + 'Butter, Margarine and Spreads', + 'Cream', + 'Yoghurt', + ], + ), + Subcategory( + 'Beverages', + [ + 'Coffee', + 'Tea', + 'Juices', + 'Soft Drinks', + 'Others', + ], + ), + Subcategory( + 'Pantry Essentials', + [ + 'Canned', + 'Rice', + 'Pasta', + 'Noodles', + 'Cereal', + 'Condiments', + 'Baking Needs', + 'Oil', + ], + ), + Subcategory('Frozen', [ + 'Desserts', + 'Meat', + 'Seafoods', + ]), + Subcategory('Fruits and Vegetables', [ + 'Fruits', + 'Vegetables', + ]), + Subcategory('Meat and Seafood', [ + 'Chicken', + 'Pork', + 'Beef', + 'Lamb', + 'Seafood', + ]), + Subcategory('Others '), + ], + ), + Category( + 'Dietary Needs', + [ + Subcategory('Halal'), + Subcategory('Kosher'), + Subcategory('Contains Lactose'), + Subcategory('Contains Nuts'), + Subcategory('Contains Soy'), + Subcategory('None'), + ], + ), + ]; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded( + child: ListView.builder( + itemCount: categories.length, + itemBuilder: (context, categoryIndex) { + final category = categories[categoryIndex]; + return ExpansionTile( + title: Text( + category.name, + style: const TextStyle(color: Colors.white), + ), + children: category.subcategories.map((subcategory) { + if (subcategory.options != null && + subcategory.options!.isNotEmpty) { + return _buildExpandableSubcategoryTile(category, subcategory); + } else { + return _buildCheckboxSubcategoryTile(category, subcategory); + } + }).toList(), + ); + }, + ), + ), + Container( + alignment: Alignment.bottomCenter, + width: double.infinity, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + ElevatedButton( + onPressed: () { + selectedFoodTypes.clear(); + selectedDietaryNeeds.clear(); + setState(() {}); + }, + child: const Text('Reset'), + ), + const SizedBox( + width: 30, + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context, { + //'Food Types': selectedFoodTypes, + //'Dietary Needs': selectedDietaryNeeds, + }); + }, + child: const Text('Done'), + ), + ], + ), + ), + ], + ); + } + + Widget _buildExpandableSubcategoryTile( + Category category, Subcategory subcategory) { + return ExpansionTile( + title: Text( + subcategory.name, + style: const TextStyle(color: Colors.white), + ), + children: subcategory.options!.map((option) { + return CheckboxListTile( + title: Text(option, style: const TextStyle(color: Colors.white)), + value: _isSubcategorySelected(category, subcategory, option), + onChanged: (value) { + setState(() { + _toggleSubcategorySelection(category, subcategory, option, value); + }); + }, + ); + }).toList(), + ); + } + + Widget _buildCheckboxSubcategoryTile( + Category category, Subcategory subcategory) { + bool isDietaryNeedsSubcategory = (category.name == 'Dietary Needs'); + bool isAnyOptionSelected = _isSubcategorySelected(category, subcategory); + bool isGreyedOut = isDietaryNeedsSubcategory && isAnyOptionSelected; + + return CheckboxListTile( + title: Text(subcategory.name, + style: TextStyle(color: isGreyedOut ? Colors.grey : Colors.white)), + value: isAnyOptionSelected, + onChanged: (value) { + setState(() { + if (isDietaryNeedsSubcategory && value != null && value) { + // If it's the "Dietary Needs" subcategory and a new option is being selected + selectedDietaryNeeds.clear(); // Clear all previous selections + selectedDietaryNeeds + .add(subcategory.name); // Add the selected option + } else { + _toggleSubcategorySelection(category, subcategory, null, value); + } + }); + }, + activeColor: isDietaryNeedsSubcategory + ? Colors.grey + : null, // Set the active color to grey for "Dietary Needs" + checkColor: isDietaryNeedsSubcategory + ? Colors.white + : null, // Set the check color to white for "Dietary Needs" + ); + } + + bool _isSubcategorySelected(Category category, Subcategory subcategory, + [String? option]) { + if (category.name == 'Food Types') { + if (option != null) { + return selectedFoodTypes.contains(option); + } else { + return selectedFoodTypes.contains(subcategory.name); + } + } else if (category.name == 'Dietary Needs') { + if (option != null) { + return selectedDietaryNeeds.contains(option); + } else { + return selectedDietaryNeeds.contains(subcategory.name); + } + } + return false; + } + + void _toggleSubcategorySelection( + Category category, Subcategory subcategory, String? option, bool? value) { + if (category.name == 'Food Types') { + if (value != null && value) { + if (option != null) { + selectedFoodTypes.add(option); + } else { + selectedFoodTypes.add(subcategory.name); + } + } else { + if (option != null) { + selectedFoodTypes.remove(option); + } else { + selectedFoodTypes.remove(subcategory.name); + } + } + } else if (category.name == 'Dietary Needs') { + if (value != null && value) { + if (option != null) { + selectedDietaryNeeds.add(option); + } else { + selectedDietaryNeeds.add(subcategory.name); + } + } else { + if (option != null) { + selectedDietaryNeeds.remove(option); + } else { + selectedDietaryNeeds.remove(subcategory.name); + } + } + } + } +} + +class Category { + final String name; + final List subcategories; + + Category(this.name, this.subcategories); +} + +class Subcategory { + final String name; + final List? options; + + Subcategory(this.name, [this.options]); +} diff --git a/lib/widgets/filter_mapping.dart b/lib/widgets/filter_mapping.dart new file mode 100644 index 0000000..d37bf73 --- /dev/null +++ b/lib/widgets/filter_mapping.dart @@ -0,0 +1,40 @@ +Map filterMap = { + 'Baby Food': 'SubCategory.babyFood', + 'Baked Goods': 'SubCategory.bakedGoods', + 'Eggs': 'SubCategory.eggs', + 'Milk': 'SubCategory.milk', + 'Cheese': 'SubCategory.cheese', + 'Butter, Margarine and Spreads': 'SubCategory.butterMargarineAndSpreads', + 'Cream': 'SubCategory.cream', + 'Yoghurt': 'SubCategory.yoghurt', + 'Coffee': 'SubCategory.coffee', + 'Tea': 'SubCategory.tea', + 'Juices': 'SubCategory.juices', + 'Soft Drinks': 'SubCategory.softDrinks', + 'Others': 'SubCategory.others', //Beverage others + 'Canned': 'SubCategory.canned', + 'Rice': 'SubCategory.rice', + 'Pasta': 'SubCategory.pasta', + 'Noodles': 'SubCategory.noodles', + 'Cereal': 'SubCategory.cereal', + 'Condiments': 'SubCategory.Condiments', + 'Baking Needs': 'SubCategory.bakingNeeds', + 'Oil': 'SubCategory.oil', + 'Desserts': 'SubCategory.desserts', + 'Meat': 'SubCategory.meat', + 'Seafoods': 'SubCategory.seafoods', //frozen seafood + 'Fruits': 'SubCategory.fruits', + 'Vegetables': 'SubCategory.vegetables', + 'Chicken': 'SubCategory.chicken', + 'Pork': 'SubCategory.pork', + 'Beef': 'SubCategory.beef', + 'Lamb': 'SubCategory.lamb', + 'Seafood': 'SubCategory.seafood', + 'Others ': 'MainCategory.others', // others others + 'Halal': 'DietaryNeeds.halal', + 'Kosher': 'DietaryNeeds.kosher', + 'Contains Lactose': 'DietaryNeeds.containsLactose', + 'Contains Nuts': 'DietaryNeeds.containsNuts', + 'ContainsSoy': 'DietaryNeeds.containsSoy', + 'None': 'DietaryNeeds.none', +}; diff --git a/lib/widgets/image_input.dart b/lib/widgets/image_input.dart deleted file mode 100644 index b5f5425..0000000 --- a/lib/widgets/image_input.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:image_picker/image_picker.dart'; - -class ImageInput extends StatefulWidget { - ImageInput({ - super.key, - required this.chosenImage, - }); - - final void Function(File image) chosenImage; - - @override - State createState() => _ImageInputState(); -} - -class _ImageInputState extends State { - File? _selectedImage; - - void _takePicture() async { - final imagePicker = ImagePicker(); - final pickedImage = await imagePicker.pickImage( - source: ImageSource.camera, - maxWidth: 600, - ); - if (pickedImage == null) { - return; - } - setState(() { - _selectedImage = File(pickedImage.path); - //print('dogg $_selectedImage'); - widget.chosenImage(_selectedImage!); - }); - } - - @override - Widget build(BuildContext context) { - Widget content = TextButton.icon( - icon: Icon(Icons.camera), - label: Text('Select image'), - onPressed: _takePicture, - ); - - if (_selectedImage != null) { - content = GestureDetector( - onTap: _takePicture, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.file( - _selectedImage!, - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - ), - ), - ); - } - return Container( - decoration: BoxDecoration( - border: Border.all(), - borderRadius: BorderRadius.circular(8), - ), - height: 250, - width: double.infinity, - alignment: Alignment.center, - child: content, - ); - } -} diff --git a/lib/widgets/edit_image_input.dart b/lib/widgets/image_input/edit_image_input.dart similarity index 53% rename from lib/widgets/edit_image_input.dart rename to lib/widgets/image_input/edit_image_input.dart index 2760126..3fcec86 100644 --- a/lib/widgets/edit_image_input.dart +++ b/lib/widgets/image_input/edit_image_input.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; -import '../models/listing.dart'; +import '../../models/listing.dart'; class EditImageInput extends StatefulWidget { const EditImageInput( @@ -19,10 +19,14 @@ class EditImageInput extends StatefulWidget { class _ImageInputState extends State { File? _selectedImage; - void _takePicture() async { + //take picture from camera. + //if users returns without taking a picture, return + //if users takes a picture, display that picture as a + //preview on screen and updates parent widget. + void _takePicture(ImageSource source) async { final imagePicker = ImagePicker(); final pickedImage = await imagePicker.pickImage( - source: ImageSource.camera, + source: source, maxWidth: 600, ); if (pickedImage == null) { @@ -30,18 +34,56 @@ class _ImageInputState extends State { } setState(() { _selectedImage = File(pickedImage.path); - //print('dogg $_selectedImage'); widget.chosenImage(_selectedImage!); }); } + //Show dialog on screen and + //prompt user if he wants to retake + //photo from gallery or camera + void _promptRetakePhoto() { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text("Retake Photo"), + content: const Text( + "Do you want to retake the photo from the gallery or the camera?"), + actions: [ + ButtonBar( + alignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + child: const Text('Gallery'), + onPressed: () { + Navigator.of(context).pop(); + _takePicture(ImageSource.gallery); + }, + ), + TextButton( + child: const Text('Camera'), + onPressed: () { + Navigator.of(context).pop(); + _takePicture(ImageSource.camera); + }, + ), + ], + ) + ], + ); + }, + ); + } + @override Widget build(BuildContext context) { Widget content; + //Allows user to retake photo + //if they wish to if (_selectedImage != null) { content = GestureDetector( - onTap: _takePicture, + onTap: _promptRetakePhoto, child: ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.file( @@ -54,7 +96,7 @@ class _ImageInputState extends State { ); } else { content = GestureDetector( - onTap: _takePicture, + onTap: _promptRetakePhoto, child: ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.network( diff --git a/lib/widgets/image_input/image_input.dart b/lib/widgets/image_input/image_input.dart new file mode 100644 index 0000000..55f30ff --- /dev/null +++ b/lib/widgets/image_input/image_input.dart @@ -0,0 +1,130 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +class ImageInput extends StatefulWidget { + const ImageInput({ + super.key, + required this.chosenImage, + }); + + final void Function(File image) chosenImage; + + @override + State createState() => _ImageInputState(); +} + +class _ImageInputState extends State { + //Stores currently selected image + File? _selectedImage; + + //take picture from camera. + //if users returns without taking a picture, return + //if users takes a picture, display that picture as a + //preview on screen and updates parent widget. + void _takePicture(ImageSource source) async { + final imagePicker = ImagePicker(); + final pickedImage = await imagePicker.pickImage( + source: source, + maxWidth: 600, + ); + if (pickedImage == null) { + return; + } + setState(() { + _selectedImage = File(pickedImage.path); + widget.chosenImage(_selectedImage!); + }); + } + + //Show dialog on screen and + //prompt user if he wants to retake + //photo from gallery or camera + void _promptRetakePhoto() { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text("Retake Photo"), + content: const Text( + "Do you want to retake the photo from the gallery or the camera?"), + actions: [ + ButtonBar( + alignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + child: const Text('Gallery'), + onPressed: () { + Navigator.of(context).pop(); + _takePicture(ImageSource.gallery); + }, + ), + TextButton( + child: const Text('Camera'), + onPressed: () { + Navigator.of(context).pop(); + _takePicture(ImageSource.camera); + }, + ), + ], + ) + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + Widget content = Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton.icon( + icon: const Icon(Icons.camera), + label: const Text('Select from camera'), + onPressed: () { + _takePicture(ImageSource.camera); + }, + ), + const SizedBox( + height: 8, + ), + TextButton.icon( + icon: const Icon(Icons.image), + label: const Text('Select from gallery'), + onPressed: () { + _takePicture(ImageSource.gallery); + }, + ), + ], + ); + + //Allows user to retake photo + //if they wish to + if (_selectedImage != null) { + content = GestureDetector( + onTap: _promptRetakePhoto, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file( + _selectedImage!, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ), + ), + ); + } + return Container( + decoration: BoxDecoration( + border: Border.all(), + borderRadius: BorderRadius.circular(8), + ), + height: 250, + width: double.infinity, + alignment: Alignment.center, + child: content, + ); + } +} diff --git a/lib/widgets/listing_grid_item.dart b/lib/widgets/listing_grid_item.dart index 863b830..609b973 100644 --- a/lib/widgets/listing_grid_item.dart +++ b/lib/widgets/listing_grid_item.dart @@ -19,6 +19,8 @@ class ListingGridItem extends StatelessWidget { return formatter.format(data.expiryDate); } + //UI layout for each grid item + //that is used for grid view @override Widget build(BuildContext context) { return InkWell( @@ -58,21 +60,21 @@ class ListingGridItem extends StatelessWidget { ), ), ), - data.isAvailable + data.expiryDate.isAfter(DateTime.now()) ? Container() : Positioned( bottom: 0, - right: 1, - left: 1, + right: 0, + left: 0, child: Container( alignment: Alignment.center, height: 32, decoration: BoxDecoration( - color: Colors.green.shade400, + color: Colors.red.shade400, borderRadius: BorderRadius.circular(8), ), child: const Text( - 'MARKED AS DONATED', + 'EXPIRED', style: TextStyle( fontWeight: FontWeight.bold, color: Colors.white, @@ -80,21 +82,21 @@ class ListingGridItem extends StatelessWidget { ), ), ), - data.expiryDate.isAfter(DateTime.now()) + data.isAvailable ? Container() : Positioned( bottom: 0, - right: 1, - left: 1, + right: 0, + left: 0, child: Container( alignment: Alignment.center, height: 32, decoration: BoxDecoration( - color: Colors.red.shade400, + color: Colors.green.shade400, borderRadius: BorderRadius.circular(8), ), child: const Text( - 'EXPIRED', + 'MARKED AS DONATED', style: TextStyle( fontWeight: FontWeight.bold, color: Colors.white, diff --git a/lib/widgets/listings_column.dart b/lib/widgets/listings_column.dart new file mode 100644 index 0000000..1045ac8 --- /dev/null +++ b/lib/widgets/listings_column.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +import '../screens/listings_list_screen.dart'; +import '../widgets/filter.dart'; + + +class ListingsColumn extends StatelessWidget { + final int selectedPageIndex; + final String itemName; + final Function addNewListing; + final Function readListings; + + const ListingsColumn({ + super.key, + required this.selectedPageIndex, + required this.itemName, + required this.addNewListing, + required this.readListings, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + selectedPageIndex == 0 + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Row( + children: [ + const SizedBox( + width: 16, + ), + itemName == '' + ? const Text( + 'All Listings', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ) + : Expanded( + child: SizedBox( + width: double.infinity, + child: Text( + 'Searched for: $itemName', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ), + ], + ), + ), + Container( + margin: const EdgeInsets.all(8), + child: ElevatedButton.icon( + onPressed: () { + showModalBottomSheet( + context: context, + builder: (BuildContext context) { + return const FilterWidget(); + }, + ); + }, + icon: const Icon(Icons.filter_alt), + label: const Text('Filter'), + ), + ), + ], + ) + : Container(), + Expanded( + child: ListingsScreen( + availListings: readListings(itemName), + isYourListing: false, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/loading.dart b/lib/widgets/loading.dart new file mode 100644 index 0000000..84b83d7 --- /dev/null +++ b/lib/widgets/loading.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +class LoadingCircleScreen extends StatelessWidget { + const LoadingCircleScreen({super.key}); + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ); + } +} diff --git a/lib/widgets/location_input.dart b/lib/widgets/location_input.dart deleted file mode 100644 index 901722b..0000000 --- a/lib/widgets/location_input.dart +++ /dev/null @@ -1,146 +0,0 @@ -import 'dart:convert'; -import 'package:flutter/material.dart'; -import 'package:location/location.dart'; -import 'package:http/http.dart' as http; - -import '../models/listing.dart'; - -class LocationInput extends StatefulWidget { - const LocationInput({super.key, required this.chosenLocation}); - - final void Function(UserLocation location) chosenLocation; - - @override - State createState() => _LocationInputState(); -} - -class _LocationInputState extends State { - UserLocation? _pickedLocation; - var _isGettingLocation = false; - - String get locationImage { - if (_pickedLocation == null) { - return ''; - } - final lat = _pickedLocation!.latitude; - final lng = _pickedLocation!.longitude; - return 'https://maps.googleapis.com/maps/api/staticmap?center=$lat,$lng=&zoom=16&size=600x300&maptype=roadmap&markers=color:red%7Clabel:A%7C$lat,$lng&key=AIzaSyBPnh4TzaS8FEO7vnHEdobC0Z6hsJATCoE'; - } - - void _getCurrentLocation() async { - Location location = Location(); - - bool serviceEnabled; - PermissionStatus permissionGranted; - LocationData locationData; - - serviceEnabled = await location.serviceEnabled(); - if (!serviceEnabled) { - serviceEnabled = await location.requestService(); - if (!serviceEnabled) { - return; - } - } - - permissionGranted = await location.hasPermission(); - if (permissionGranted == PermissionStatus.denied) { - permissionGranted = await location.requestPermission(); - if (permissionGranted != PermissionStatus.granted) { - return; - } - } - - setState(() { - _isGettingLocation = true; - }); - - locationData = await location.getLocation(); - final lat = locationData.latitude; - final lng = locationData.longitude; - - if (lat == null || lng == null) { - return; - } //TODO add error handling - - final url = Uri.parse( - 'https://maps.googleapis.com/maps/api/geocode/json?latlng=$lat,$lng&key=AIzaSyBPnh4TzaS8FEO7vnHEdobC0Z6hsJATCoE'); - - final response = await http.get(url); - final resData = json.decode(response.body); - final address = resData['results'][0]['formatted_address']; - - setState(() { - _pickedLocation = UserLocation( - latitude: lat, - longitude: lng, - address: address, - ); - _isGettingLocation = false; - widget.chosenLocation(_pickedLocation!); - }); - } - - @override - Widget build(BuildContext context) { - Widget previewContent = Text('Get location'); - - if (_pickedLocation != null) { - previewContent = Stack( - children: [ - Container( - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - locationImage, - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - ), - ), - ), - Container( - padding: const EdgeInsets.all(5.0), - alignment: Alignment.bottomCenter, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withAlpha(0), - Colors.black12, - Colors.black45 - ], - ), - ), - child: Text( - _pickedLocation!.address, - style: TextStyle(color: Colors.white, fontSize: 20.0), - ), - ), - ], - ); - } - - if (_isGettingLocation) { - previewContent = const CircularProgressIndicator(); - } - - return Column( - children: [ - Container( - alignment: Alignment.center, - height: 170, - width: double.infinity, - decoration: BoxDecoration( - border: Border.all(), borderRadius: BorderRadius.circular(8)), - child: previewContent, - ), - TextButton.icon( - onPressed: _getCurrentLocation, - icon: Icon(Icons.map), - label: Text('Get location'), - ), - ], - ); - } -} diff --git a/lib/widgets/location_input/edit_location_input.dart b/lib/widgets/location_input/edit_location_input.dart new file mode 100644 index 0000000..39277c8 --- /dev/null +++ b/lib/widgets/location_input/edit_location_input.dart @@ -0,0 +1,312 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:foodbridge_project/api_key.dart'; +import 'package:location/location.dart' as loc; +import '../../models/listing.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:flutter_google_places/flutter_google_places.dart'; +import 'package:google_maps_webservice/places.dart'; + +class EditLocationInput extends StatefulWidget { + const EditLocationInput( + {super.key, required this.chosenLocation, required this.listing}); + + final Listing listing; + final void Function(UserLocation? location) chosenLocation; + + @override + State createState() => _EditLocationInputState(); +} + +class _EditLocationInputState extends State { + final TextEditingController _addressController = TextEditingController(); + UserLocation? _pickedLocation; + var _isGettingLocation = false; + Widget? previewContent; + + @override + void dispose() { + _addressController.dispose(); + super.dispose(); + } + + //Use reverse geocoding to get new snapshot image of map + String get newLocationImage { + if (_pickedLocation == null) { + return ''; + } + final lat = _pickedLocation!.latitude; + final lng = _pickedLocation!.longitude; + return 'https://maps.googleapis.com/maps/api/staticmap?center=$lat,$lng=&zoom=16&size=600x300&maptype=roadmap&markers=color:red%7Clabel:A%7C$lat,$lng&key=$googMapsKey'; + } + + //Use reverse geocoding to get old/ original snapshot image of map + String get oldLocationImage { + final oldLat = widget.listing.lat; + final oldLng = widget.listing.lng; + return 'https://maps.googleapis.com/maps/api/staticmap?center=$oldLat,$oldLng=&zoom=16&size=600x300&maptype=roadmap&markers=color:red%7Clabel:A%7C$oldLat,$oldLng&key=$googMapsKey'; + } + + @override + void initState() { + //Initialise preview content to picture of + //original location selected + _addressController.text = widget.listing.address; + + previewContent = Stack( + children: [ + Container( + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + oldLocationImage, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ), + ), + ), + Container( + padding: const EdgeInsets.all(5.0), + alignment: Alignment.bottomCenter, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withAlpha(0), + Colors.black12, + Colors.black45 + ], + ), + ), + child: Text( + widget.listing.address, + style: const TextStyle(color: Colors.white, fontSize: 20.0), + ), + ), + ], + ); + super.initState(); + } + + //Uses Google Places API to auto complete + //searched addresses or postal codes + Future showGoogleAutoComplete() async { + Prediction? prediction = await PlacesAutocomplete.show( + offset: 0, + radius: 100, + strictbounds: false, + //Singapore region + region: 'sg', + context: context, + apiKey: placesKey, + mode: Mode.overlay, + //language = engilsh + language: 'en', + //types returned by search + types: [ + "neighborhood", + "postal_code", + "street_address" + ], + hint: "Search address or postal code", // Update the hint text + components: [Component(Component.country, 'sg')], + ); + + if (prediction == null) { + return null; + } else { + return prediction; + } + } + + //Extract location data from selected 'prediction'/ suggestion + void _getLatLng(Prediction prediction) async { + //an instance of the GoogleMapsPlaces class is created and assigned + //to the _places variable. The GoogleMapsPlaces class is part of + // the google_maps_webservice package and is used for making requests + //to the Google Maps Places API. The apiKey parameter is provided to authenticate + // the API requests using the specified API key. + GoogleMapsPlaces _places = GoogleMapsPlaces(apiKey: placesKey); + + //a request is made to the Google Places API to retrieve detailed information + //about a place using its unique place ID. The response is stored in the detail + //variable, which will contain the detailed information such as the place's + // address, coordinates, and other relevant data. + PlacesDetailsResponse detail = + await _places.getDetailsByPlaceId(prediction.placeId!); + + double lat = detail.result.geometry!.location.lat; + double lng = detail.result.geometry!.location.lng; + String address = prediction.description!; + + //update state and update location parameters in parent widget + setState(() { + _addressController.text = address; + _pickedLocation = UserLocation( + latitude: lat, + longitude: lng, + address: address, + addressImageUrl: 'https://maps.googleapis.com/maps/api/staticmap?center=$lat,$lng=&zoom=16&size=600x300&maptype=roadmap&markers=color:red%7Clabel:A%7C$lat,$lng&key=$googMapsKey', + ); + _isGettingLocation = false; + widget.chosenLocation(_pickedLocation!); + }); + } + + //Check for user permission to access location + //Retrieves latitude and longitude of current location + void _getCurrentLocation() async { + loc.Location location = loc.Location(); + + bool serviceEnabled; + loc.PermissionStatus permissionGranted; + loc.LocationData locationData; + + //Check if location is turned on + serviceEnabled = await location.serviceEnabled(); + if (!serviceEnabled) { + serviceEnabled = await location.requestService(); + if (!serviceEnabled) { + return; + } + } + + //check if permission is given + permissionGranted = await location.hasPermission(); + if (permissionGranted == loc.PermissionStatus.denied) { + permissionGranted = await location.requestPermission(); + if (permissionGranted != loc.PermissionStatus.granted) { + return; + } + } + + setState(() { + _isGettingLocation = true; + }); + + //retrieve location using google maps API + locationData = await location.getLocation(); + final lat = locationData.latitude; + final lng = locationData.longitude; + + if (lat == null || lng == null) { + return; + } + + //The given code snippet creates a URL for making a request to the Google Geocoding API. + //It includes the latitude and longitude coordinates of a location and an API key for + //authentication. The Uri.parse() function converts the URL string into a Uri object, + //which can be used to interact with the API and extract information from the URL. + final url = Uri.parse( + 'https://maps.googleapis.com/maps/api/geocode/json?latlng=$lat,$lng&key=AIzaSyBPnh4TzaS8FEO7vnHEdobC0Z6hsJATCoE'); + + //an HTTP GET request is made to the specified URL using the http package. The response + //from the request is obtained and its body is decoded from JSON format using the + //json.decode() function. The resulting data is then accessed to extract the formatted address + //of the location from the response. + final response = await http.get(url); + final resData = json.decode(response.body); + final address = resData['results'][0]['formatted_address']; + + //update state and update location parameters in parent widget + setState(() { + _pickedLocation = UserLocation( + latitude: lat, + longitude: lng, + address: address, + addressImageUrl: 'https://maps.googleapis.com/maps/api/staticmap?center=$lat,$lng=&zoom=16&size=600x300&maptype=roadmap&markers=color:red%7Clabel:A%7C$lat,$lng&key=$googMapsKey', + ); + _isGettingLocation = false; + _addressController.clear(); + _addressController.text = address; + widget.chosenLocation(_pickedLocation!); + }); + } + + @override + Widget build(BuildContext context) { + if (_pickedLocation != null) { + previewContent = Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + newLocationImage, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ), + ), + Container( + padding: const EdgeInsets.all(5.0), + alignment: Alignment.bottomCenter, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withAlpha(0), + Colors.black12, + Colors.black45 + ], + ), + ), + child: Text( + _pickedLocation!.address, + style: const TextStyle(color: Colors.white, fontSize: 20.0), + ), + ), + ], + ); + } + + if (_isGettingLocation) { + previewContent = const CircularProgressIndicator(); + } + + return Column( + children: [ + Container( + alignment: Alignment.center, + height: 170, + width: double.infinity, + decoration: BoxDecoration( + border: Border.all(), borderRadius: BorderRadius.circular(8)), + child: previewContent, + ), + TextField( + readOnly: true, + controller: _addressController, + decoration: InputDecoration( + labelText: 'Address', + suffixIcon: IconButton( + icon: Icon(Icons.clear), + onPressed: () { + setState(() { + _pickedLocation = null; + previewContent = const Text('Get location'); + _addressController.clear(); + widget.chosenLocation(_pickedLocation); + }); + }, + ), + ), + onTap: () async { + Prediction? prediction = await showGoogleAutoComplete(); + if (prediction != null) { + _getLatLng(prediction); + } + }, + ), + TextButton.icon( + onPressed: _getCurrentLocation, + icon: const Icon(Icons.map), + label: const Text('Get location'), + ), + ], + ); + } +} diff --git a/lib/widgets/location_input/location_input.dart b/lib/widgets/location_input/location_input.dart new file mode 100644 index 0000000..1790901 --- /dev/null +++ b/lib/widgets/location_input/location_input.dart @@ -0,0 +1,262 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:foodbridge_project/api_key.dart'; +import 'package:location/location.dart' as loc; +import 'package:http/http.dart' as http; +import '../../models/listing.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:flutter_google_places/flutter_google_places.dart'; +import 'package:google_maps_webservice/places.dart'; + +class LocationInput extends StatefulWidget { + const LocationInput({super.key, required this.chosenLocation}); + + final void Function(UserLocation? location) chosenLocation; + + @override + State createState() => _LocationInputState(); +} + +class _LocationInputState extends State { + Widget previewContent = const Text('Get location'); + UserLocation? _pickedLocation; + var _isGettingLocation = false; + + final TextEditingController _addressController = TextEditingController(); + + @override + void dispose() { + _addressController.dispose(); + super.dispose(); + } + + //Use reverse geocoding to get snapshot image of map + String get locationImage { + if (_pickedLocation == null) { + return ''; + } + final lat = _pickedLocation!.latitude; + final lng = _pickedLocation!.longitude; + return 'https://maps.googleapis.com/maps/api/staticmap?center=$lat,$lng=&zoom=16&size=600x300&maptype=roadmap&markers=color:red%7Clabel:A%7C$lat,$lng&key=$googMapsKey'; + } + + //Uses Google Places API to auto complete + //searched addresses or postal codes + Future showGoogleAutoComplete() async { + Prediction? prediction = await PlacesAutocomplete.show( + offset: 0, + radius: 100, + strictbounds: false, + //Singapore region + region: 'sg', + context: context, + apiKey: placesKey, + mode: Mode.overlay, + //language = engilsh + language: 'en', + //types returned by search + types: [ + "neighborhood", + "postal_code", + "street_address" + ], + hint: "Search address or postal code", // Update the hint text + //limited to singapore + components: [Component(Component.country, 'sg')], + ); + + if (prediction == null) { + return null; + } else { + return prediction; + } + } + + //Check for user permission to access location + //Retrieves latitude and longitude of current location + void _getCurrentLocation() async { + loc.Location location = loc.Location(); + + bool serviceEnabled; + loc.PermissionStatus permissionGranted; + loc.LocationData locationData; + + //Check if location is turned on + serviceEnabled = await location.serviceEnabled(); + if (!serviceEnabled) { + serviceEnabled = await location.requestService(); + if (!serviceEnabled) { + return; + } + } + + //check if permission is given + permissionGranted = await location.hasPermission(); + if (permissionGranted == loc.PermissionStatus.denied) { + permissionGranted = await location.requestPermission(); + if (permissionGranted != loc.PermissionStatus.granted) { + return; + } + } + + setState(() { + _isGettingLocation = true; + }); + + //retrieve location using google maps API + locationData = await location.getLocation(); + final lat = locationData.latitude; + final lng = locationData.longitude; + + if (lat == null || lng == null) { + return; + } + + //The given code snippet creates a URL for making a request to the Google Geocoding API. + //It includes the latitude and longitude coordinates of a location and an API key for + //authentication. The Uri.parse() function converts the URL string into a Uri object, + //which can be used to interact with the API and extract information from the URL. + final url = Uri.parse( + 'https://maps.googleapis.com/maps/api/geocode/json?latlng=$lat,$lng&key=$googMapsKey'); + + //an HTTP GET request is made to the specified URL using the http package. The response + //from the request is obtained and its body is decoded from JSON format using the + //json.decode() function. The resulting data is then accessed to extract the formatted address + //of the location from the response. + final response = await http.get(url); + final resData = json.decode(response.body); + final address = resData['results'][0]['formatted_address']; + + //update state and update location parameters in parent widget + setState(() { + _pickedLocation = UserLocation( + latitude: lat, + longitude: lng, + address: address, + addressImageUrl: 'https://maps.googleapis.com/maps/api/staticmap?center=$lat,$lng=&zoom=16&size=600x300&maptype=roadmap&markers=color:red%7Clabel:A%7C$lat,$lng&key=$googMapsKey', + ); + _isGettingLocation = false; + _addressController.clear(); + _addressController.text = address; + widget.chosenLocation(_pickedLocation!); + }); + } + + //Extract location data from selected 'prediction'/ suggestion + void _getLatLng(Prediction prediction) async { + //an instance of the GoogleMapsPlaces class is created and assigned + //to the _places variable. The GoogleMapsPlaces class is part of + // the google_maps_webservice package and is used for making requests + //to the Google Maps Places API. The apiKey parameter is provided to authenticate + // the API requests using the specified API key. + GoogleMapsPlaces _places = GoogleMapsPlaces(apiKey: placesKey); + + //a request is made to the Google Places API to retrieve detailed information + //about a place using its unique place ID. The response is stored in the detail + //variable, which will contain the detailed information such as the place's + // address, coordinates, and other relevant data. + PlacesDetailsResponse detail = + await _places.getDetailsByPlaceId(prediction.placeId!); + + double lat = detail.result.geometry!.location.lat; + double lng = detail.result.geometry!.location.lng; + String address = prediction.description!; + + //update state and update location parameters in parent widget + setState(() { + _addressController.text = address; + _pickedLocation = UserLocation( + latitude: lat, + longitude: lng, + address: address, + addressImageUrl: 'https://maps.googleapis.com/maps/api/staticmap?center=$lat,$lng=&zoom=16&size=600x300&maptype=roadmap&markers=color:red%7Clabel:A%7C$lat,$lng&key=$googMapsKey', + ); + _isGettingLocation = false; + widget.chosenLocation(_pickedLocation!); + }); + } + + @override + Widget build(BuildContext context) { + if (_isGettingLocation) { + previewContent = const CircularProgressIndicator(); + } + + if (_pickedLocation != null) { + previewContent = Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + locationImage, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ), + ), + Container( + padding: const EdgeInsets.all(5.0), + alignment: Alignment.bottomCenter, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withAlpha(0), + Colors.black12, + Colors.black45 + ], + ), + ), + child: Text( + _pickedLocation!.address, + style: const TextStyle(color: Colors.white, fontSize: 20.0), + ), + ), + ], + ); + } + + return Column( + children: [ + Container( + alignment: Alignment.center, + height: 170, + width: double.infinity, + decoration: BoxDecoration( + border: Border.all(), borderRadius: BorderRadius.circular(8)), + child: previewContent, + ), + TextField( + readOnly: true, + controller: _addressController, + decoration: InputDecoration( + labelText: 'Address', + suffixIcon: IconButton( + icon: Icon(Icons.clear), + onPressed: () { + setState(() { + _pickedLocation = null; + previewContent = const Text('Get location'); + _addressController.clear(); + widget.chosenLocation(_pickedLocation); + }); + }, + ), + ), + onTap: () async { + Prediction? prediction = await showGoogleAutoComplete(); + if (prediction != null) { + _getLatLng(prediction); + } + }, + ), + TextButton.icon( + onPressed: _getCurrentLocation, + icon: const Icon(Icons.map), + label: const Text('Get location'), + ), + ], + ); + } +} diff --git a/lib/widgets/login_registration/user_image_picker.dart b/lib/widgets/login_registration/user_image_picker.dart new file mode 100644 index 0000000..9620bf9 --- /dev/null +++ b/lib/widgets/login_registration/user_image_picker.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +class UserImagePicker extends StatefulWidget { + const UserImagePicker({ + super.key, + required this.onPickImage, + }); + + final void Function(File pickedImage) onPickImage; + + @override + State createState() { + return _UserImagePickerState(); + } +} + +class _UserImagePickerState extends State { + File? _pickedImageFile; + + void _pickImage() async { + final pickedImage = await ImagePicker().pickImage( + source: ImageSource.camera, + imageQuality: 100, + maxWidth: 150, + ); + + if (pickedImage == null) { + return; + } + + setState(() { + _pickedImageFile = File(pickedImage.path); + }); + + widget.onPickImage(_pickedImageFile!); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + CircleAvatar( + radius: 40, + backgroundColor: Colors.grey, + foregroundImage: + _pickedImageFile != null ? FileImage(_pickedImageFile!) : null, + ), + TextButton.icon( + onPressed: _pickImage, + icon: const Icon(Icons.image), + label: Text( + 'Add Image', + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + ), + ), + ) + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/profile_appbar.dart b/lib/widgets/profile_appbar.dart index 6c1cae9..0f441dc 100644 --- a/lib/widgets/profile_appbar.dart +++ b/lib/widgets/profile_appbar.dart @@ -3,11 +3,9 @@ import 'package:flutter/material.dart'; class ProfileAppBar extends AppBar { ProfileAppBar(BuildContext context, {super.key}) : super( - backgroundColor: Colors.orange, - title: const Text( - 'PROFILE PAGE', - style: TextStyle( - fontWeight: FontWeight.bold, color: Colors.white, fontSize: 24), + title: Text( + 'Profile Page', + style: Theme.of(context).textTheme.titleLarge, ), centerTitle: true, ); diff --git a/lib/widgets/login_registration/utils.dart b/lib/widgets/utils.dart similarity index 100% rename from lib/widgets/login_registration/utils.dart rename to lib/widgets/utils.dart diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 27c5bcb..c4c3b14 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -11,6 +11,7 @@ import firebase_auth import firebase_core import firebase_storage import location +import package_info_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin")) @@ -19,4 +20,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseStoragePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseStoragePlugin")) LocationPlugin.register(with: registry.registrar(forPlugin: "LocationPlugin")) + FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 6502cc0..3c63e9e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -81,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.3+4" + csslib: + dependency: transitive + description: + name: csslib + sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb" + url: "https://pub.dev" + source: hosted + version: "1.0.0" cupertino_icons: dependency: "direct main" description: @@ -105,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: ed5337a5660c506388a9f012be0288fb38b49020ce2b45fe1f8b8323fe429f99 + url: "https://pub.dev" + source: hosted + version: "2.0.2" firebase_app_check: dependency: "direct main" description: @@ -206,6 +222,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_google_places: + dependency: "direct main" + description: + name: flutter_google_places + sha256: e9fb23ceacdc7359aa759627550146f7fd3ae6c067d16f39f3c50d8feebf4809 + url: "https://pub.dev" + source: hosted + version: "0.3.0" flutter_lints: dependency: "direct dev" description: @@ -222,6 +246,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.15" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: b83ac5827baadefd331ea1d85110f34645827ea234ccabf53a655f41901a9bf4 + url: "https://pub.dev" + source: hosted + version: "2.3.6" flutter_test: dependency: "direct dev" description: flutter @@ -232,6 +264,78 @@ packages: description: flutter source: sdk version: "0.0.0" + google_api_headers: + dependency: transitive + description: + name: google_api_headers + sha256: b27a55935d5c51cedda8a925f5df8388cc327c94a47fef5a4335e8707e089878 + url: "https://pub.dev" + source: hosted + version: "1.6.0" + google_maps: + dependency: transitive + description: + name: google_maps + sha256: "555d5d736339b0478e821167ac521c810d7b51c3b2734e6802a9f046b64ea37a" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_maps_flutter: + dependency: "direct main" + description: + name: google_maps_flutter + sha256: "7b417a64ee7a060f42cf44d8c274d3b562423f6fe57d2911b7b536857c0d8eb6" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + google_maps_flutter_android: + dependency: transitive + description: + name: google_maps_flutter_android + sha256: "9512c862df77c1f0fa5f445513dd3c57f5996f0a809dccb74e54b690ee4e3a0f" + url: "https://pub.dev" + source: hosted + version: "2.4.15" + google_maps_flutter_ios: + dependency: transitive + description: + name: google_maps_flutter_ios + sha256: a9462a433bf3ebe60aadcf4906d2d6341a270d69d3e0fcaa8eb2b64699fcfb4f + url: "https://pub.dev" + source: hosted + version: "2.2.3" + google_maps_flutter_platform_interface: + dependency: transitive + description: + name: google_maps_flutter_platform_interface + sha256: "308f0af138fa78e8224d598d46ca182673874d0ef4d754b7157c073b5b4b8e0d" + url: "https://pub.dev" + source: hosted + version: "2.2.7" + google_maps_flutter_web: + dependency: transitive + description: + name: google_maps_flutter_web + sha256: "5f58d7c491240b0074f455e70ce8d9b038f92472559e49e3b611d9f39b8d51a7" + url: "https://pub.dev" + source: hosted + version: "0.5.0+1" + google_maps_webservice: + dependency: "direct main" + description: + name: google_maps_webservice + sha256: d0ae4e4508afd74a3f051565261a3cdbae59db29448f9b6e6beb5674545e1eb7 + url: "https://pub.dev" + source: hosted + version: "0.0.20-nullsafety.5" + html: + dependency: transitive + description: + name: html + sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" + url: "https://pub.dev" + source: hosted + version: "0.15.4" http: dependency: "direct main" description: @@ -304,6 +408,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.7" + js_wrapping: + dependency: transitive + description: + name: js_wrapping + sha256: e385980f7c76a8c1c9a560dfb623b890975841542471eade630b2871d243851c + url: "https://pub.dev" + source: hosted + version: "0.7.4" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + url: "https://pub.dev" + source: hosted + version: "4.8.1" lints: dependency: transitive description: @@ -360,6 +480,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: ceb027f6bc6a60674a233b4a90a7658af1aebdea833da0b5b53c1e9821a78c7b + url: "https://pub.dev" + source: hosted + version: "4.0.2" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "9bc8ba46813a4cc42c66ab781470711781940780fd8beddd0c3da62506d3a6c6" + url: "https://pub.dev" + source: hosted + version: "2.0.1" path: dependency: transitive description: @@ -377,13 +513,29 @@ packages: source: hosted version: "2.1.4" riverpod: - dependency: "direct main" + dependency: transitive description: name: riverpod sha256: "80e48bebc83010d5e67a11c9514af6b44bbac1ec77b4333c8ea65dbc79e2d8ef" url: "https://pub.dev" source: hosted version: "2.3.6" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "2ef8b4e91cb3b55d155e0e34eeae0ac7107974e451495c955ac04ddee8cc21fd" + url: "https://pub.dev" + source: hosted + version: "0.26.0" + sanitize_html: + dependency: transitive + description: + name: sanitize_html + sha256: "0a445f19bbaa196f5a4f93461aa066b94e6e025622eb1e9bc77872a5e25233a5" + url: "https://pub.dev" + source: hosted + version: "2.0.0" sky_engine: dependency: transitive description: flutter @@ -421,6 +573,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + url: "https://pub.dev" + source: hosted + version: "2.1.0" string_scanner: dependency: transitive description: @@ -461,6 +621,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + win32: + dependency: transitive + description: + name: win32 + sha256: "7dacfda1edcca378031db9905ad7d7bd56b29fd1a90b0908b71a52a12c41e36b" + url: "https://pub.dev" + source: hosted + version: "5.0.3" sdks: - dart: ">=3.0.0-0 <4.0.0" + dart: ">=3.0.0 <4.0.0" flutter: ">=3.3.0" diff --git a/pubspec.yaml b/pubspec.yaml index d809743..a4eec1e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,12 +42,15 @@ dependencies: cloud_firestore: ^4.6.0 email_validator: ^2.1.17 intl: ^0.18.1 - riverpod: ^2.3.6 image_picker: ^0.8.7+5 location: ^4.4.0 http: ^0.13.6 firebase_storage: ^11.2.2 firebase_app_check: ^0.1.4+2 + flutter_riverpod: ^2.3.6 + google_maps_flutter: ^2.3.0 + flutter_google_places: ^0.3.0 + google_maps_webservice: ^0.0.20-nullsafety.5 dev_dependencies: