Enhanced ConversationsPage
parent
6aa821a913
commit
4bff849b13
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 &&
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
||||||
///
|
///
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue