본문 바로가기

Flutter/12 Clone 'Used Goods app'

[Flutter] Clone - 당근마켓53(Chat - 5) 동시 채팅 및 실제 정보 매핑

이번에는 채팅 화면에 실제 데이터를 매핑하고 동시 채팅을 실행해 보겠습니다.

개발환경 : 윈도우11, 안드로이드 스튜디오, flutter 3.0.1

 

 

 

화면 구현은 아래와 같습니다.

1. 내가 포함된 채팅방을 보여주는 화면 - 내가 팔거나 살려고 하는 채팅방 리스트 입니다. 게시글 제목과 마지막 메시지 등을 표시

2. 채팅방 리스트에서 클릭을 하면 해당 채팅방이 보여짐 - 상단의 아이템 정보를 실정보로 매핑(게시글제목, 금액, 가격제안 여부, 아이템 이미지 표시)

 

 

 

 

실제 메시지 구현 동영상입니다.

 

 

 

 

./src/repo/chat_service.dart - 내가 포함된 채팅방 조회 함수

 

Future<List<ChatroomModel2>> getMyChatList(String myUserKey) async {
  List<ChatroomModel2> chatrooms = [];

  // todo: I am as a buyer
  QuerySnapshot<Map<String, dynamic>> buying = await FirebaseFirestore.instance
      .collection(COL_CHATROOMS)
      .where(DOC_BUYERKEY, isEqualTo: myUserKey)
      .get();

  // todo: I am as a seller
  QuerySnapshot<Map<String, dynamic>> selling = await FirebaseFirestore.instance
      .collection(COL_CHATROOMS)
      .where(DOC_SELLERKEY, isEqualTo: myUserKey)
      .get();

  for (var documentSnapshot in buying.docs) {
    ChatroomModel2 chatroom = ChatroomModel2.fromJson(documentSnapshot.data());
    chatroom.chatroomKey = documentSnapshot.id;
    chatroom.reference = documentSnapshot.reference;
    chatrooms.add(chatroom);
  }
  for (var documentSnapshot in selling.docs) {
    ChatroomModel2 chatroom = ChatroomModel2.fromJson(documentSnapshot.data());
    chatroom.chatroomKey = documentSnapshot.id;
    chatroom.reference = documentSnapshot.reference;
    chatrooms.add(chatroom);
  }

  // 정렬하는 기능, 내림차순 정렬
  chatrooms.sort((a, b) => (b.lastMsgTime).compareTo(a.lastMsgTime));

  return chatrooms;
}

 

 

 

./src/models/chatroom_model.dart - negotiable 필드 추가, 상세 코드를 보려면 "더보기" 클릭하세요

 

더보기
class ChatroomModel2 {
  String itemImage;
  String itemTitle;
  String itemKey;
  String itemAddress;
  num itemPrice;
  String sellerKey;
  String buyerKey;
  String sellerImage;
  String buyerImage;
  GeoFirePoint geoFirePoint;
  String lastMsg;
  DateTime lastMsgTime;
  String lastMsgUserKey;
  String chatroomKey;
  bool negotiable;
  DocumentReference? reference;

  ChatroomModel2({
    required this.itemImage,
    required this.itemTitle,
    required this.itemKey,
    required this.itemAddress,
    required this.itemPrice,
    required this.sellerKey,
    required this.buyerKey,
    required this.sellerImage,
    required this.buyerImage,
    required this.geoFirePoint,
    this.lastMsg = '',
    required this.lastMsgTime,
    this.lastMsgUserKey = '',
    required this.chatroomKey,
    required this.negotiable,
    this.reference,
  });

