Flutter 플러터 파이어베이스 채팅 앱 구현하기 Firestore Database

반응형

Flutter 플러터 파이어베이스 채팅 앱 구현하기 Firestore Database

Flutter 플러터 파이어베이스 채팅 앱 구현하기 Firestore Database

main.dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'chat_screen.dart';
import 'random_chat_service.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: AuthenticationScreen(),
    );
  }
}

class AuthenticationScreen extends StatelessWidget {
  final FirebaseAuth _auth = FirebaseAuth.instance;

  Future<void> _signInAnonymously(BuildContext context) async {
    try {
      UserCredential userCredential = await _auth.signInAnonymously();
      Navigator.pushReplacement(
        context,
        MaterialPageRoute(
          builder: (context) => MainScreen(userId: userCredential.user!.uid),
        ),
      );
    } catch (e) {
      print("로그인 오류: $e");
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('랜덤 채팅 로그인')),
      body: Center(
        child: ElevatedButton(
          onPressed: () => _signInAnonymously(context),
          child: Text('로그인'),
        ),
      ),
    );
  }
}

class MainScreen extends StatefulWidget {
  final String userId;
  MainScreen({required this.userId});

  @override
  _MainScreenState createState() => _MainScreenState();
}

class _MainScreenState extends State<MainScreen> {
  bool isLoading = false;
  StreamSubscription<String?>? _matchSubscription;
  Timer? _countdownTimer;
  int countdownSeconds = 10;

  Future<void> _startRandomChat() async {
    setState(() {
      isLoading = true;
    });

    // 사용자를 대기열에 추가
    await RandomChatService().addToQueue(widget.userId);

    // 대기 중 모달 창 띄우기
    showDialog(
      context: context,
      barrierDismissible: false,
      builder: (context) => StatefulBuilder(
        builder: (context, setDialogState) {
          // 타이머를 시작하고, 타이머가 실행될 때마다 setDialogState를 호출하여 UI 갱신
          if (_countdownTimer == null || !_countdownTimer!.isActive) {
            _countdownTimer = Timer.periodic(Duration(seconds: 1), (timer) {
              if (countdownSeconds > 0) {
                setState(() {
                  countdownSeconds--;
                });
                setDialogState(() {}); // 모달 창 UI 갱신
              } else {
                _cancelMatchmaking();
                if (Navigator.canPop(context)) {
                  Navigator.pop(context); // 대기 중 모달 창 닫기
                }
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text("매칭 시간이 초과되어 취소되었습니다.")),
                );
              }
            });
          }

          return AlertDialog(
            title: Text('상대방을 찾고 있습니다...'),
            content: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                CircularProgressIndicator(),
                SizedBox(height: 20),
                Text("대기 시간 : $countdownSeconds초"),
              ],
            ),
          );
        },
      ),
    );

    // 상대방을 기다리며 매칭을 확인
    _matchSubscription = RandomChatService().waitForMatch(widget.userId).listen((chatId) {
      if (chatId != null) {
        _cancelMatchmaking(); // 타이머와 매칭 구독 해제
        Navigator.pop(context); // 대기 중 모달 창 닫기
        Navigator.push(
          context,
          MaterialPageRoute(
            builder: (context) => ChatScreen(chatId: chatId, userId: widget.userId),
          ),
        );
        setState(() {
          isLoading = false;
        });
      }
    });
  }

  void _startCountdownTimer() {
    _countdownTimer = Timer.periodic(Duration(seconds: 1), (timer) {
      if (countdownSeconds > 0) {
        setState(() {
          countdownSeconds--;
        });
      } else {
        _cancelMatchmaking();
        if (Navigator.canPop(context)) {
          Navigator.pop(context); // 대기 중 모달 창 닫기
        }
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text("매칭 시간이 초과되어 취소되었습니다.")),
        );
      }
    });
  }

  void _cancelMatchmaking() {
    _matchSubscription?.cancel();
    _countdownTimer?.cancel();
    _countdownTimer = null; // 타이머 해제 후 null로 초기화하여 재실행 방지
    setState(() {
      isLoading = false;
      countdownSeconds = 10;
    });
  }

  @override
  void dispose() {
    _cancelMatchmaking(); // 모든 타이머와 스트림 구독 해제
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('메인 화면')),
      body: Center(
        child: isLoading
            ? CircularProgressIndicator()
            : ElevatedButton(
                onPressed: _startRandomChat,
                child: Text('랜덤 채팅 시작'),
              ),
      ),
    );
  }
}

 

chat_screen.dart

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';

class ChatScreen extends StatefulWidget {
  final String chatId;
  final String userId;

  ChatScreen({required this.chatId, required this.userId});

