Enhanced ConversationsPage

master
Rafael 2024-06-03 16:35:33 +02:00
parent 6aa821a913
commit 4bff849b13
9 changed files with 267 additions and 40 deletions

View File

@ -1,13 +1,21 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/message.dart';
import '../services/chat/chat_service.dart';
import '../utils/helper.dart';
class UserTile extends StatelessWidget { class UserTile extends StatelessWidget {
final String text; final String headerText;
final String? currentUserId;
final String? otherUserId;
final String? profileImageUrl; final String? profileImageUrl;
final void Function()? onTap; final void Function()? onTap;
const UserTile({ const UserTile({
super.key, super.key,
required this.text, required this.headerText,
this.currentUserId,
this.otherUserId,
this.profileImageUrl, this.profileImageUrl,
required this.onTap, required this.onTap,
}); });
@ -22,26 +30,110 @@ class UserTile extends StatelessWidget {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 25), margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 25),
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(10),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Profile image buildProfileIcon(),
if (profileImageUrl != null && profileImageUrl!.isNotEmpty) buildMsgContent(),
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),
], ],
), ),
), ),
); );
} }
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<Message?>(
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,
),
),
],
),
],
),
);
}
},
);
}
} }

View File

@ -27,6 +27,12 @@ class Constants {
static const String dbFieldLocationSubLocality = 'subLocality'; static const String dbFieldLocationSubLocality = 'subLocality';
static const String dbFieldLocationPostalCode = 'postalCode'; 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 dbFieldUsersID = 'uid';
static const String dbFieldUsersEmail = 'email'; static const String dbFieldUsersEmail = 'email';
static const String dbFieldUsersName = 'name'; static const String dbFieldUsersName = 'name';

View File

@ -1,4 +1,5 @@
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import '../constants.dart';
class Message { class Message {
final String senderID; final String senderID;
@ -18,11 +19,22 @@ class Message {
// convert to a map // convert to a map
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'senderID': senderID, Constants.dbFieldMessageSenderId: senderID,
'senderEmail': senderEmail, Constants.dbFieldMessageSenderEmail: senderEmail,
'receiverID': receiverID, Constants.dbFieldMessageReceiverId: receiverID,
'message': message, Constants.dbFieldMessageText: message,
'timestamp': timestamp, Constants.dbFieldMessageTimestamp: timestamp,
}; };
} }
// create Message from map
factory Message.fromMap(Map<String, dynamic> map) {
return Message(
senderID: map[Constants.dbFieldMessageSenderId],
senderEmail: map[Constants.dbFieldMessageSenderEmail],
receiverID: map[Constants.dbFieldMessageReceiverId],
message: map[Constants.dbFieldMessageText],
timestamp: map[Constants.dbFieldMessageTimestamp],
);
}
} }

View File

