Flutter'da Firestore ile Gerçek Zamanlı Chat Uygulaması
Firestore'un gerçek zamanlı listener'ları, chat uygulamaları için mükemmel bir altyapı sağlar. Bu yazıda veri modelinden UI'ya kadar eksiksiz bir mesajlaşma uygulamasının mimarisini inceleyeceğiz.
Firestore Veri Modeli
// Koleksiyon yapısı:
// /users/{userId}
// - displayName, photoUrl, lastSeen, isOnline
//
// /conversations/{conversationId}
// - participantIds: [uid1, uid2]
// - lastMessage: {text, senderId, createdAt}
// - unreadCount: {uid1: 0, uid2: 3}
//
// /conversations/{conversationId}/messages/{messageId}
// - senderId, text, imageUrl, type (text|image|file)
// - createdAt, status (sending|sent|delivered|read)
// - readBy: [uid1, uid2]
class ChatMessage {
final String id;
final String senderId;
final String? text;
final String? imageUrl;
final MessageType type;
final DateTime createdAt;
final MessageStatus status;
final List<String> readBy;
const ChatMessage({
required this.id,
required this.senderId,
this.text,
this.imageUrl,
required this.type,
required this.createdAt,
required this.status,
required this.readBy,
});
factory ChatMessage.fromFirestore(DocumentSnapshot doc) {
final data = doc.data()! as Map<String, dynamic>;
return ChatMessage(
id: doc.id,
senderId: data['senderId'] as String,
text: data['text'] as String?,
imageUrl: data['imageUrl'] as String?,
type: MessageType.values.byName(data['type'] as String),
createdAt: (data['createdAt'] as Timestamp).toDate(),
status: MessageStatus.values.byName(data['status'] as String),
readBy: List<String>.from(data['readBy'] as List),
);
}
Map<String, dynamic> toFirestore() => {
'senderId': senderId,
'text': text,
'imageUrl': imageUrl,
'type': type.name,
'createdAt': FieldValue.serverTimestamp(),
'status': status.name,
'readBy': readBy,
};
}
Chat Repository
@lazySingleton
class ChatRepository {
final FirebaseFirestore _db = FirebaseFirestore.instance;
final FirebaseStorage _storage = FirebaseStorage.instance;
// Konuşmaları dinle
Stream<List<Conversation>> watchConversations(String userId) {
return _db
.collection('conversations')
.where('participantIds', arrayContains: userId)
.orderBy('lastMessage.createdAt', descending: true)
.snapshots()
.map((s) => s.docs.map(Conversation.fromFirestore).toList());
}
// Mesajları gerçek zamanlı dinle
Stream<List<ChatMessage>> watchMessages(String conversationId) {
return _db
.collection('conversations/$conversationId/messages')
.orderBy('createdAt', descending: true)
.limit(50)
.snapshots()
.map((s) => s.docs.map(ChatMessage.fromFirestore).toList());
}
// Mesaj gönder
Future<void> sendMessage({
required String conversationId,
required String senderId,
required String text,
}) async {
final batch = _db.batch();
final msgRef = _db
.collection('conversations/$conversationId/messages')
.doc();
batch.set(msgRef, {
'senderId': senderId,
'text': text,
'type': MessageType.text.name,
'createdAt': FieldValue.serverTimestamp(),
'status': MessageStatus.sent.name,
'readBy': [senderId],
});
// Konuşmayı güncelle
batch.update(
_db.collection('conversations').doc(conversationId),
{
'lastMessage': {
'text': text,
'senderId': senderId,
'createdAt': FieldValue.serverTimestamp(),
},
'unreadCount.${_getOtherUserId(conversationId, senderId)}':
FieldValue.increment(1),
},
);
await batch.commit();
}
// Resim mesajı gönder
Future<void> sendImageMessage({
required String conversationId,
required String senderId,
required File imageFile,
}) async {
// Önce Storage'a yükle
final ref = _storage.ref(
'chat/$conversationId/${DateTime.now().millisecondsSinceEpoch}.jpg',
);
final compressed = await compute(_compressImage, imageFile.readAsBytesSync());
await ref.putData(compressed, SettableMetadata(contentType: 'image/jpeg'));
final url = await ref.getDownloadURL();
// Sonra Firestore'a kaydet
await _db.collection('conversations/$conversationId/messages').add({
'senderId': senderId,
'imageUrl': url,
'type': MessageType.image.name,
'createdAt': FieldValue.serverTimestamp(),
'status': MessageStatus.sent.name,
'readBy': [senderId],
});
}
// Okundu işaretleme
Future<void> markAsRead(String conversationId, String userId) async {
// Okunmamış mesajları toplu güncelle
final unread = await _db
.collection('conversations/$conversationId/messages')
.where('status', whereIn: [MessageStatus.sent.name, MessageStatus.delivered.name])
.where('senderId', isNotEqualTo: userId)
.get();
if (unread.docs.isEmpty) return;
final batch = _db.batch();
for (final doc in unread.docs) {
batch.update(doc.reference, {
'readBy': FieldValue.arrayUnion([userId]),
'status': MessageStatus.read.name,
});
}
batch.update(
_db.collection('conversations').doc(conversationId),
{'unreadCount.$userId': 0},
);
await batch.commit();
}
// Yazıyor durumu (Realtime Database daha uygun ama Firestore da olur)
Future<void> setTypingStatus(
String conversationId, String userId, bool isTyping) {
return _db
.collection('conversations')
.doc(conversationId)
.update({'typing.$userId': isTyping});
}
}
Çevrimiçi Durum Yönetimi
// Firebase Realtime Database — presence için daha uygun
class PresenceService {
final FirebaseDatabase _rtdb = FirebaseDatabase.instance;
final String _userId;
PresenceService(this._userId);
Future<void> initialize() async {
final connectedRef = _rtdb.ref('.info/connected');
final userStatusRef = _rtdb.ref('users/$_userId/status');
connectedRef.onValue.listen((event) {
final connected = event.snapshot.value as bool? ?? false;
if (!connected) return;
// Bağlantı kesilince offline yap
userStatusRef.onDisconnect().set({
'isOnline': false,
'lastSeen': ServerValue.timestamp,
});
// Şimdi online
userStatusRef.set({'isOnline': true, 'lastSeen': ServerValue.timestamp});
});
}
Stream<bool> watchUserOnlineStatus(String userId) {
return _rtdb
.ref('users/$userId/status/isOnline')
.onValue
.map((e) => e.snapshot.value as bool? ?? false);
}
void dispose() {
_rtdb.ref('users/$_userId/status').set({
'isOnline': false,
'lastSeen': ServerValue.timestamp,
});
}
}
Chat UI
class ChatPage extends ConsumerStatefulWidget {
final String conversationId;
const ChatPage({super.key, required this.conversationId});
@override
ConsumerState<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends ConsumerState<ChatPage> {
final _textCtrl = TextEditingController();
final _scrollCtrl = ScrollController();
@override
void initState() {
super.initState();
// Sayfaya girildiğinde mesajları okundu işaretle
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(chatRepositoryProvider).markAsRead(
widget.conversationId,
ref.read(currentUserProvider).id,
);
});
}
@override
Widget build(BuildContext context) {
final messages = ref.watch(messagesStreamProvider(widget.conversationId));
final currentUser = ref.read(currentUserProvider);
return Scaffold(
appBar: ChatAppBar(conversationId: widget.conversationId),
body: Column(
children: [
Expanded(
child: messages.when(
loading: () => const MessagesShimmer(),
error: (e, _) => ErrorView(error: e.toString()),
data: (msgs) => ListView.builder(
controller: _scrollCtrl,
reverse: true,
itemCount: msgs.length,
itemBuilder: (_, i) => MessageBubble(
message: msgs[i],
isMe: msgs[i].senderId == currentUser.id,
),
),
),
),
const TypingIndicatorBar(),
MessageInputBar(
controller: _textCtrl,
onSend: _sendMessage,
onImagePick: _sendImage,
),
],
),
);
}
Future<void> _sendMessage() async {
final text = _textCtrl.text.trim();
if (text.isEmpty) return;
_textCtrl.clear();
await ref.read(chatRepositoryProvider).sendMessage(
conversationId: widget.conversationId,
senderId: ref.read(currentUserProvider).id,
text: text,
);
_scrollCtrl.animateTo(0,
duration: const Duration(milliseconds: 300), curve: Curves.easeOut);
}
}
Firestore chat için güvenlik kurallarını baştan doğru ayarlayın: kullanıcılar yalnızca katılımcısı oldukları konuşmalara erişebilmeli. Yazıyor durumu ve çevrimiçi durum için Firebase Realtime Database daha uygun maliyetlidir çünkü bu veriler sık güncellenir ve Firestore yazma işlemi daha pahalıdır.