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.