@ -9,14 +9,13 @@ import 'chat_page.dart';
class ConversationsPage extends StatelessWidget { class ConversationsPage extends StatelessWidget {
ConversationsPage({super.key}); ConversationsPage({super.key});
// auth service
final AuthService _authService = AuthService(); final AuthService _authService = AuthService();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Conversations'), title: const Text('Your Chat Contacts'),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
foregroundColor: Colors.grey.shade800, foregroundColor: Colors.grey.shade800,
elevation: 0, elevation: 0,
@ -54,8 +53,8 @@ class ConversationsPage extends StatelessWidget {
} }
return ListView( return ListView(
children: matchedUsers children: matchedUsers
.map<Widget>( .map<Widget>((userData) =>
(userData) => _buildUserListItem(userData, context)) _buildUserListItem(currentUser.uid, userData, context))
.toList(), .toList(),
); );
} else { } else {
@ -66,10 +65,12 @@ class ConversationsPage extends StatelessWidget {
} }
// build individual user list item // build individual user list item
Widget _buildUserListItem( Widget _buildUserListItem(String currentUserId, Map<String, dynamic> userData,
Map<String, dynamic> userData, BuildContext context) { BuildContext context) {
return UserTile( return UserTile(
text: userData[Constants.dbFieldUsersEmail], headerText: userData[Constants.dbFieldUsersName],
currentUserId: currentUserId,
otherUserId: userData[Constants.dbFieldUsersID],
profileImageUrl: userData[Constants.dbFieldUsersProfilePic], profileImageUrl: userData[Constants.dbFieldUsersProfilePic],
onTap: () { onTap: () {
// tapped on a user -> go to chat page // tapped on a user -> go to chat page

View File

@ -229,15 +229,13 @@ class EditProfilePageState extends State<EditProfilePage> {
CircleAvatar( CircleAvatar(
radius: 50, radius: 50,
backgroundImage: _profileImage != null backgroundImage: _profileImage != null
? FileImage(_profileImage!) as ImageProvider ? FileImage(_profileImage!)
: _webProfileImage != null : (_webProfileImage != null
? MemoryImage(_webProfileImage!) ? MemoryImage(_webProfileImage!)
as ImageProvider // web specific
: (widget.userData.profilePictureUrl != null && : (widget.userData.profilePictureUrl != null &&
widget.userData.profilePictureUrl!.isNotEmpty widget.userData.profilePictureUrl!.isNotEmpty
? NetworkImage(widget.userData.profilePictureUrl!) ? NetworkImage(widget.userData.profilePictureUrl!)
as ImageProvider : null as ImageProvider<Object>?)),
: null),
child: ClipOval( child: ClipOval(
child: _profileImage == null && child: _profileImage == null &&
_webProfileImage == null && _webProfileImage == null &&

View File

@ -59,7 +59,7 @@ class HomePage extends StatelessWidget {
if (userData[Constants.dbFieldUsersEmail] != if (userData[Constants.dbFieldUsersEmail] !=
_authService.getCurrentUser()!.email) { _authService.getCurrentUser()!.email) {
return UserTile( return UserTile(
text: userData[Constants.dbFieldUsersEmail], headerText: userData[Constants.dbFieldUsersEmail],
onTap: () { onTap: () {
// tapped on a user -> go to chat page // tapped on a user -> go to chat page
Navigator.push( Navigator.push(

View File

@ -6,7 +6,6 @@ import '../../utils/helper.dart';
import '../../models/message.dart'; import '../../models/message.dart';
class ChatService { class ChatService {
// get instance of firestore and auth
final FirebaseFirestore _firestore = FirebaseFirestore.instance; final FirebaseFirestore _firestore = FirebaseFirestore.instance;
final FirebaseAuth _auth = FirebaseAuth.instance; final FirebaseAuth _auth = FirebaseAuth.instance;
@ -29,12 +28,67 @@ class ChatService {
// construct chat room ID for the two users // construct chat room ID for the two users
String chatRoomID = getCompoundId([currentUserID, receiverID]); 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 // add new message to database
await _firestore var newMessageRef = _firestore
.collection(Constants.dbCollectionChatRooms) .collection(Constants.dbCollectionChatRooms)
.doc(chatRoomID) .doc(chatRoomID)
.collection(Constants.dbCollectionMessages) .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<Message> 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 // get messages
@ -45,7 +99,7 @@ class ChatService {
.collection(Constants.dbCollectionChatRooms) .collection(Constants.dbCollectionChatRooms)
.doc(chatRoomID) .doc(chatRoomID)
.collection(Constants.dbCollectionMessages) .collection(Constants.dbCollectionMessages)
.orderBy('timestamp', descending: false) .orderBy(Constants.dbFieldMessageTimestamp, descending: false)
.snapshots(); .snapshots();
} }
} }

View File

@ -1,3 +1,4 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -17,6 +18,49 @@ String getCompoundId(List<String> ids) {
return ids.join('_'); 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<String> weekdays = [
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
'Sun'
];
const List<String> 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. /// Get the [displayName] of our own Enumerations.
/// ///

View File

@ -1,3 +1,4 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:cofounderella/utils/helper.dart'; import 'package:cofounderella/utils/helper.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@ -22,7 +23,7 @@ void main() {
}); });
test('returns correct compound ID for empty elements', () { test('returns correct compound ID for empty elements', () {
List<String> ids = ['id1','','id2','']; List<String> ids = ['id1', '', 'id2', ''];
String result = getCompoundId(ids); String result = getCompoundId(ids);
expect(result, '__id1_id2'); expect(result, '__id1_id2');
}); });
@ -78,4 +79,23 @@ void main() {
expect(result, false); 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);
});
});
} }