package com.example.class_step_websocket.chat;
public enum MessageType {
CHAT, SYSTEM
}
package com.example.class_step_websocket.chat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ChatMessageDto {
private String id;
private String content;
private String sender;
private String type;
private String timestamp;
// 내부적으로 enum으로 변환하는 메서드 추가
public MessageType getMessageType() {
return "system".equals(this.type) ? MessageType.SYSTEM : MessageType.CHAT;
}
}
package com.example.class_step_websocket.chat;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import java.time.LocalDateTime;
@Slf4j
@RequiredArgsConstructor
@Controller // 뷰 반환
public class ChatController {
private final ChatService chatService;
// 메세지 작성 폼 페이지
@GetMapping("/save-form")
public String saveForm() {
return "save-form";
}
// 채팅 목록 페이지
@GetMapping("/")
public String index(Model model) {
model.addAttribute("models", chatService.findAll());
return "index";
}
// 채팅 메세지 저장 요청
@PostMapping("/chat")
public String save(String msg) {
chatService.save(msg);
// POST 맵핑에서 (웹 에서 주의할 점) - 중복 등록 조심
return "save-form";
// PRG 패턴
//return "ridirect:/";
}
/**
*
* @MessageMapping - 내부 동작 원리 (AOP)
* /app/chat --> xxxxx --> 브로드 캐스트 처리
* 순수 웹소켓 구현시 만들었던 웹 소켓 핸들러를 대체 한다
* MessageMapping -> handleMessage() 로직을 대체 함 if --> CHAT:
*
* @SendTo("/topic/message") -- 기존에 로직인 broadcastMessage() 를 대체
*/
@MessageMapping("/chat")
@SendTo("/topic/message")
public ChatMessageDto sendMessage(ChatMessageDto messageDto) {
log.info("---> STOMP 메세지 수신 : {}", messageDto);
try {
if(messageDto == null || messageDto.getContent().trim().isEmpty()) {
log.warn("빈 메세지 수신, 브로드 캐스트 하지 않음");
return null;
}
// 메세지 내용 DB 저장 로직 그대로 사용
Chat savedChat = chatService.save(messageDto.getContent().trim());
// 클라이언트들 에게 던지는 메세지 타입은 수정이 되어야 한다.
ChatMessageDto chatMessageDto = ChatMessageDto.builder()
.id(savedChat.getId().toString())
.content(savedChat.getMsg())
.sender(messageDto.getSender())
.type("CHAT")
.timestamp(LocalDateTime.now().toString())
.build();
return chatMessageDto;
} catch (Exception e) {
log.error("메세지 저장 실패 : ", e);
return createErrorMessage("메세지 전송 및 저장에 실패");
}
}
/**
* 에러 메세지 생성 헬퍼 메서드
*/
private ChatMessageDto createErrorMessage(String errorMessage) {
return ChatMessageDto.builder()
.id(System.currentTimeMillis() + "")
.content(errorMessage)
.sender("SYSTEM")
.timestamp(LocalDateTime.now().toString())
.build();
}
}
index.mustache
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>채팅 (WebSocket)</title>
<style>
body { margin: 20px; font-family: Arial, sans-serif; }
nav ul { list-style: none; padding: 0; display: flex; gap: 10px; }
nav a { padding: 8px 15px; background: #666; color: white; text-decoration: none; }
.chat-area {
border: 1px solid #ccc;
height: 300px;
padding: 10px;
overflow-y: scroll;
background: #f9f9f9;
}
.message {
padding: 5px 10px;
margin: 3px 0;
background: white;
border-radius: 8px;
max-width: 80%;
}
.message:nth-child(odd) {
background: #e1f5fe;
margin-left: 20%;
}
/* 연결 상태 표시용 스타일 */
.status {
padding: 5px 10px;
border-radius: 3px;
font-size: 12px;
margin-bottom: 10px;
}
.connected { background: #d4edda; color: #155724; }
.disconnected { background: #f8d7da; color: #721c24; }
/* 메시지 입력 폼 스타일 */
.message-form {
margin-top: 15px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 5px;
background: #f8f9fa;
}
.message-form input {
padding: 8px;
width: 70%;
margin-right: 10px;
border: 1px solid #ccc;
border-radius: 3px;
}
.message-form button {
padding: 8px 15px;
background: #007bff;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
}
.message-form button:disabled {
background: #6c757d;
cursor: not-allowed;
}
</style>
</head>
<body>
<nav>
<ul>
<li><a href="/">목록</a></li>
<li><a href="/save-form">작성</a></li>
</ul>
</nav>
<h3>채팅 (WebSocket 방식)</h3>
<p style="color: #666; font-size: 13px;">실시간 업데이트</p>
<!-- 연결 상태 표시 영역 -->
<div id="status" class="status disconnected">연결 중...</div>
<!-- 채팅 메시지 표시 영역 -->
<div class="chat-area" id="chatArea">
{{#models}}
<div class="message">{{msg}}</div>
{{/models}}
</div>
<!-- 메시지 입력 폼 -->
<div class="message-form">
<form id="chatForm">
<input type="text" id="msgInput" placeholder="메시지를 입력하세요..." required>
<button type="submit" id="sendButton" disabled>연결 중...</button>
</form>
</div>
<!-- STOMP 라이브러리 로드 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.6.1/sockjs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script>
<!-- 이 페이지에 전역 변수 선언 -->
// DOM 요소 참조
const chatArea = document.getElementById('chatArea');
const status = document.getElementById('status');
const msgInput = document.getElementById('msgInput');
const chatForm = document.getElementById('chatForm');
const sendButton = document.getElementById('sendButton');
<!-- 이 페이지에 전역 변수 선언 -->
let stompClient = null;
<!-- 스톰프 연결 시작 -->
function connect() {
const socket = new SockJS('http:/192.168.0.132:8080/ws');
stompClient = Stomp.over(socket);
// 스톰프 연결 시도
stompClient.connect({}, onConnected, onError);
}
<!-- 연결 요청 함수 실행-->
connect();
<!-- 연결 성공 콜백 메서드 선언-->
function onConnected() {
status.textContent = "실시간 연결됨(STOMP)";
status.className = 'status connected';
sendButton.disabled = false;
sendButton.textContent = '전송';
msgInput.focus();
console.log('11111111111');
// 메시지 채널 구독 설정
stompClient.subscribe('/topic/message', onMessageReceived);
console.log('STOMP 연결 및 구독 처리 완료');
}
<!-- 연결 실패시 콜백 -->
function onError(error) {
status.textContent = "연결 에러 발생";
status.className = 'status disconnected';
sendButton.disabled = true;
console.error('STOMP 연결 에러', error);
}
<!-- 메세지 수신 처리 -->
function onMessageReceived(message) {
const chatMessageDto = JSON.parse(message.body);
console.log('chatMessageDto', chatMessageDto);
// 에러 메세지 처리
if(chatMessageDto.type === 'SYSTEM') {
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = '[시스템] 에러 발생';
chatArea.appendChild(errorDiv);
chatArea.scrollTop = chatArea.scrollHeight;
return;
}
// 일반 채팅 메세지 정상 수신
const messageDiv = document.createElement('div');
messageDiv.className = 'message';
// 발신자와 메세지 내용을 함께 표시해 보자.
const senderText = chatMessageDto.sender ? `[${chatMessageDto.sender}]` : '';
messageDiv.textContent = `${senderText} ${chatMessageDto.content}`;
chatArea.appendChild(messageDiv);
// 스크롤 맨 아래로 이동 처리
chatArea.scrollTop = chatArea.scrollHeight;
}
<!-- 메세지 전송 로직 처리-->
chatForm.addEventListener('submit', function(e) {
e.preventDefault();
sendMessage();
});
<!-- 메세지 전송 로직 처리 -->
function sendMessage() {
// 사용자가 작성한 글자 가져오기
const sendMessage = msgInput.value.trim();
// 가져온 글자를 서버로 보내는 처리 - (검증...방어적 코드 작성)
if(sendMessage != null && stompClient != null && stompClient.connected) {
// {'content-Type': 'application/json'} <-- 헤더 설정
// js 객체를 생성 --> 직렬화 --> json 형식으로 서버에 전송
const messageDto = {
id : Date.now().toString(),
content: sendMessage,
sender:'웹[김근호]',
type: "CHAT",
timestamp: new Date().toString()
};
// JSON 문자열로 변환하여 서버측으로 전송해야 한다.
stompClient.send('/app/chat', {}, JSON.stringify(messageDto));
msgInput.value = '';
msgInput.focus();
} else {
alert('Stomp 연결이 끊어져 있습니다');
}
}
<!-- 엔터키 이벤트 등록 및 처리 -->
msgInput.addEventListener('keypress', function(e) {
if(e.key === 'Enter') {
sendMessage();
}
});
// 페이지 종료 시 연결 종료 처리
window.addEventListener('beforeunload', () => {
if(stompClient) {
stompClient.disconnect();
console.log('stomp연결 정리 완료');
}
});
</script>
</body>
</html>
플러터 채팅 기능 (STOMP) 구현
패키지 구조 확인


핵심 코드
의존성 확인
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
stomp_dart_client: ^1.0.2
flutter_riverpod: ^2.4.7
ChatMessage 모델 설계
enum MessageType { CHAT, SYSTEM }
class ChatMessage {
final String id;
final String content;
final String sender;
final MessageType type;
final DateTime timestamp;
final bool isRead; // UI 측 데이터
const ChatMessage({
required this.id,
required this.content,
required this.sender,
required this.type,
required this.timestamp,
this.isRead = false,
});
// 일반 채팅 메세지 생성자 (네임드)
factory ChatMessage.createChat(
{required String content, required String sender}) {
return ChatMessage(
id: DateTime.now().millisecondsSinceEpoch.toString(),
content: content,
sender: sender.trim(),
type: MessageType.CHAT,
timestamp: DateTime.now(),
);
}
// 시스템 메세지 생성
factory ChatMessage.createSystem(String content) {
return ChatMessage(
id: DateTime.now().millisecondsSinceEpoch.toString(),
content: content,
sender: 'system',
type: MessageType.SYSTEM,
timestamp: DateTime.now(),
);
}
// 시간 포맷팅 기능
String get formattedTime {
// 시간 2자리 수 (08), (11) , 자동으로 0을 붙여서 만들어 내. 9 -> 09
final hour = timestamp.hour.toString().padLeft(2, '0');
final minute = timestamp.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}
// 시스템 메세지 여부
bool get isSystemMessage {
return type == MessageType.SYSTEM;
}
// 내 메세지 여부 (UI 확인용)
bool isMyMessage(String currentUsername) {
if (isSystemMessage) return false;
return sender == currentUsername;
}
// 읽음 처리(불편 객체이므로 새 인스턴스 생성)
ChatMessage markAsRead() {
return ChatMessage(
id: id,
content: content,
sender: sender,
type: type,
timestamp: timestamp,
isRead: true,
);
}
}
ChatMessageDto 클래스 설계
import '../models/chat_message.dart';
class ChatMessageDto {
final String id;
final String content;
final String sender;
final String type; // 'CHAT' or 'SYSTEM'
final String timestamp;
ChatMessageDto({
required this.id,
required this.content,
required this.sender,
required this.type,
required this.timestamp,
});
// JSON 에서 Map 구조로 변환 한 뒤 map 구조에서 DTO 클래스를 생성(수신)
factory ChatMessageDto.fromJson(Map<String, dynamic> json) {
return ChatMessageDto(
id: json['id'] as String,
content: json['content'] as String,
sender: json['sender'] as String,
type: json['type'] as String,
timestamp: json['timestamp'] as String,
);
}
// DTO 객체를 JSON 으로 변환 시 사용 (전송)
Map<String, dynamic> toJson() {
return {
'id': id,
'content': content,
'sender': sender,
'type': type,
'timestamp': timestamp,
};
}
// Model 객체를 DTO 로 변환 하는 기능
factory ChatMessageDto.fromModel(ChatMessage model) {
return ChatMessageDto(
id: model.id,
content: model.content,
sender: model.sender,
type: model.type.toString(),
timestamp: model.timestamp.toString(),
);
}
// ChatMessageDto -> 에서 ChatMessage 클래스 생성하는 기능
// ChatMesageDto.toModel() ; --> ChatMessage() 생성 하는 코드
ChatMessage toModel() {
return ChatMessage(
id: DateTime.now().millisecondsSinceEpoch.toString(),
content: content,
sender: sender,
type: MessageType.CHAT,
timestamp: DateTime.now(),
);
}
}
chat_repository.dart
import 'dart:convert';
import 'dart:io';
import 'package:class_websocket_flutter/data/dtos/chat_message_dto.dart';
import 'package:class_websocket_flutter/data/models/chat_message.dart';
import 'package:stomp_dart_client/stomp.dart';
import 'package:stomp_dart_client/stomp_config.dart';
import 'package:stomp_dart_client/stomp_frame.dart';
// 웹 소켓 네트워크만 담당
// 어떤 기능이 필요하지?
// 스톰프 클라이언트 초기화 처리
// 연결 성공 처리
// 연결 해제 처리
// 메세지 전송 처리
// 메세제 수진 처리
// 에러처리 등
class ChatRepository {
late StompClient stompClient;
// 플랫폼별 서버 주소 설정
static String get serverUrl {
if (Platform.isAndroid) {
return 'http://10.0.2.2:8080';
// 192.1680.13 <- 강사 컴퓨터 서버 주소
} else if (Platform.isIOS) {
return 'http://localhost:8080';
} else {
return 'http://localhost:8080';
}
}
// 연결 상태 확인 getter
bool get isConnected => stompClient.connected;
// 콜백 함수 만들기
// 네트워크 이벤트를 상위 클래스에 알려주기 위함
// ChatRepository 목적은 네트워크 통신을 통해서 무슨 이벤트만 들어 왔는지만 알면 되고
// 위 알림을 바탕으로 어떤게 처리할지는 상위 클래스가 결정 ....
void Function()? onConnected;
void Function()? onDisconnected;
void Function(String error)? onError;
void Function(ChatMessage message)? onMessage;
// 스톰프 클라이언트 초기화
void init() {
stompClient = StompClient(
config: StompConfig(
url: '$serverUrl/ws',
onConnect: _handleConnected,
onDisconnect: _handleDisconnected,
onWebSocketError: (error) => onError?.call('연결 오류 : $error'),
useSockJS: true, // 호환성
),
);
}
// StompFrame --> 스톰프 클라이언트가 가지고 있음
// frame.body (편지 내용)
// frame.headers (받는 사람 주소, 보내는 사람 주소, 배송 옵션)
// frame.command (일반우편, 등기우편, 택배)
void _handleConnected(StompFrame frame) {
// 연결 성공시 == 어떤 메세지를 구독할지 바로 호출
_subscribeToMessage(); // 선이 연결 된 상태니깐 그 중에 나는 /topic/message 만 받을꺼야!
onConnected?.call();
}
void _handleDisconnected(StompFrame frame) {
onDisconnected?.call();
}
// 서버 연결
void connect() {
stompClient.activate();
}
// 연결 해제
void disconnect() {
if (stompClient.connected) {
stompClient.deactivate();
}
}
// 메세지 전송
void sendMessage(ChatMessage message) {
if (stompClient.connected == false) {
onError?.call('웹 소켓 서버와 연결 안됨');
return;
}
final ChatMessageDto dto = ChatMessageDto.fromModel(message);
final String jsonBody = jsonEncode(dto);
stompClient.send(
destination: '/app/chat',
body: jsonBody,
);
}
// 메세지 구독
void _subscribeToMessage() {
stompClient.subscribe(
destination: '/topic/message',
callback: (StompFrame frame) {
if (frame.body != null) {
// 메세지 수신 처리
_handleIncomingMessage(frame.body!);
}
},
);
}
void _handleIncomingMessage(String messageBody) {
final jsonData = jsonDecode(messageBody);
final dto = ChatMessageDto.fromModel(jsonData);
final ChatMessage message = dto.toModel();
onMessage?.call(message);
}
}
connection_provider.dart
// 창고 데이터
import 'package:class_websocket_flutter/data/repository/chat_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class ConnectionState {
final bool isConnected;
final String status;
ConnectionState({required this.isConnected, required this.status});
ConnectionState copyWith({bool? isConnected, String? status}) {
return ConnectionState(
isConnected: isConnected ?? this.isConnected,
status: status ?? this.status,
);
}
} // end of ConnectionState
// 창고 매뉴얼 (확장된 - VM 개념)
class ConnectionStateNotifier extends Notifier<ConnectionState> {
late ChatRepository _chatRepository;
@override
ConnectionState build() {
_chatRepository = ChatRepository();
_setupCallbacks();
_chatRepository.init();
return ConnectionState(isConnected: false, status: "연결 준비중");
}
void _setupCallbacks() {
_chatRepository.onConnected = () {
state = state.copyWith(isConnected: true, status: "연결됨");
};
_chatRepository.onDisconnected = () {
state = state.copyWith(isConnected: false, status: "연결 해제됨");
};
_chatRepository.onError = (error) {
state = state.copyWith(isConnected: false, status: '$error');
};
}
// 연결 시작
void connect() {
state = state.copyWith(status: "연결 중...");
_chatRepository.connect();
}
// 연결 해제
void disconnected() {
state = state.copyWith(status: "연결 해제 중...");
_chatRepository.disconnect();
}
}
// 실제 창고 개설
final connectedStateProvider =
NotifierProvider<ConnectionStateNotifier, ConnectionState>(
() => ConnectionStateNotifier(),
);
chat_message_provider.dart
// 창고 데이터
import 'package:class_websocket_flutter/data/models/chat_message.dart';
import 'package:class_websocket_flutter/data/repository/chat_repository.dart';
import 'package:class_websocket_flutter/providers/local/connection_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class ChatState {
final List<ChatMessage> messages;
final String username;
ChatState({required this.messages, required this.username});
ChatState copyWith({List<ChatMessage>? messages, String? username}) {
return ChatState(
messages: messages ?? this.messages,
username: username ?? this.username);
}
}
// 창고 매뉴얼
class ChatMessageNotifier extends Notifier<ChatState> {
late ChatRepository _chatRepository;
@override
ChatState build() {
final connectionNotifier = ref.watch(connectedStateProvider.notifier);
_chatRepository = connectionNotifier.chatRepository;
_setupMessageCallback(); // 콜백 메서드 등록 처리
return ChatState(
username: "김강사", messages: [ChatMessage.createSystem("채팅이 시작 되었습니다")]);
}
void _setupMessageCallback() {
_chatRepository.onMessage = (messageBody) {
// 디버그 용
print("메세지 수신 됨 : ${messageBody.content} ${messageBody.sender}");
if (messageBody.sender == state.username) {
// 내 메세지의 브로드 캐스트는 무시
return;
}
// 새로운 ChatMessage 객체가 들어 왔다면 내 변수 List<ChatMessage> 에 추가 해줘야
// UI 화면을 재 갱신 처리 한다(자동)
// 스프레드 연산자 사용하기
state = state.copyWith(
messages: [...state.messages, messageBody],
);
};
// TODO - 필요 하다면 구현 하기
// _chatRepository.onError
// _chatRepository.onDisconnected
}
void sendMessage(String content) {
if (content.trim().isEmpty) {
return;
}
ChatMessage message =
ChatMessage.createChat(content: content, sender: state.username);
// 메세지 수신측 콜백메서드에 내가 발송한 메세지는 무시 처리 해놨음
// 상태 변경 --> UI watch 할 예정이기 때문에 자동으로 UI 업데이트 됨
state = state.copyWith(messages: [...state.messages, message]);
// 서버로 전송 하기
_chatRepository.sendMessage(message);
}
// TODO - 추가 비즈니스 로직 설계 (사용자명 변경 또는 내 메세지 삭제)
}
// 창고 개설
final chatMessageProvider = NotifierProvider<ChatMessageNotifier, ChatState>(
() => ChatMessageNotifier());
main.dart
import 'package:class_websocket_flutter/pages/chat/chat_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
// ProviderScope 리버팟에서 제공하는 위젯들 제공한다.
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'stomp chat',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange),
// 최산 머리티얼 디자인 사용 하겠다.
useMaterial3: true,
),
home: const ChatPage(),
debugShowCheckedModeBanner: false,
);
}
}
ChatPage
import 'package:class_websocket_flutter/pages/chat/widgets/chat_body.dart';
import 'package:flutter/material.dart';
class ChatPage extends StatelessWidget {
const ChatPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('채팅'),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
body: SafeArea(child: ChatBody()),
);
}
}
UI 작업 부분 및 연동
ChatBody
import 'package:class_websocket_flutter/pages/chat/widgets/chat_connection_status.dart';
import 'package:class_websocket_flutter/pages/chat/widgets/chat_input_field.dart';
import 'package:class_websocket_flutter/pages/chat/widgets/chat_message_list.dart';
import 'package:class_websocket_flutter/providers/local/chat_message_provider.dart';
import 'package:class_websocket_flutter/providers/local/connection_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class ChatBody extends ConsumerStatefulWidget {
const ChatBody({super.key});
@override
ConsumerState<ChatBody> createState() => _ChatBodyState();
}
class _ChatBodyState extends ConsumerState<ChatBody> {
final TextEditingController _editingController = TextEditingController();
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
// 위젯이 안정적으로 바인딩 된 후에 ref 호출
ref.read(connectedStateProvider.notifier).connect();
});
}
// 메모리 해제
@override
void dispose() {
_editingController.dispose();
_scrollController.dispose();
super.dispose();
}
// 메세지 전송
void _sendMessage() {
final content = _editingController.text;
if (content.isEmpty) return;
_editingController.clear();
// 기능 호출 한번 마
ref.read(chatMessageProvider.notifier).sendMessage(content);
}
void _scrollToBottom() {
// 이 위젯이 완전희 렌더링 된 시점에 한 번만 콜백을 실행하도록 보장
// 위젯의 크기나 위치 정보가 정확히 확정된 시점에 네트워크 연결 또는
// 안정작인 동작을 보장 합니다.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeIn,
);
}
});
}
@override
Widget build(BuildContext context) {
// ref.listen() - 상태가 변경될 때 특정 로직만 실행 (UI 재구축이랑 별개)
ref.listen<ChatState>(
chatMessageProvider,
(previous, next) {
if (previous != null &&
previous.messages.length < next.messages.length) {
// 자동 스크롤 내리는 기능 만들기
_scrollToBottom();
}
},
);
return Column(
children: [
ChatConnectionStatus(),
// ListView...
Expanded(child: ChatMessageList(scrollController: _scrollController)),
ChatInputField(
controller: _editingController, onSendMessage: _sendMessage)
],
);
}
}
ChatConnectionStatus
import 'package:class_websocket_flutter/providers/local/connection_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class ChatConnectionStatus extends ConsumerWidget {
const ChatConnectionStatus({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// 상태 감지 가능
final connection = ref.watch(connectedStateProvider);
return Container(
width: double.infinity,
padding: EdgeInsets.all(12.0),
// 동적 컬러 설정
color: connection.isConnected ? Colors.green : Colors.red,
child: Text(
'${connection.status}',
style: TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
);
}
}
ChatInputField
import 'package:class_websocket_flutter/providers/local/connection_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class ChatInputField extends ConsumerWidget {
final TextEditingController controller;
final VoidCallback onSendMessage;
const ChatInputField({
required this.controller,
required this.onSendMessage,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final connection = ref.watch(connectedStateProvider);
return Container(
padding: EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: '메세지를 입력하세요',
border: OutlineInputBorder(),
),
// 엔터키 입력시 처리 됨 : 키보드의 완료/ 전송 버튼 터치시 동작 함
onSubmitted: (value) => onSendMessage(),
),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: connection.isConnected ? onSendMessage : null,
child: Text('전송'),
)
],
),
);
}
}
chat_message_item
import 'package:class_websocket_flutter/data/models/chat_message.dart';
import 'package:flutter/material.dart';
class ChatMessageItem extends StatelessWidget {
final ChatMessage message;
final String currentUsername;
const ChatMessageItem({
required this.message,
required this.currentUsername,
super.key,
});
@override
Widget build(BuildContext context) {
if (message.isSystemMessage) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
// Colors.grey[300]
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(12),
),
// 시스템 메세지
child: Text(
message.content,
style: TextStyle(fontSize: 12, color: Colors.grey.shade700),
),
),
),
);
}
// 내 메세지 or 다른 사람 메세지
final isMyMessage = message.isMyMessage(currentUsername);
if (isMyMessage) {
// 나의 메세지
return Container(
margin: const EdgeInsets.symmetric(vertical: 2, horizontal: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// 말풍선 위젯
// Expanded - 전체 공간을 차지 (클수 있을 만큼)
// Flexible - 필요한 만큼 차지 (보통 Container 과 많이 활용 됨)
Flexible(
child: Container(
// 컨테이너 위젯에 제약 조건을 설정하는 속성
constraints: BoxConstraints(
minWidth: 60,
maxWidth: MediaQuery.of(context).size.width * 0.7,
),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(12),
),
child: Text(
message.content,
style: TextStyle(color: Colors.white),
),
),
),
const SizedBox(width: 8),
Text(
message.formattedTime,
style: TextStyle(
fontSize: 10,
color: Colors.grey,
),
),
],
),
);
} else {
// 다른 사람 메세지
return Container(
margin: const EdgeInsets.symmetric(vertical: 2, horizontal: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
message.formattedTime,
style: TextStyle(
fontSize: 10,
color: Colors.grey,
),
),
const SizedBox(width: 8),
// 말풍선 위젯
// Expanded - 전체 공간을 차지 (클수 있을 만큼)
// Flexible - 필요한 만큼 차지 (보통 Container 과 많이 활용 됨)
Flexible(
child: Container(
// 컨테이너 위젯에 제약 조건을 설정하는 속성
constraints: BoxConstraints(
minWidth: 60,
maxWidth: MediaQuery.of(context).size.width * 0.7,
),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Text(
message.sender,
style: TextStyle(color: Colors.black),
),
Text(message.content,
style: TextStyle(color: Colors.black)),
],
),
),
),
],
),
);
}
return const Placeholder();
}
}
ChatMessageList
import 'package:class_websocket_flutter/pages/chat/widgets/chat_message_item.dart';
import 'package:class_websocket_flutter/providers/local/chat_message_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class ChatMessageList extends ConsumerWidget {
// 외부에서 주입 받는 스크롤 컨트롤러
// 상위 위젯에서 자동 스크롤 기능을 제어하기 위해 필요
final ScrollController scrollController;
const ChatMessageList({required this.scrollController, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// 상태 구독
final ChatState chatState = ref.watch(chatMessageProvider);
// ListView() <== List<ChatMessage> 100 개
return ListView.builder(
controller: scrollController,
itemCount: chatState.messages.length,
itemBuilder: (context, index) {
return ChatMessageItem(
message: chatState.messages[index],
currentUsername: chatState.username,
);
},
);
}
}'Spring boot > Spring boot websocket' 카테고리의 다른 글
| Spring Boot - STOMP 프로토콜 채팅 (0) | 2025.09.05 |
|---|---|
| Spring Boot WebSocket 기본 채팅 시스템 (0) | 2025.09.04 |
| Spring Boot SSE(Server-Sent Events) 채팅 시스템 (0) | 2025.09.04 |
| Spring Boot 폴링(Polling) 채팅 시스템 (0) | 2025.09.02 |