  factory ChatroomModel2.fromJson(Map<String, dynamic> json) => ChatroomModel2(
        itemImage: json[DOC_ITEMIMAGE] ?? '',
        itemTitle: json[DOC_ITEMTITLE] ?? '',
        itemKey: json[DOC_ITEMKEY] ?? '',
        itemAddress: json[DOC_ITEMADDRESS] ?? '',
        itemPrice: json[DOC_ITEMPRICE] ?? 0,
        sellerKey: json[DOC_SELLERKEY] ?? '',
        buyerKey: json[DOC_BUYERKEY] ?? '',
        sellerImage: json[DOC_SELLERIMAGE] ?? '',
        buyerImage: json[DOC_BUYERIMAGE] ?? '',
        geoFirePoint: json[DOC_GEOFIREPOINT] == null
            ? GeoFirePoint(0, 0)
            : GeoFirePoint((json[DOC_GEOFIREPOINT][DOC_GEOPOINT]).latitude,
                (json[DOC_GEOFIREPOINT][DOC_GEOPOINT]).longitude),
        lastMsg: json[DOC_LASTMSG] ?? '',
        lastMsgTime: json[DOC_LASTMSGTIME] == null
            ? DateTime.now().toUtc()
            : (json[DOC_LASTMSGTIME] as Timestamp).toDate(),
        lastMsgUserKey: json[DOC_LASTMSGUSERKEY] ?? '',
        chatroomKey: json[DOC_CHATROOMKEY] ?? '',
        negotiable: json[DOC_NEGOTIABLE] ?? false,
      );

  Map<String, dynamic> toJson() => {
        DOC_ITEMIMAGE: itemImage,
        DOC_ITEMTITLE: itemTitle,
        DOC_ITEMKEY: itemKey,
        DOC_ITEMADDRESS: itemAddress,
        DOC_ITEMPRICE: itemPrice,
        DOC_SELLERKEY: sellerKey,
        DOC_BUYERKEY: buyerKey,
        DOC_SELLERIMAGE: sellerImage,
        DOC_BUYERIMAGE: buyerImage,
        DOC_GEOFIREPOINT: geoFirePoint.data,
        DOC_LASTMSG: lastMsg,
        DOC_LASTMSGTIME: lastMsgTime,
        DOC_LASTMSGUSERKEY: lastMsgUserKey,
        DOC_CHATROOMKEY: chatroomKey,
        DOC_NEGOTIABLE: negotiable,
      };

  static String generateChatRoomKey({required String buyer, required String itemKey}) {
    return '${itemKey}_$buyer';
  }
}

 

 

 

./src/screens/home/item_detail_page.dart - 모델링 변경으로 클래스 생성자 변경

 

ChatroomModel2 _chatroomModel = ChatroomModel2(
  itemImage: itemModel.imageDownloadUrls[0],
  itemTitle: itemModel.title,
  itemKey: newItemKey,
  itemAddress: itemModel.address,
  itemPrice: itemModel.price,
  sellerKey: itemModel.userKey,
  buyerKey: userModel.userKey,
  sellerImage: 'https://minimaltoolkit.com/images/randomdata/male/101.jpg',
  buyerImage: 'https://minimaltoolkit.com/images/randomdata/female/41.jpg',
  geoFirePoint: itemModel.geoFirePoint,
  chatroomKey: chatroomKey,
  lastMsgTime: DateTime.now().toUtc(),
  negotiable: itemModel.negotiable, // <<== 새로 추가된 변수
);

 

 

 

./src/screens/chat/chat_list_page.dart - 내가 표함된 채팅방 리스트 및 화면 표시

 

import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';

import '../../constants/data_keys.dart';
import '../../models/chatroom_model.dart';
import '../../repo/chat_service.dart';
import '../../utils/logger.dart';
import '../../utils/time_calculation.dart';

class ChatListPage extends StatelessWidget {
  final String userKey;

  const ChatListPage({Key? key, required this.userKey}) : super(key: key);

