From 4bff849b1348c9ff98d2636aaccc37d767e84017 Mon Sep 17 00:00:00 2001 From: Rafael <1024481@stud.hs-mannheim.de> Date: Mon, 3 Jun 2024 16:35:33 +0200 Subject: [PATCH] Enhanced ConversationsPage --- lib/components/user_tile.dart | 126 ++++++++++++++++++++++++---- lib/constants.dart | 6 ++ lib/models/message.dart | 22 +++-- lib/pages/conversations_page.dart | 15 ++-- lib/pages/edit_profile_page.dart | 8 +- lib/pages/home_page.dart | 2 +- lib/services/chat/chat_service.dart | 62 +++++++++++++- lib/utils/helper.dart | 44 ++++++++++ test/helper_test.dart | 22 ++++- 9 files changed, 267 insertions(+), 40 deletions(-) diff --git a/lib/components/user_tile.dart b/lib/components/user_tile.dart index d224a38..aff7e57 100644 --- a/lib/components/user_tile.dart +++ b/lib/components/user_tile.dart @@ -1,13 +1,21 @@ import 'package:flutter/material.dart'; +import '../models/message.dart'; +import '../services/chat/chat_service.dart'; +import '../utils/helper.dart'; + class UserTile extends StatelessWidget { - final String text; + final String headerText; + final String? currentUserId; + final String? otherUserId; final String? profileImageUrl; final void Function()? onTap; const UserTile({ super.key, - required this.text, + required this.headerText, + this.currentUserId, + this.otherUserId, this.profileImageUrl, required this.onTap, }); @@ -22,26 +30,110 @@ class UserTile extends StatelessWidget { borderRadius: BorderRadius.circular(12), ), margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 25), - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(10), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Profile image - if (profileImageUrl != null && profileImageUrl!.isNotEmpty) - CircleAvatar( - backgroundImage: NetworkImage(profileImageUrl!), - radius: 24, - ), - // Icon if profile image is not set - if (profileImageUrl == null || profileImageUrl!.isEmpty) - const Icon(Icons.person), - - const SizedBox(width: 20), - - // user name - Text(text), + buildProfileIcon(), + buildMsgContent(), ], ), ), ); } + + Widget buildProfileIcon() { + return Row( + children: [ + // Profile image + if (profileImageUrl != null && profileImageUrl!.isNotEmpty) + CircleAvatar( + backgroundImage: NetworkImage(profileImageUrl!), + radius: 24, + ), + // Icon if profile image is not set + if (profileImageUrl == null || profileImageUrl!.isEmpty) + const Icon(Icons.person), + + const SizedBox(width: 12), + ], + ); + } + + Widget buildMsgContent() { + String msgDateString = ''; + String msgContent = ''; + bool? outgoing; + String chatRoomID = getCompoundId([currentUserId ?? '', otherUserId ?? '']); + + return FutureBuilder( + future: ChatService().getLastMessage(chatRoomID), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const CircularProgressIndicator(); + } else if (snapshot.hasError) { + return Text( + 'Error: ${snapshot.error}', + style: const TextStyle(color: Colors.red), + ); + } else if (!snapshot.hasData || snapshot.data == null) { + return const Text('No messages yet'); + } else { + Message lastMessage = snapshot.data!; + msgDateString = formatTimestamp(lastMessage.timestamp); + + if (lastMessage.senderID == currentUserId) { + msgContent = 'Me: ${lastMessage.message}'; + outgoing = true; + } else if (lastMessage.senderID == otherUserId) { + msgContent = lastMessage.message; + outgoing = false; + } + + return Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + // user name + Text( + headerText, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text(msgContent, overflow: TextOverflow.ellipsis, maxLines: 1), + Row( + children: [ + if (outgoing == true) + const Icon( + Icons.call_made, + color: Colors.green, + size: 16, + ), + if (outgoing == false) + const Icon( + Icons.call_received, + color: Colors.blue, + size: 16, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + msgDateString, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ], + ), + ], + ), + ); + } + }, + ); + } } diff --git a/lib/constants.dart b/lib/constants.dart index 7e36b6b..ea6539d 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -27,6 +27,12 @@ class Constants { static const String dbFieldLocationSubLocality = 'subLocality'; static const String dbFieldLocationPostalCode = 'postalCode'; + static const String dbFieldMessageText = 'message'; + static const String dbFieldMessageReceiverId = 'receiverID'; + static const String dbFieldMessageSenderId = 'senderID'; + static const String dbFieldMessageSenderEmail = 'senderEmail'; + static const String dbFieldMessageTimestamp = 'timestamp'; + static const String dbFieldUsersID = 'uid'; static const String dbFieldUsersEmail = 'email'; static const String dbFieldUsersName = 'name'; diff --git a/lib/models/message.dart b/lib/models/message.dart index 78920ac..24567e7 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -1,4 +1,5 @@ import 'package:cloud_firestore/cloud_firestore.dart'; +import '../constants.dart'; class Message { final String senderID; @@ -18,11 +19,22 @@ class Message { // convert to a map Map toMap() { return { - 'senderID': senderID, - 'senderEmail': senderEmail, - 'receiverID': receiverID, - 'message': message, - 'timestamp': timestamp, + Constants.dbFieldMessageSenderId: senderID, + Constants.dbFieldMessageSenderEmail: senderEmail, + Constants.dbFieldMessageReceiverId: receiverID, + Constants.dbFieldMessageText: message, + Constants.dbFieldMessageTimestamp: timestamp, }; } + + // create Message from map + factory Message.fromMap(Map map) { + return Message( + senderID: map[Constants.dbFieldMessageSenderId], + senderEmail: map[Constants.dbFieldMessageSenderEmail], + receiverID: map[Constants.dbFieldMessageReceiverId], + message: map[Constants.dbFieldMessageText], + timestamp: map[Constants.dbFieldMessageTimestamp], + ); + } } diff --git a/lib/pages/conversations_page.dart b/lib/pages/conversations_page.dart index b4cba08..e45a17b 100644 --- a/lib/pages/conversations_page.dart +++ b/lib/pages/conversations_page.dart @@ -9,14 +9,13 @@ import 'chat_page.dart'; class ConversationsPage extends StatelessWidget { ConversationsPage({super.key}); - // auth service final AuthService _authService = AuthService(); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Conversations'), + title: const Text('Your Chat Contacts'), backgroundColor: Colors.transparent, foregroundColor: Colors.grey.shade800, elevation: 0, @@ -54,8 +53,8 @@ class ConversationsPage extends StatelessWidget { } return ListView( children: matchedUsers - .map( - (userData) => _buildUserListItem(userData, context)) + .map((userData) => + _buildUserListItem(currentUser.uid, userData, context)) .toList(), ); } else { @@ -66,10 +65,12 @@ class ConversationsPage extends StatelessWidget { } // build individual user list item - Widget _buildUserListItem( - Map userData, BuildContext context) { + Widget _buildUserListItem(String currentUserId, Map userData, + BuildContext context) { return UserTile( - text: userData[Constants.dbFieldUsersEmail], + headerText: userData[Constants.dbFieldUsersName], + currentUserId: currentUserId, + otherUserId: userData[Constants.dbFieldUsersID], profileImageUrl: userData[Constants.dbFieldUsersProfilePic], onTap: () { // tapped on a user -> go to chat page diff --git a/lib/pages/edit_profile_page.dart b/lib/pages/edit_profile_page.dart index 0f45013..4d296d9 100644 --- a/lib/pages/edit_profile_page.dart +++ b/lib/pages/edit_profile_page.dart @@ -229,15 +229,13 @@ class EditProfilePageState extends State { CircleAvatar( radius: 50, backgroundImage: _profileImage != null - ? FileImage(_profileImage!) as ImageProvider - : _webProfileImage != null + ? FileImage(_profileImage!) + : (_webProfileImage != null ? MemoryImage(_webProfileImage!) - as ImageProvider // web specific : (widget.userData.profilePictureUrl != null && widget.userData.profilePictureUrl!.isNotEmpty ? NetworkImage(widget.userData.profilePictureUrl!) - as ImageProvider - : null), + : null as ImageProvider?)), child: ClipOval( child: _profileImage == null && _webProfileImage == null && diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 4cb0756..182f608 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -59,7 +59,7 @@ class HomePage extends StatelessWidget { if (userData[Constants.dbFieldUsersEmail] != _authService.getCurrentUser()!.email) { return UserTile( - text: userData[Constants.dbFieldUsersEmail], + headerText: userData[Constants.dbFieldUsersEmail], onTap: () { // tapped on a user -> go to chat page Navigator.push( diff --git a/lib/services/chat/chat_service.dart b/lib/services/chat/chat_service.dart index 311becc..e40c733 100644 --- a/lib/services/chat/chat_service.dart +++ b/lib/services/chat/chat_service.dart @@ -6,7 +6,6 @@ import '../../utils/helper.dart'; import '../../models/message.dart'; class ChatService { - // get instance of firestore and auth final FirebaseFirestore _firestore = FirebaseFirestore.instance; final FirebaseAuth _auth = FirebaseAuth.instance; @@ -29,12 +28,67 @@ class ChatService { // construct chat room ID for the two users String chatRoomID = getCompoundId([currentUserID, receiverID]); + // create batch to add message and update last message details in one step + WriteBatch batch = _firestore.batch(); + // add new message to database - await _firestore + var newMessageRef = _firestore .collection(Constants.dbCollectionChatRooms) .doc(chatRoomID) .collection(Constants.dbCollectionMessages) - .add(newMessage.toMap()); + .doc(); // doc generates a random ID for the message + batch.set(newMessageRef, newMessage.toMap()); + + // update last message details in chat room + var chatRoomRef = + _firestore.collection(Constants.dbCollectionChatRooms).doc(chatRoomID); + + // add to batch above + batch.set( + chatRoomRef, + { + Constants.dbFieldMessageSenderId: currentUserID, + Constants.dbFieldMessageSenderEmail: currentUserEmail, + Constants.dbFieldMessageTimestamp: timestamp, + }, + SetOptions(merge: true)); + + // commit batch + await batch.commit(); + } + + /// Retrieves the last message of a chatroom. + Future getLastMessage(String chatRoomID) async { + String senderID = ''; + String senderEmail = ''; + String receiverID = ''; + String message = ''; + Timestamp timestamp = Timestamp.fromDate(DateTime.utc(1970, 01, 01)); + + QuerySnapshot messageSnapshot = await _firestore + .collection(Constants.dbCollectionChatRooms) + .doc(chatRoomID) + .collection(Constants.dbCollectionMessages) + .orderBy(Constants.dbFieldMessageTimestamp, descending: true) + .limit(1) + .get(); + + DocumentSnapshot lastMessageDoc = messageSnapshot.docs.first; + if (lastMessageDoc.exists) { + senderID = lastMessageDoc[Constants.dbFieldMessageSenderId]; + senderEmail = lastMessageDoc[Constants.dbFieldMessageSenderEmail]; + receiverID = lastMessageDoc[Constants.dbFieldMessageReceiverId]; + message = lastMessageDoc[Constants.dbFieldMessageText]; + timestamp = lastMessageDoc[Constants.dbFieldMessageTimestamp]; + } + + return Message( + senderID: senderID, + senderEmail: senderEmail, + receiverID: receiverID, + message: message, + timestamp: timestamp, + ); } // get messages @@ -45,7 +99,7 @@ class ChatService { .collection(Constants.dbCollectionChatRooms) .doc(chatRoomID) .collection(Constants.dbCollectionMessages) - .orderBy('timestamp', descending: false) + .orderBy(Constants.dbFieldMessageTimestamp, descending: false) .snapshots(); } } diff --git a/lib/utils/helper.dart b/lib/utils/helper.dart index 4cb3f4b..c950e05 100644 --- a/lib/utils/helper.dart +++ b/lib/utils/helper.dart @@ -1,3 +1,4 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -17,6 +18,49 @@ String getCompoundId(List ids) { return ids.join('_'); } +/// Returns a date format of '$weekday, $day. $month $year $hours:$minutes'. +/// For example: Sat. 3 Jun. 2024 15:03. +/// If any errors occur, an empty string will be returned. +String formatTimestamp(Timestamp timestamp) { + try { + const List weekdays = [ + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + 'Sun' + ]; + const List months = [ + 'Jan.', + 'Feb.', + 'Mar.', + 'Apr.', + 'May', + 'Jun.', + 'Jul.', + 'Aug.', + 'Sep.', + 'Oct.', + 'Nov.', + 'Dec.' + ]; + + DateTime dateTime = timestamp.toDate(); + String weekday = weekdays[dateTime.weekday - 1]; + int day = dateTime.day; + String month = months[dateTime.month - 1]; + int year = dateTime.year; + int hour = dateTime.hour; + int minute = dateTime.minute; + + return '$weekday, $day. $month $year ${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}'; + } catch (e) { + return ''; + } +} + /// /// Get the [displayName] of our own Enumerations. /// diff --git a/test/helper_test.dart b/test/helper_test.dart index eb15677..2f7ed80 100644 --- a/test/helper_test.dart +++ b/test/helper_test.dart @@ -1,3 +1,4 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cofounderella/utils/helper.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -22,7 +23,7 @@ void main() { }); test('returns correct compound ID for empty elements', () { - List ids = ['id1','','id2','']; + List ids = ['id1', '', 'id2', '']; String result = getCompoundId(ids); expect(result, '__id1_id2'); }); @@ -78,4 +79,23 @@ void main() { expect(result, false); }); }); + + group('formatTimestamp', () { + test('formatTimestamp returns the expected formatted date for 01.01.1970', + () { + Timestamp timestamp = Timestamp.fromDate(DateTime(1970, 1, 1, 23, 59)); + String expectedFormattedDate = 'Thu, 1. Jan. 1970 23:59'; + String formattedDate = formatTimestamp(timestamp); + expect(formattedDate, expectedFormattedDate); + }); + + test( + 'formatTimestamp returns the expected formatted date for another timestamp', + () { + Timestamp timestamp = Timestamp.fromDate(DateTime(2023, 8, 15)); + String expectedFormattedDate = 'Tue, 15. Aug. 2023 00:00'; + String formattedDate = formatTimestamp(timestamp); + expect(formattedDate, expectedFormattedDate); + }); + }); }