import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; import '../models/chat_message.dart'; import '../services/chat_service.dart'; import 'dart:math'; // ===== 반응형 크기 계산 함수 ===== double _getResponsiveFontSize(BuildContext context, double baseSize) { double screenWidth = MediaQuery.of(context).size.width; return baseSize * (screenWidth / 1366); // 기준: 1366px (14.1인치) } double _getResponsivePadding(BuildContext context, double basePadding) { double screenWidth = MediaQuery.of(context).size.width; return basePadding * (screenWidth / 1366); } double _getResponsiveSize(BuildContext context, double baseSize) { double screenWidth = MediaQuery.of(context).size.width; return baseSize * (screenWidth / 1366); } class ChatScreen extends StatefulWidget { const ChatScreen({Key? key}) : super(key: key); @override State createState() => _ChatScreenState(); } class _ChatScreenState extends State { final TextEditingController _textController = TextEditingController(); final List _messages = []; final ScrollController _scrollController = ScrollController(); late VideoPlayerController _videoController; bool _isVideoInitialized = false; bool _isLoading = false; int _currentEmotion = 1; // ← 추가 (현재 감정, 기본값: 1 = DEFAULT) @override void initState() { super.initState(); // 더미 컨트롤러로 먼저 초기화 _videoController = VideoPlayerController.asset('assets/videos/default.mp4'); // 실제 감정에 따른 비디오 초기화 _initializeVideo(_currentEmotion); _addInitialMessage(); } (String, int) _getVideoInfo(int emotionCategory) { switch(emotionCategory) { case 1: return ('default.mp4', 10); // DEFAULT case 2: return ('joy.mp4', 8); // JOY case 3: return ('thinking.mp4', 10); // THINKING case 4: return ('realization.mp4', 10); // REALIZATION case 5: return ('angry.mp4', 10); // ANGER case 6: return ('thirst.mp4', 10); // THIRST case 7: return ('cold.mp4', 8); // COLD default: return ('default.mp4', 10); // 기본값 } } void _initializeVideo(int emotionCategory) { final (videoFileName, duration) = _getVideoInfo(emotionCategory); print('비디오 변경: emotion=$emotionCategory → $videoFileName (${duration}초)'); // 새 비디오 컨트롤러 생성 final newController = VideoPlayerController.asset('assets/videos/$videoFileName'); newController.initialize().then((_) { try { if (_videoController.hasListeners) { _videoController.dispose(); } } catch (e) { print('기존 컨트롤러 dispose 스킵: $e'); } _videoController = newController; _videoController.setLooping(true); _videoController.setVolume(0.0); _videoController.play(); setState(() { _isVideoInitialized = true; }); }).catchError((error) { print('비디오 로드 오류: $error'); }); } void _addInitialMessage() { setState(() { _messages.add( ChatMessage( text: '상태 분석이 완료되었습니다.\n무엇을 도와드릴까요?', isUser: false, timestamp: DateTime.now(), emotionCategory: _currentEmotion, // 감정 추가 ), ); }); } void _sendMessage(String text) async { if (text.isEmpty) return; // 사용자 메시지 추가 setState(() { _messages.add( ChatMessage( text: text, isUser: true, timestamp: DateTime.now(), emotionCategory: null, // 사용자 메시지는 감정값 없음 ), ); }); _textController.clear(); _scrollToBottom(); // 로딩 메시지 추가 (... 표시) setState(() { _messages.add( ChatMessage( text: '로딩 중...', isUser: false, timestamp: DateTime.now(), emotionCategory: _currentEmotion, // 현재 감정값 유지 ), ); _isLoading = true; }); _scrollToBottom(); // 서버로 메시지 전송 try { final (responseMessage, emotionCategory) = await ChatService.sendMessage(text); print('받은 감정값: $emotionCategory (기존: $_currentEmotion)'); setState(() { // 마지막 로딩 메시지를 실제 응답으로 교체 _messages.removeLast(); // 로딩 메시지 제거 // 감정값이 변경되었으면 비디오 변경 if (emotionCategory != _currentEmotion) { print('감정 변경! $emotionCategory로 비디오 교체'); _currentEmotion = emotionCategory; _isVideoInitialized = false; _initializeVideo(_currentEmotion); // 영상별 길이에 맞춰 DEFAULT로 복원 final (_, duration) = _getVideoInfo(emotionCategory); final delaySeconds = duration * 2; // 2번 재생 Future.delayed(Duration(seconds: delaySeconds), () { if (mounted) { print('DEFAULT로 복원'); _currentEmotion = 1; // DEFAULT = 1 _initializeVideo(1); } }); } _messages.add( ChatMessage( text: responseMessage, isUser: false, timestamp: DateTime.now(), emotionCategory: emotionCategory, ), ); _isLoading = false; }); _scrollToBottom(); } catch (e) { print('메시지 전송 오류: $e'); setState(() { _messages.removeLast(); _messages.add( ChatMessage( text: '오류가 발생했습니다. 다시 시도해주세요.', isUser: false, timestamp: DateTime.now(), emotionCategory: _currentEmotion, ), ); _isLoading = false; }); _scrollToBottom(); } } void _scrollToBottom() { Future.delayed(const Duration(milliseconds: 100), () { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); }); } void _sendQuickQuestion(String question) { _textController.text = question; _sendMessage(question); } @override void dispose() { _textController.dispose(); _scrollController.dispose(); _videoController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.transparent, body: Stack( children: [ Container( decoration: BoxDecoration( image: DecorationImage( image: AssetImage('assets/images/background.png'), fit: BoxFit.cover, ), ), child: Center( child: Container( margin: EdgeInsets.all(_getResponsivePadding(context, 30)), // 메인 컨테이너 여백 조정 decoration: BoxDecoration( color: Colors.white.withOpacity(0.8), // 투명도 넣기 borderRadius: BorderRadius.circular(36), // 모서리 둥글게 ), child: Row( children: [ // 좌측 캐릭터 영역 (50%) Expanded( flex: 5, child: Container( margin: EdgeInsets.fromLTRB( _getResponsivePadding(context, 30), _getResponsivePadding(context, 50), _getResponsivePadding(context, 30), _getResponsivePadding(context, 50), ), child: Column( children: [ // 헤더 Padding( padding: EdgeInsets.fromLTRB( _getResponsivePadding(context, 30), _getResponsivePadding(context, 20), _getResponsivePadding(context, 30), _getResponsivePadding(context, 15), ), child: Row( children: [ Container( width: _getResponsiveSize(context, 44), height: _getResponsiveSize(context, 44), decoration: BoxDecoration( color: Color(0xFFC8E6C9), borderRadius: BorderRadius.circular(_getResponsiveSize(context, 12)), ), child: Center( child: Text( '🌱', style: TextStyle(fontSize: _getResponsiveFontSize(context, 28)), ), ), ), SizedBox(width: _getResponsivePadding(context, 16)), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Smart Garden', style: TextStyle( fontSize: _getResponsiveFontSize(context, 20), fontWeight: FontWeight.w600, color: Color(0xFF212121), ), ), Text( '스마트가든 챗봇', style: TextStyle( fontSize: _getResponsiveFontSize(context, 16), color: Color(0xFF757575), ), ), ], ), ], ), ), // 캐릭터 영역 Expanded( child: Container( margin: EdgeInsets.fromLTRB( _getResponsivePadding(context, 30), _getResponsivePadding(context, 10), _getResponsivePadding(context, 30), _getResponsivePadding(context, 10), ), decoration: BoxDecoration( color: Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(35), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.1), // 그림자 색상 및 투명도 blurRadius: 15, // 그림자가 퍼지는 정도 (숫자가 클수록 부드러움) offset: const Offset(0, 5), // 그림자 위치 (x: 가로, y: 세로) ), ], ), child: AspectRatio( aspectRatio: 16 / 9, // 고정 종횡비 child: _isVideoInitialized ? ClipRRect( borderRadius: BorderRadius.circular(_getResponsiveSize(context, 30)), child: FittedBox( fit: BoxFit.cover, // 영상이 카드를 가득 채우고 넘치는 부분 잘라냄 child: SizedBox( width: _videoController.value.size.width, height: _videoController.value.size.height, child: VideoPlayer(_videoController), ), ), ) : Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: _getResponsiveSize(context, 180), height: _getResponsiveSize(context, 180), decoration: BoxDecoration( // color: Color(0xFFF1F8E9), borderRadius: BorderRadius.circular(_getResponsiveSize(context, 12)), border: Border.all( // color: Color(0xFFC8E6C9), width: 1, ), ), child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircularProgressIndicator( color: Color(0xFF81C784), ), SizedBox(height: _getResponsivePadding(context, 20)), Text( '비디오 로딩 중...', style: TextStyle( fontSize: _getResponsiveFontSize(context, 12), color: Color(0xFF757575), ), ), ], ), ), ), ], ), ), ), ), ), SizedBox(height: _getResponsivePadding(context, 16)), // SizedBox(height: _getResponsivePadding(context, 16)), //26.06.26 // 캐릭터 정보 + 설명 문구 // 1. 가장 바깥쪽 박스 추가 Container( margin: EdgeInsets.fromLTRB( _getResponsivePadding(context, 30), 0, _getResponsivePadding(context, 30), 0, ), padding: EdgeInsets.all(_getResponsivePadding(context, 24)), decoration: BoxDecoration( color: Colors.white.withOpacity(0.9), borderRadius: BorderRadius.circular(30), //border: Border.all(color: const Color(0xFFE0E0E0), width: 1), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.1), // 그림자 색상 (투명도 조절) blurRadius: 10, // 그림자가 퍼지는 정도 offset: const Offset(0, 4), // 그림자의 위치 (x, y) - 아래쪽으로 살짝 그림자 ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 48, height: 48, decoration: BoxDecoration( color: const Color(0xFFC8E6C9), borderRadius: BorderRadius.circular(24), ), child: ClipRRect( borderRadius: BorderRadius.circular(24), child: Image.asset('assets/images/profile.png', fit: BoxFit.cover), ), ), SizedBox(width: _getResponsivePadding(context, 12)), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('푸미', style: TextStyle(fontSize: _getResponsiveFontSize(context, 18), fontWeight: FontWeight.w600, color: Color(0xFF212121))), Text('스마트가든 AI 가이드', style: TextStyle(fontSize: _getResponsiveFontSize(context, 14), color: Color(0xFF757575))), ], ), ], ), SizedBox(height: _getResponsivePadding(context, 20)), Container( width: double.infinity, padding: EdgeInsets.all(_getResponsivePadding(context, 16)), decoration: BoxDecoration( color: const Color(0xFFF1F8E9), borderRadius: BorderRadius.circular(_getResponsiveSize(context, 12)), //border: Border.all(color: const Color(0xFFC8E6C9), width: 1), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.12), // 그림자 색상 및 투명도 blurRadius: 15, // 그림자가 퍼지는 정도 (숫자가 클수록 부드러움) offset: const Offset(0, 5), // 그림자 위치 (x: 가로, y: 세로) ), ], ), child: Text( '안녕하세요!\n스마트가든의 식물들이 건강하게 자랄 수 있도록 도와드릴게요.\n궁금한 점을 언제든 물어보세요!', style: TextStyle(fontSize: _getResponsiveFontSize(context, 13), color: Color(0xFF558B2F), height: 1.6), ), ), ], ), ), ], ), ), ), // 우측 채팅 영역 (50%) Expanded( flex: 5, child: Container( margin: EdgeInsets.only( top: _getResponsivePadding(context, 50), bottom: _getResponsivePadding(context, 50), right: _getResponsivePadding(context, 50), left: _getResponsivePadding(context, 10), ), decoration: BoxDecoration( color: Color(0xFFfbfdf8), borderRadius: BorderRadius.circular(_getResponsiveSize(context, 30)), // border: Border.all( // color: Color(0xFFE0E0E0), // width: 1, // ), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.1), // 그림자 색상 (투명도 조절) blurRadius: 10, // 그림자가 퍼지는 정도 offset: const Offset(0, 0), // 그림자 위치 (x: 가로, y: 세로) ), ], ), child: Column( children: [ // 헤더 Container( padding: EdgeInsets.all(_getResponsivePadding(context, 20)), decoration: BoxDecoration(), child: Row( children: [ Icon( Icons.eco, color: Color(0xFF81C784), size: _getResponsiveSize(context, 24), ), SizedBox(width: _getResponsivePadding(context, 12)), Text( '푸미 일지', style: TextStyle( fontSize: _getResponsiveFontSize(context, 22), fontWeight: FontWeight.w600, color: Color(0xFF212121), ), textAlign: TextAlign.left, ), ], ), ), // 채팅 메시지 영역 Expanded( child: Container( margin: EdgeInsets.symmetric( horizontal: _getResponsivePadding(context, 16), ), // decoration: BoxDecoration( // image: DecorationImage( // image: AssetImage( // 'assets/images/chat_img.png', // ), // // ← 배경 이미지 추가 // fit: BoxFit.cover, // colorFilter: ColorFilter.mode( // Colors.white.withOpacity(0.9), // // 채팅 배경 이미지 투명도 조정 // BlendMode.lighten, // ), // ), // color: Colors.white.withOpacity(0.5), // // 채팅창 투명도 // borderRadius: BorderRadius.circular(30), // border: Border.all( // color: Color(0xFFE0E0E0), // width: 0.5, // ), // boxShadow: [ // BoxShadow( // color: Colors.black.withOpacity(0.8), // 그림자 색상 (투명도 조절) // blurRadius: 10, // 그림자가 퍼지는 정도 // offset: const Offset(0, 0), // 그림자 위치 (x: 가로, y: 세로) // ), // ], // ), decoration: BoxDecoration( // 배경 이미지 설정 image: DecorationImage( image: AssetImage('assets/images/chat_img.png'), fit: BoxFit.cover, colorFilter: ColorFilter.mode( Colors.white.withOpacity(0.9), // 이미지 투명도 BlendMode.lighten, ), ), borderRadius: BorderRadius.circular(30), // 테두리(border) 제거됨 // 부드러운 그림자 추가 boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.1), // 아주 연한 그림자 blurRadius: 15, // 부드럽게 퍼짐 offset: const Offset(0, 5), // 아래쪽으로 은은하게 ), ], ), child: ListView.builder( controller: _scrollController, padding: EdgeInsets.all(_getResponsivePadding(context, 16)), itemCount: _messages.length, itemBuilder: (context, index) { final message = _messages[index]; final isLoadingMessage = _isLoading && index == _messages.length - 1 && !message.isUser; return _ChatBubble( message: message, isLoading: isLoadingMessage, ); }, ), ), ), // 자주하는 질문 버튼들을 감싸는 컨테이너 Container( margin: EdgeInsets.fromLTRB( _getResponsivePadding(context, 16), _getResponsivePadding(context, 12), _getResponsivePadding(context, 16), _getResponsivePadding(context, 12), ), padding: EdgeInsets.symmetric( horizontal: _getResponsivePadding(context, 6), vertical: _getResponsivePadding(context, 12), ), decoration: BoxDecoration( color: Color(0xFFecf6df).withOpacity(0.8), // 배경 borderRadius: BorderRadius.circular(_getResponsiveSize(context, 22)), ), child: Row( children: [ // 온도 정보 Expanded( child: Container( decoration: BoxDecoration( color: Colors.white, // ← 흰색 borderRadius: BorderRadius.circular(_getResponsiveSize(context, 25)), ), child: Material( color: Colors.transparent, child: InkWell( onTap: () => _sendQuickQuestion('현재 온도는?'), borderRadius: BorderRadius.circular( _getResponsiveSize(context, 25), ), child: Padding( padding: EdgeInsets.symmetric( horizontal: _getResponsivePadding(context, 12), vertical: _getResponsivePadding(context, 12), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: _getResponsiveSize(context, 32), height: _getResponsiveSize(context, 32), decoration: BoxDecoration( borderRadius: BorderRadius.circular( _getResponsiveSize(context, 6), ), ), child: Center( child: Icon( Icons.thermostat, color: Color(0xFF4CAF50), size: _getResponsiveSize(context, 25), ), ), ), SizedBox(width: _getResponsivePadding(context, 8)), Text( '온도 정보', style: TextStyle( fontSize: _getResponsiveFontSize(context, 18), fontWeight: FontWeight.w600, color: Colors.black, ), ), ], ), ), ), ), ), ), SizedBox(width: _getResponsivePadding(context, 8)), // 습도 정보 Expanded( child: Container( decoration: BoxDecoration( color: Colors.white, // ← 흰색 borderRadius: BorderRadius.circular(_getResponsiveSize(context, 25)), ), child: Material( color: Colors.transparent, child: InkWell( onTap: () => _sendQuickQuestion('현재 습도는?'), borderRadius: BorderRadius.circular( _getResponsiveSize(context, 25), ), child: Padding( padding: EdgeInsets.symmetric( horizontal: _getResponsivePadding(context, 12), vertical: _getResponsivePadding(context, 12), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: _getResponsiveSize(context, 32), height: _getResponsiveSize(context, 32), decoration: BoxDecoration( borderRadius: BorderRadius.circular( _getResponsiveSize(context, 6), ), ), child: Center( child: Icon( Icons.water_drop, color: Color(0xFF4CAF50), size: _getResponsiveSize(context, 25), ), ), ), SizedBox(width: _getResponsivePadding(context, 8)), Text( '습도 정보', style: TextStyle( fontSize: _getResponsiveFontSize(context, 18), fontWeight: FontWeight.w600, color: Color(0xFF000000), ), ), ], ), ), ), ), ), ), SizedBox(width: _getResponsivePadding(context, 8)), // 물 주기 Expanded( child: Container( decoration: BoxDecoration( color: Colors.white, // ← 흰색 borderRadius: BorderRadius.circular(_getResponsiveSize(context, 25)), ), child: Material( color: Colors.transparent, child: InkWell( onTap: () => _sendQuickQuestion('물을 주세요'), borderRadius: BorderRadius.circular( _getResponsiveSize(context, 25), ), child: Padding( padding: EdgeInsets.symmetric( horizontal: _getResponsivePadding(context, 12), vertical: _getResponsivePadding(context, 12), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: _getResponsiveSize(context, 32), height: _getResponsiveSize(context, 32), decoration: BoxDecoration( borderRadius: BorderRadius.circular( _getResponsiveSize(context, 6), ), ), child: Center( child: Icon( Icons.waves, color: Color(0xFF4CAF50), size: _getResponsiveSize(context, 25), ), ), ), SizedBox(width: _getResponsivePadding(context, 8)), Text( '물 주기', style: TextStyle( fontSize: _getResponsiveFontSize(context, 18), fontWeight: FontWeight.w600, color: Color(0xFF000000), ), ), ], ), ), ), ), ), ), ], ), ), // 입력 필드 + 전송 버튼 (같은 줄) Padding( padding: EdgeInsets.fromLTRB( _getResponsivePadding(context, 16), 0, _getResponsivePadding(context, 16), _getResponsivePadding(context, 16), ), child: Row( children: [ // 입력 필드 Expanded( child: Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(_getResponsiveSize(context, 22)), // border: Border.all( // color: Color(0xFFE0E0E0), // width: 0.5, // ), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.1), // 그림자 색상(연한 검정) blurRadius: 8, // 그림자가 퍼지는 정도 offset: const Offset(0, 2), // 그림자 위치 (x: 0, y: 2) - 아래로 살짝 ), ], ), child: TextField( controller: _textController, onSubmitted: (value) { _sendMessage( _textController.text, ); // 엔터로 전송 }, decoration: InputDecoration( hintText: '메시지를 입력하세요...', hintStyle: TextStyle( fontSize: _getResponsiveFontSize(context, 16), color: Color(0xFF9E9E9E), ), border: InputBorder.none, contentPadding: EdgeInsets.symmetric( horizontal: _getResponsivePadding(context, 16), vertical: _getResponsivePadding(context, 22), ), ), style: TextStyle(fontSize: _getResponsiveFontSize(context, 20)), maxLines: 1, ), ), ), SizedBox(width: _getResponsivePadding(context, 12)), // 전송 버튼 SizedBox( height: _getResponsiveSize(context, 60), width: _getResponsiveSize(context, 60), child: ElevatedButton( onPressed: () => _sendMessage(_textController.text), style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF81C784), elevation: 6, // 그림자 깊이 shadowColor: Colors.black.withOpacity(0.4), // 그림자 색상 shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(_getResponsiveSize(context, 100)), ), padding: EdgeInsets.zero, ), child: ImageIcon( AssetImage('assets/images/send.png'), color: Colors.white, size: _getResponsiveSize(context, 28), ), ), ), ], ), ), ], ), ), ), ], ), ), ), ), // Layer 이미지 (화면 전체) IgnorePointer( child: Image.asset( 'assets/images/layer_img.png', fit: BoxFit.cover, width: double.infinity, height: double.infinity, ), ), ], ), ); } } class _ChatBubble extends StatelessWidget { final ChatMessage message; final bool isLoading; const _ChatBubble({ required this.message, this.isLoading = false }); String _formatTime(DateTime dateTime) { final hour = dateTime.hour; final minute = dateTime.minute; final period = hour >= 12 ? '오후' : '오전'; final displayHour = hour > 12 ? hour - 12 : (hour == 0 ? 12 : hour); return '$period ${displayHour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}'; } @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.only( bottom: _getResponsivePadding(context, 30), ), child: Row( mainAxisAlignment: message.isUser ? MainAxisAlignment.end : MainAxisAlignment.start, children: [ if (!message.isUser) ...[ Container( width: 40, height: 40, decoration: BoxDecoration( color: Color(0xFFC8E6C9), borderRadius: BorderRadius.circular(20), ), child: ClipRRect( borderRadius: BorderRadius.circular(20), child: Image.asset( 'assets/images/profile.png', fit: BoxFit.cover, ), ), ), SizedBox(width: _getResponsivePadding(context, 10)), ], Flexible( child: Row( mainAxisAlignment: message.isUser ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.end, children: [ if (message.isUser) Text( _formatTime(message.timestamp), style: TextStyle( fontSize: _getResponsiveFontSize(context, 12), color: Color(0xFF9E9E9E), ), ), if (message.isUser) SizedBox(width: _getResponsivePadding(context, 8)), Flexible( child: Container( padding: EdgeInsets.symmetric( horizontal: _getResponsivePadding(context, 16), vertical: _getResponsivePadding(context, 10), ), decoration: BoxDecoration( color: message.isUser ? Color(0xFFecf6df) : Colors.white, borderRadius: BorderRadius.circular(_getResponsiveSize(context, 8)), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.1), blurRadius: 4, offset: const Offset(0, 2), ), ], ), child: isLoading ? _LoadingAnimation() // 로딩 중 : SelectableText( message.text, style: TextStyle( fontSize: _getResponsiveFontSize(context, 20), color: message.isUser ? Color(0xFF000000) : Color(0xFF424242), height: 1.4, ), ), ), ), if (!message.isUser) SizedBox(width: _getResponsivePadding(context, 8)), if (!message.isUser) Text( _formatTime(message.timestamp), style: TextStyle( fontSize: _getResponsiveFontSize(context, 12), color: Color(0xFF9E9E9E), ), ), ], ), ), ], ), ); } } class _LoadingAnimation extends StatefulWidget { const _LoadingAnimation(); @override State<_LoadingAnimation> createState() => _LoadingAnimationState(); } class _LoadingAnimationState extends State<_LoadingAnimation> with SingleTickerProviderStateMixin { late AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 900), //움직이는 속도 vsync: this, )..repeat(); } @override void dispose() { _controller.dispose(); super.dispose(); } double _getOffset(double progress, int index) { final delay = index * 0.15; final adjustedProgress = (progress + delay) % 1.0; return sin(adjustedProgress * pi * 2) * 2; } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (context, child) { final progress = _controller.value; return SizedBox( height: 20, // 일반 텍스트 높이와 동일 child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ _AnimatedDot( progress: progress, index: 0, getOffset: _getOffset, ), SizedBox(width: _getResponsiveSize(context, 3)), _AnimatedDot( progress: progress, index: 1, getOffset: _getOffset, ), SizedBox(width: _getResponsiveSize(context, 3)), _AnimatedDot( progress: progress, index: 2, getOffset: _getOffset, ), ], ), ); }, ); } } // 개별 원형 점 위젯 class _AnimatedDot extends StatelessWidget { final double progress; final int index; final double Function(double, int) getOffset; const _AnimatedDot({ required this.progress, required this.index, required this.getOffset, }); @override Widget build(BuildContext context) { final offset = getOffset(progress, index); return Transform.translate( offset: Offset(0, offset), child: SizedBox( //원형 사이즈 width: 5, height: 5, child: CustomPaint( painter: _DotPainter(), ), ), ); } } // CustomPaint로 원 그리기 class _DotPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = const Color(0xFF424242) ..style = PaintingStyle.fill; // 원의 중심과 반지름 final center = Offset(size.width / 2, size.height / 2); final radius = size.width / 2; canvas.drawCircle(center, radius, paint); } @override bool shouldRepaint(_DotPainter oldDelegate) => false; }