  @override
  Widget build(BuildContext context) {

    return FutureBuilder<List<ChatroomModel2>>(
        future: ChatService().getMyChatList(userKey),
        builder: (context, snapshot) {
          Size _size = MediaQuery.of(context).size;

          return Scaffold(
            body: ListView.separated(
                itemBuilder: (context, index) {
                  ChatroomModel2 chatroomModel = snapshot.data![index];

                  // 현재 시간과 작성시간의 차이 계산
                  String gapTime = TimeCalculation.getTimeDiff(chatroomModel.lastMsgTime);
                  // 화면 표시용 지역명 표시
                  List _address = chatroomModel.itemAddress.split(' ');
                  String _detail = _address[_address.length - 1];
                  String _location = '';
                  if (_detail.contains('(') && _detail.contains(')')) {
                    _location = _detail.replaceAll('(', '').replaceAll(')', '');
                  } else {
                    _location = _address[2];
                  }

                  return ListTile(
                    onTap: () {
                    // 클릭시 해당 채팅방으로 이동
                      logger.d(chatroomModel.chatroomKey);
                      Get.toNamed("/$ROUTE_ITEM_DETAIL/${chatroomModel.chatroomKey}");
                    },
                    // 작성자 이미지가 없으므로 임시 이미지로 표시
                    leading: ExtendedImage.network(
                      'https://randomuser.me/api/portraits/women/18.jpg',
                      height: _size.height / 8,
                      width: _size.width / 8,
                      fit: BoxFit.cover,
                      shape: BoxShape.circle,
                    ),
                    // 제품 이미지 표시
                    trailing: ExtendedImage.network(
                      chatroomModel.itemImage,
                      height: _size.height / 8,
                      width: _size.width / 8,
                      fit: BoxFit.cover,
                      shape: BoxShape.rectangle,
                      borderRadius: BorderRadius.circular(4),
                    ),
                    title: Row(
                      children: [
                        // 게시글 타이틀 표시
                        Expanded(
                          child: Text(
                            chatroomModel.itemTitle,
                            maxLines: 1,
                            // 1줄 이상이면 ... 으로 표시
                            overflow: TextOverflow.ellipsis,
                            style: Theme.of(context).textTheme.subtitle1,
                          ),
                        ),
                        // 간략 주소 및 (현제일자-작성일자) 표시,
                        Text(
                          _location == '+'
                              ? _address[0] + '/' + gapTime
                              : _location + '/' + gapTime,
                          //chatroomModel.itemAddress,
                          maxLines: 1,
                          // 1줄 이상이면 ... 으로 표시
                          overflow: TextOverflow.ellipsis,
                          style: Theme.of(context).textTheme.subtitle2,
                        ),
                      ],
                    ),
                    // 마지막 메시지 표시
                    subtitle: Text(
                      chatroomModel.lastMsg,
                      maxLines: 1,
                      // 1줄 이상이면 ... 으로 표시
                      overflow: TextOverflow.ellipsis,
                      style: Theme.of(context).textTheme.bodyText1,
                    ),
                  );
                },
                separatorBuilder: (context, index) {
                  return Divider(thickness: 1, height: 1, color: Colors.grey[300]);
                },
                itemCount: snapshot.hasData ? snapshot.data!.length : 0),
          );
        });
  }
}

 

 

 

./src/screens/main_screen.dart - 새로운 ChatListPage 페이지 추가

 

body: IndexedStack(
  index: _bottomSelectedIndex,
  children: <Widget>[
    const ItemsScreen(),
    (UserController.to.userModel.value == null)
        ? Container()
        : MapScreen(UserController.to.userModel.value!),
    (UserController.to.userModel.value == null)
        ? Container()
        : GoogleMapScreen(UserController.to.userModel.value!),
// 새로운 화면(채팅 리스트 화면) 추가
    (UserController.to.userModel.value == null)
        ? Container()
        : ChatListPage(userKey: UserController.to.userModel.value!.userKey),
    Container(color: Colors.accents[3]),
    Container(color: Colors.accents[5]),
  ],
),

 

 

 

./src/screens/chat/chatroom_screen.dart - 새로운 방식으로 접근하기 위해서 채팅화면을 수정함

_chatroomModel 은 nullable 이므로 사용하기전에는 반드시 null 확인을 해야한다.

 

import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_multi_formatter/flutter_multi_formatter.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:shimmer/shimmer.dart';

import '../../models/chat_model.dart';
import '../../models/chatroom_model.dart';
import '../../models/user_model.dart';
import '../../states/chat_controller.dart';
import '../../states/user_controller.dart';
import '../../utils/logger.dart';
import 'chat.dart';

class ChatroomScreen extends StatefulWidget {
  const ChatroomScreen({Key? key}) : super(key: key);

  @override
  State<ChatroomScreen> createState() => _ChatroomScreenState();
}