  @override
  _ChatScreenState createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  final TextEditingController messageController = TextEditingController();
  final FirebaseFirestore _firestore = FirebaseFirestore.instance;
  final ScrollController _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    _sendSystemMessage("${widget.userId} 님이 입장하였습니다.");
  }

  @override
  void dispose() {
    _sendSystemMessage("${widget.userId} 님이 방에서 나갔습니다.");
    _scrollController.dispose();
    super.dispose();
  }

  Future<void> _sendSystemMessage(String message) async {
    await _firestore.collection('chats').doc(widget.chatId).collection('messages').add({
      'text': message,
      'createdAt': FieldValue.serverTimestamp(),
      'isSystemMessage': true,
      'senderId': 'system', // 시스템 메시지는 senderId를 'system'으로 설정
    });
  }

  Future<void> _sendMessage() async {
    if (messageController.text.trim().isNotEmpty) {
      await _firestore.collection('chats').doc(widget.chatId).collection('messages').add({
        'senderId': widget.userId,
        'text': messageController.text,
        'createdAt': FieldValue.serverTimestamp(),
        'isSystemMessage': false,
      });
      messageController.clear();
      _scrollToBottom();
    }
  }

  void _scrollToBottom() {
    if (_scrollController.hasClients) {
      _scrollController.animateTo(
        _scrollController.position.maxScrollExtent,
        duration: Duration(milliseconds: 300),
        curve: Curves.easeOut,
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('랜덤 채팅'),
        leading: IconButton(
          icon: Icon(Icons.arrow_back),
          onPressed: () {
            Navigator.pop(context);
          },
        ),
      ),
      body: Column(
        children: [
          Expanded(
            child: StreamBuilder(
              stream: _firestore
                  .collection('chats')
                  .doc(widget.chatId)
                  .collection('messages')
                  .orderBy('createdAt')
                  .snapshots(),
              builder: (context, AsyncSnapshot<QuerySnapshot> snapshot) {
                if (!snapshot.hasData) return CircularProgressIndicator();

                // 새 메시지가 수신되면 자동 스크롤
                WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom());

                return ListView(
                  controller: _scrollController,
                  children: snapshot.data!.docs.map((doc) {
                    final data = doc.data() as Map<String, dynamic>;
                    bool isSystemMessage = data['isSystemMessage'] ?? false;
                    String senderId = data['senderId'] ?? '';

                    if (isSystemMessage) {
                      return Center(
                        child: Padding(
                          padding: const EdgeInsets.all(8.0),
                          child: Text(
                            data['text'],
                            style: TextStyle(
                              color: Colors.grey,
                              fontStyle: FontStyle.italic,
                            ),
                          ),
                        ),
                      );
                    } else {
                      bool isCurrentUser = senderId == widget.userId;
                      return Align(
                        alignment: isCurrentUser ? Alignment.centerRight : Alignment.centerLeft,
                        child: Container(
                          margin: EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
                          padding: EdgeInsets.all(10.0),
                          decoration: BoxDecoration(
                            color: isCurrentUser ? Colors.blue[100] : Colors.grey[300],
                            borderRadius: BorderRadius.only(
                              topLeft: Radius.circular(12),
                              topRight: Radius.circular(12),
                              bottomLeft: isCurrentUser ? Radius.circular(12) : Radius.circular(0),
                              bottomRight: isCurrentUser ? Radius.circular(0) : Radius.circular(12),
                            ),
                          ),
                          child: Text(
                            data['text'],
                            style: TextStyle(
                              color: Colors.black,
                            ),
                          ),
                        ),
                      );
                    }
                  }).toList(),
                );
              },
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: messageController,
                    decoration: InputDecoration(hintText: '메시지를 입력하세요'),
                    onSubmitted: (value) => _sendMessage(),
                  ),
                ),
                IconButton(
                  icon: Icon(Icons.send),
                  onPressed: _sendMessage,
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

 

random_chat_service.dart

import 'package:cloud_firestore/cloud_firestore.dart';

class RandomChatService {
  final FirebaseFirestore _firestore = FirebaseFirestore.instance;

  Future<void> addToQueue(String userId) async {
    await _firestore.collection('queue').doc(userId).set({
      'userId': userId,
      'createdAt': FieldValue.serverTimestamp(),
    });
  }

  Stream<String?> waitForMatch(String userId) async* {
    await for (var snapshot in _firestore
        .collection('queue')
        .where('userId', isNotEqualTo: userId)
        .snapshots()) {
      if (snapshot.docs.isNotEmpty) {
        final otherUserId = snapshot.docs.first.id;
        
        // 두 사용자 ID를 이용해 기존 채팅방이 있는지 확인하고 가져오기
        final chatId = await _findOrCreateChatRoom(userId, otherUserId);
        yield chatId;
      } else {
        yield null;
      }
    }
  }

  Future<String> _findOrCreateChatRoom(String userId, String otherUserId) async {
    // 두 사용자가 이미 존재하는 채팅방이 있는지 확인
    final existingChat = await _firestore
        .collection('chats')
        .where('users', arrayContains: userId)
        .get();

    for (var doc in existingChat.docs) {
      final users = List<String>.from(doc['users']);
      if (users.contains(otherUserId)) {
        return doc.id; // 기존 채팅방 ID 반환
      }
    }

    // 기존 채팅방이 없다면 새로 생성
    var chatDoc = await _firestore.collection('chats').add({
      'users': [userId, otherUserId],
      'createdAt': FieldValue.serverTimestamp(),
    });

    // 대기열에서 두 사용자 제거
    await _firestore.collection('queue').doc(userId).delete();
    await _firestore.collection('queue').doc(otherUserId).delete();

    return chatDoc.id;
  }
}

 

반응형