class _ChatroomScreenState extends State<ChatroomScreen> {
  late String chatroomKey;
  final TextEditingController _textEditingController = TextEditingController();
  late final ChatController chatController;

  @override
  void initState() {
    // TODO: implement initState
    chatroomKey = Get.parameters['chatroomKey']!;
    chatController = Get.put(ChatController(chatroomKey));
    logger.d(chatroomKey);
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    // logger.d('${Get.parameters['chatroomKey']}');
    Size _size = MediaQuery.of(context).size;
    UserModel1 userModel = UserController.to.userModel.value!;
    
    // ChatController 내부의 chatList, chatroomModel 변수를 접근하기 위해서,
    return GetBuilder<ChatController>(
      builder: (controller) {
        return Scaffold(
          appBar: AppBar(),
          backgroundColor: Colors.grey[200],
          // 화면 하단의 메뉴바 때문에 SafeArea 로 wrapping 해야 오동작을 방지함.
          body: SafeArea(
            child: Column(
              children: [
                // 게시글 정보를 간략히 표시
                _buildItemInfo(context),
                // 채팅 메시지 표시 부분
                Expanded(
                  child: ListView.separated(
                    shrinkWrap: true,
                    // 최신 메시지가 아래에 위치하게 설정,
                    reverse: true,
                    padding: const EdgeInsets.all(16),
                    itemBuilder: (context, index) {
                      bool _isMine = controller.chatList[index].userKey == userModel.userKey;
                      return Chat(
                        size: _size,
                        isMine: _isMine,
                        chatModel: controller.chatList[index], //chatNotifier.chatList[index],
                      );
                    },
                    // 날짜가 다르면 디바이터 대신 일장 정보를 표시하게 함,
                    separatorBuilder: (context, index) {
                      if (DateFormat('yyyy-MM-dd').format(controller.chatList[index].createdDate) ==
                          DateFormat('yyyy-MM-dd')
                              .format(controller.chatList[index + 1].createdDate)) {
                        return const SizedBox(height: 12);
                      } else {
                        return Center(
                          child: Padding(
                            padding: const EdgeInsets.all(8.0),
                            child: Text(
                              DateFormat('yyyy-MM-dd')
                                  .format(controller.chatList[index].createdDate),
                            ),
                          ),
                        );
                      }
                    },
                    itemCount: controller.chatList.length, //chatNotifier.chatList.length,
                  ),
                ),
                const Padding(padding: EdgeInsets.all(4)),
                // 메시지 입력 창
                _buildInputBar(userModel)
              ],
            ),
          ),
        );
      },
    );
  }

  // 컬럼내부에 리스트타일(높이 관련 오류가 있어서 나중에 row+column 조함으로 변경함)과 버튼으로 구성 예정
  MaterialBanner _buildItemInfo(BuildContext context) {
    Rxn<ChatroomModel2> _chatroomModel = ChatController.to.chatroomModel;

    return MaterialBanner(
      // 공간 조절을 위해서 패딩을 수정함
      padding: EdgeInsets.zero,
      leadingPadding: EdgeInsets.zero,
      // actions 는 빈칸
      actions: [Container()],
      // content 에만 위젯을 넣어서 표시 예정임.
      content: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              Padding(
                padding: const EdgeInsets.only(left: 16, right: 8, top: 8, bottom: 8),
                child: _chatroomModel.value == null
                    ? Shimmer.fromColors(
                        highlightColor: Colors.grey[200]!,
                        baseColor: Colors.grey,
                        child: Container(
                          width: 48,
                          height: 48,
                          color: Colors.white,
                        ),
                      )
                    : ExtendedImage.network(
                        _chatroomModel.value!.itemImage,
                        width: 48,
                        height: 48,
                        fit: BoxFit.cover,
                      ),
              ),
              Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  RichText(
                    // todo: 거래완료 여부 확인 필드 추가
                    text: TextSpan(
                        text: '거래완료',
                        style: Theme.of(context).textTheme.bodyText1,
                        children: [
                          TextSpan(
                              text: _chatroomModel.value == null
                                  ? ''
                                  : ' ' + _chatroomModel.value!.itemTitle,
                              // text: ' 복숭아 떨이',
                              style: Theme.of(context).textTheme.bodyText2)
                        ]),
                  ),
                  RichText(
                    text: TextSpan(
                        text: _chatroomModel.value == null
                            ? ''
                            : _chatroomModel.value!.itemPrice
                                .toCurrencyString(mantissaLength: 0, trailingSymbol: '원'),
                        style: Theme.of(context).textTheme.bodyText1,
                        children: [
                          TextSpan(
                            text: _chatroomModel.value == null
                                ? ''
                                : _chatroomModel.value!.negotiable
                                    ? '  (가격제안가능)'
                                    : '  (가격제안불가)',
                            style: _chatroomModel.value == null
                                ? Theme.of(context)
                                    .textTheme
                                    .bodyText2!
                                    .copyWith(color: Colors.black26)
                                : _chatroomModel.value!.negotiable
                                    ? Theme.of(context)
                                        .textTheme
                                        .bodyText2!
                                        .copyWith(color: Colors.blue)
                                    : Theme.of(context)
                                        .textTheme
                                        .bodyText2!
                                        .copyWith(color: Colors.black26),
                          )
                        ]),
                  ),
                ],
              ),
            ],
          ),
          Padding(
            padding: const EdgeInsets.only(left: 16, bottom: 8),
            // 버튼 사이즈를 줄이기 위해서 SizedBox 추가
            child: SizedBox(
              height: 32,
              child: TextButton.icon(
                onPressed: () {
                  debugPrint('후기남기기 클릭~~');
                },
                icon: const Icon(
                  Icons.edit,
                  // 버튼 사이즈를 줄이기 위해서 size 추가 설정,
                  size: 16,
                  color: Colors.black87,
                ),
                label: Text(
                  '후기 남기기',
                  style: Theme.of(context).textTheme.bodyText1!.copyWith(color: Colors.black87),
                ),
                style: TextButton.styleFrom(
                  backgroundColor: Colors.white,
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(4),
                    side: BorderSide(color: Colors.grey[300]!, width: 2),
                  ),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildInputBar(UserModel1 userModel) {
    return SizedBox(
      height: 48,
      child: Row(
        children: [
          IconButton(
            onPressed: () {},
            icon: const Icon(
              Icons.add,
              color: Colors.grey,
            ),
          ),
          Expanded(
            child: TextFormField(
              controller: _textEditingController,
              decoration: InputDecoration(
                hintText: '메시지를 입력하세요',
                // 메시지 입력박스를 조금 작게 줄임,
                isDense: true,
                // fillColor & filled 동시에 설정해야함.
                fillColor: Colors.white,
                filled: true,
                suffixIcon: GestureDetector(
                  onTap: () {
                    logger.d('Icon clicked');
                  },
                  child: const Icon(
                    Icons.emoji_emotions_outlined,
                    color: Colors.grey,
                  ),
                ),
                // 아이콘때문에 입력 박스가 커져서 줄여줌 설정,
                suffixIconConstraints: BoxConstraints.tight(const Size(40, 40)),
                // 입력 박스의 패딩 공간을 줄임, 미설정시 16쯤 됨,
                contentPadding: const EdgeInsets.all(10),
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(20),
                  borderSide: const BorderSide(color: Colors.redAccent),
                ),
              ),
            ),
          ),
          IconButton(
            onPressed: () async {
              ChatModel2 chatModel = ChatModel2(
                userKey: userModel.userKey,
                msg: _textEditingController.text,
                createdDate: DateTime.now(), // addNewChat->toJson 에서 toUtc() 재처리함.
              );

              // await ChatService().createNewChat(chatroomKey, chatModel);
              // Obs 처리하여 삭제시에도 바로 반영된다. 그래서 삭제시 화면에 바로 반영되서 기존것으로 원복함.
              ChatController.to.addNewChat(chatModel);
              // logger.d(_textEditingController.text.toString());
              _textEditingController.clear();
            },
            icon: const Icon(
              Icons.send,
              color: Colors.grey,
            ),
          )
        ],
      ),
    );
  }
}