From 989910a3ffb5bfa72966e8210aeb2b72e6d7511e Mon Sep 17 00:00:00 2001 From: jieun Date: Fri, 19 Jun 2026 15:39:15 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=C3=A3=C2=85=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=C3=AC?= =?UTF-8?q?=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/main.dart | 892 +-------------------------------- lib/models/chat_message.dart | 29 ++ lib/screens/chat_screen.dart | 831 ++++++++++++++++++++++++++++++ lib/services/chat_service.dart | 117 +++++ pubspec.lock | 24 + pubspec.yaml | 2 + 6 files changed, 1010 insertions(+), 885 deletions(-) create mode 100644 lib/models/chat_message.dart create mode 100644 lib/screens/chat_screen.dart create mode 100644 lib/services/chat_service.dart diff --git a/lib/main.dart b/lib/main.dart index 308d3d1..a78a3fa 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:video_player/video_player.dart'; +import 'screens/chat_screen.dart'; void main() { runApp(const SmartGardenApp()); @@ -12,890 +12,12 @@ class SmartGardenApp extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp( title: 'Smart Garden', - theme: ThemeData(primarySwatch: Colors.green, useMaterial3: true), - home: const SmartGardenScreen(), + theme: ThemeData( + primarySwatch: Colors.green, + useMaterial3: true, + ), + home: const ChatScreen(), debugShowCheckedModeBanner: false, ); } -} - -class ChatMessage { - final String text; - final bool isUser; - final DateTime timestamp; - - ChatMessage({ - required this.text, - required this.isUser, - required this.timestamp, - }); -} - -class SmartGardenScreen extends StatefulWidget { - const SmartGardenScreen({Key? key}) : super(key: key); - - @override - State createState() => _SmartGardenScreenState(); -} - -class _SmartGardenScreenState extends State { - final TextEditingController _textController = TextEditingController(); - final List _messages = []; - final ScrollController _scrollController = ScrollController(); - - late VideoPlayerController _videoController; - bool _isVideoInitialized = false; - - @override - void initState() { - super.initState(); - _initializeVideo(); - _addInitialMessage(); - } - - void _initializeVideo() { - _videoController = - VideoPlayerController.asset('assets/videos/basic_img.mp4') - ..initialize() - .then((_) { - _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(), - ), - ); - }); - } - - void _sendMessage(String text) { - if (text.isEmpty) return; - - // 사용자 메시지 추가 - setState(() { - _messages.add( - ChatMessage(text: text, isUser: true, timestamp: DateTime.now()), - ); - }); - - _textController.clear(); - _scrollToBottom(); - - // 봇 응답 (0.5초 딜레이) - Future.delayed(const Duration(milliseconds: 500), () { - setState(() { - _messages.add( - ChatMessage( - text: '안녕하세요 스마트가든 AI 가이드 푸미입니다', - isUser: false, - timestamp: DateTime.now(), - ), - ); - }); - _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/background1.png'), - fit: BoxFit.cover, - ), - ), - child: Center( - child: Container( - margin: const EdgeInsets.all(40), // 메인 컨테이너 여백 조정 - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.8), // 투명도 넣기 - borderRadius: BorderRadius.circular(16), // 모서리 둥글게 - border: Border.all(color: Color(0xFFE0E0E0), width: 1), - ), - child: Row( - children: [ - // 좌측 캐릭터 영역 (60%) - Expanded( - flex: 6, - child: Container( - margin: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: Color(0xFFE0E0E0), - width: 1, - ), - ), - child: Column( - children: [ - // 헤더 - Padding( - padding: const EdgeInsets.all(30), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: Color(0xFFC8E6C9), - borderRadius: BorderRadius.circular(12), - ), - child: const Center( - child: Text( - '🌱', - style: TextStyle(fontSize: 28), - ), - ), - ), - const SizedBox(width: 16), - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - const Text( - 'Smart Garden', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, - color: Color(0xFF212121), - ), - ), - Text( - '스마트가든 챗봇', - style: TextStyle( - fontSize: 16, - color: Color(0xFF757575), - ), - ), - ], - ), - ], - ), - ), - // 캐릭터 영역 - Expanded( - child: Container( - margin: const EdgeInsets.symmetric( - horizontal: 20, - ), - decoration: BoxDecoration( - color: Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(12), - ), - child: _isVideoInitialized - ? ClipRRect( - borderRadius: BorderRadius.circular(12), - child: AspectRatio( - aspectRatio: _videoController - .value - .aspectRatio, - child: VideoPlayer(_videoController), - ), - ) - : Column( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Container( - width: 180, - height: 180, - decoration: BoxDecoration( - color: Color(0xFFF1F8E9), - borderRadius: - BorderRadius.circular(12), - border: Border.all( - color: Color(0xFFC8E6C9), - width: 1, - ), - ), - child: const Center( - child: Column( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - CircularProgressIndicator( - color: Color(0xFF81C784), - ), - SizedBox(height: 20), - Text( - '비디오 로딩 중...', - style: TextStyle( - fontSize: 12, - color: Color(0xFF757575), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 40), - // 캐릭터 정보 + 설명 문구 - Padding( - padding: const EdgeInsets.only( - left: 30, - bottom: 30, - right: 30, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 푸미 정보 - Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: Color(0xFFC8E6C9), - // ← 연한 초록 배경 추가 - borderRadius: BorderRadius.circular( - 24, - ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular( - 24, - ), - child: Image.asset( - 'assets/images/profile.png', - fit: BoxFit.cover, - ), - ), - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - const Text( - '푸미', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, - color: Color(0xFF212121), - ), - ), - Text( - '스마트가든 AI 가이드', - style: TextStyle( - fontSize: 14, - color: Color(0xFF757575), - ), - ), - ], - ), - ], - ), - const SizedBox(height: 16), - // 설명 문구 (반응형) - Container( - width: double.infinity, // ← 추가: 양쪽 꽉차게 - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Color(0xFFF1F8E9), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Color(0xFFC8E6C9), - width: 1, - ), - ), - child: const Text( - '안녕하세요!\n스마트가든의 식물들이 건강하게 자랄 수 있도록 도와드릴게요.\n궁금한 점을 언제든 물어보세요!', - style: TextStyle( - fontSize: 13, - color: Color(0xFF558B2F), - height: 1.6, - ), - ), - ), - ], - ), - ), - ], - ), - ), - ), - // 우측 채팅 영역 (40%) - Expanded( - flex: 4, - child: Container( - margin: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Color(0xFFFAFAFA), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: Color(0xFFE0E0E0), - width: 1, - ), - ), - child: Column( - children: [ - // 헤더 - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration(), - child: Row( - children: [ - const Icon( - Icons.eco, - color: Color(0xFF81C784), - size: 24, - ), - const SizedBox(width: 12), - const Text( - '푸미 일지', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.w600, - color: Color(0xFF212121), - ), - textAlign: TextAlign.left, - ), - ], - ), - ), - // 채팅 메시지 영역 - Expanded( - child: Container( - margin: const EdgeInsets.symmetric( - horizontal: 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(8), - border: Border.all( - color: Color(0xFFE0E0E0), - width: 0.5, - ), - ), - child: ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(16), - itemCount: _messages.length, - itemBuilder: (context, index) { - final message = _messages[index]; - return _ChatBubble(message: message); - }, - ), - ), - ), - // 자주하는 질문 버튼들을 감싸는 컨테이너 - Container( - margin: const EdgeInsets.fromLTRB(16, 12, 16, 12), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Color(0xFFC8E6C9).withOpacity(0.3), // 배경 - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - // 온도 정보 - Expanded( - child: Container( - decoration: BoxDecoration( - color: Colors.white, // ← 흰색 - borderRadius: BorderRadius.circular(25), - border: Border.all( - color: Color( - 0xFF1976D2, - ).withOpacity(0.2), - width: 0.5, - ), - ), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => - _sendQuickQuestion('현재 온도는?'), - borderRadius: BorderRadius.circular( - 25, - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 12, - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - borderRadius: - BorderRadius.circular( - 6, - ), - ), - child: const Center( - child: Icon( - Icons.thermostat, - color: Color(0xFF4CAF50), - size: 25, - ), - ), - ), - const SizedBox(width: 8), - Text( - '온도 정보', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Color(0xFF000000), - ), - ), - ], - ), - ), - ), - ), - ), - ), - const SizedBox(width: 8), - // 습도 정보 - Expanded( - child: Container( - decoration: BoxDecoration( - color: Colors.white, // ← 흰색 - borderRadius: BorderRadius.circular(25), - border: Border.all( - color: Color( - 0xFF388E3C, - ).withOpacity(0.2), - width: 0.5, - ), - ), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => - _sendQuickQuestion('현재 습도는?'), - borderRadius: BorderRadius.circular( - 25, - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 12, - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - borderRadius: - BorderRadius.circular( - 6, - ), - ), - child: const Center( - child: Icon( - Icons.water_drop, - color: Color(0xFF4CAF50), - size: 25, - ), - ), - ), - const SizedBox(width: 8), - Text( - '습도 정보', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Color(0xFF000000), - ), - ), - ], - ), - ), - ), - ), - ), - ), - const SizedBox(width: 8), - // 물 주기 - Expanded( - child: Container( - decoration: BoxDecoration( - color: Colors.white, // ← 흰색 - borderRadius: BorderRadius.circular(25), - border: Border.all( - color: Color( - 0xFFFFA000, - ).withOpacity(0.2), - width: 0.5, - ), - ), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => - _sendQuickQuestion('물을 주세요'), - borderRadius: BorderRadius.circular( - 25, - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 12, - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - borderRadius: - BorderRadius.circular( - 6, - ), - ), - child: const Center( - child: Icon( - Icons.waves, - color: Color(0xFF4CAF50), - size: 25, - ), - ), - ), - const SizedBox(width: 8), - Text( - '물 주기', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Color(0xFF000000), - ), - ), - ], - ), - ), - ), - ), - ), - ), - ], - ), - ), - // 입력 필드 + 전송 버튼 (같은 줄) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Row( - children: [ - // 입력 필드 - Expanded( - child: Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Color(0xFFE0E0E0), - width: 0.5, - ), - ), - child: TextField( - controller: _textController, - onSubmitted: (value) { - _sendMessage( - _textController.text, - ); // 엔터로 전송 - }, - decoration: InputDecoration( - hintText: '메시지를 입력하세요...', - hintStyle: TextStyle( - fontSize: 20, - color: Color(0xFF9E9E9E), - ), - border: InputBorder.none, - contentPadding: - const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - style: const TextStyle(fontSize: 20), - maxLines: 1, - ), - ), - ), - const SizedBox(width: 12), - // 전송 버튼 - SizedBox( - height: 48, - width: 60, - child: ElevatedButton( - onPressed: () { - _sendMessage(_textController.text); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Color(0xFF81C784), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - 8, - ), - ), - padding: EdgeInsets.zero, - ), - child: const Text( - '전송', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ), - ), - ], - ), - ), - ], - ), - ), - ), - ], - ), - ), - ), - ), - // Layer 이미지 (화면 전체, 클릭 불가) - IgnorePointer( - child: Image.asset( - 'assets/images/layer_img1.png', - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - ), - ), - ], - ), - ); - } -} - -class _ChatBubble extends StatelessWidget { - final ChatMessage message; - - const _ChatBubble({required this.message}); - - 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: const EdgeInsets.only(bottom: 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), // ← 원형 (8 → 20) - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), // ← 원형 (8 → 20) - child: Image.asset( - 'assets/images/profile.png', - fit: BoxFit.cover, - ), - ), - ), - const SizedBox(width: 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: 12, color: Color(0xFF9E9E9E)), - ), - if (message.isUser) const SizedBox(width: 8), - Flexible( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - decoration: BoxDecoration( - color: message.isUser ? Color(0xFFE8F5E9) : Colors.white, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: SelectableText( - message.text, - style: TextStyle( - fontSize: 20, - color: message.isUser - ? Color(0xFF000000) - : Color(0xFF424242), - height: 1.4, - ), - ), - ), - ), - if (!message.isUser) const SizedBox(width: 8), - if (!message.isUser) - Text( - _formatTime(message.timestamp), - style: TextStyle(fontSize: 12, color: Color(0xFF9E9E9E)), - ), - ], - ), - ), - ], - ), - ); - } -} - -class _QuickButton extends StatelessWidget { - final String emoji; - final String title; - final String subtitle; - final Color bgColor; - final Color textColor; - final VoidCallback onPressed; - - const _QuickButton({ - required this.emoji, - required this.title, - required this.subtitle, - required this.bgColor, - required this.textColor, - required this.onPressed, - }); - - @override - Widget build(BuildContext context) { - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(8), - child: Container( - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: textColor.withOpacity(0.2), width: 0.5), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Column( - children: [ - Text(emoji, style: const TextStyle(fontSize: 18)), - const SizedBox(height: 4), - Text( - title, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: textColor, - ), - textAlign: TextAlign.center, - ), - if (subtitle.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - subtitle, - style: TextStyle( - fontSize: 10, - color: textColor.withOpacity(0.8), - ), - textAlign: TextAlign.center, - ), - ], - ], - ), - ), - ), - ); - } -} +} \ No newline at end of file diff --git a/lib/models/chat_message.dart b/lib/models/chat_message.dart new file mode 100644 index 0000000..c9981e9 --- /dev/null +++ b/lib/models/chat_message.dart @@ -0,0 +1,29 @@ +class ChatMessage { + final String text; + final bool isUser; + final DateTime timestamp; + + ChatMessage({ + required this.text, + required this.isUser, + required this.timestamp, + }); + + // JSON에서 ChatMessage로 변환 + factory ChatMessage.fromJson(Map json) { + return ChatMessage( + text: json['text'] as String, + isUser: json['isUser'] as bool, + timestamp: DateTime.parse(json['timestamp'] as String), + ); + } + + // ChatMessage를 JSON으로 변환 + Map toJson() { + return { + 'text': text, + 'isUser': isUser, + 'timestamp': timestamp.toIso8601String(), + }; + } +} \ No newline at end of file diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart new file mode 100644 index 0000000..ac1814a --- /dev/null +++ b/lib/screens/chat_screen.dart @@ -0,0 +1,831 @@ +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; +import '../models/chat_message.dart'; +import '../services/chat_service.dart'; + +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; + + @override + void initState() { + super.initState(); + _initializeVideo(); + _addInitialMessage(); + + } + + void _initializeVideo() { + _videoController = + VideoPlayerController.asset('assets/videos/basic_img.mp4') + ..initialize() + .then((_) { + _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(), + ), + ); + }); + } + + void _sendMessage(String text) async { + if (text.isEmpty) return; + + // 사용자 메시지 추가 + setState(() { + _messages.add( + ChatMessage(text: text, isUser: true, timestamp: DateTime.now()), + ); + _isLoading = true; + }); + + _textController.clear(); + _scrollToBottom(); + + // 서버로 메시지 전송 + try { + final response = await ChatService.sendMessage(text); + + setState(() { + _messages.add( + ChatMessage( + text: response, + isUser: false, + timestamp: DateTime.now(), + ), + ); + _isLoading = false; + }); + _scrollToBottom(); + } catch (e) { + print('메시지 전송 오류: $e'); + setState(() { + _messages.add( + ChatMessage( + text: '오류가 발생했습니다. 다시 시도해주세요.', + isUser: false, + timestamp: DateTime.now(), + ), + ); + _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/background1.png'), + fit: BoxFit.cover, + ), + ), + child: Center( + child: Container( + margin: const EdgeInsets.all(40), // 메인 컨테이너 여백 조정 + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.8), // 투명도 넣기 + borderRadius: BorderRadius.circular(16), // 모서리 둥글게 + border: Border.all(color: Color(0xFFE0E0E0), width: 1), + ), + child: Row( + children: [ + // 좌측 캐릭터 영역 (60%) + Expanded( + flex: 6, + child: Container( + margin: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Color(0xFFE0E0E0), + width: 1, + ), + ), + child: Column( + children: [ + // 헤더 + Padding( + padding: const EdgeInsets.all(30), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: Color(0xFFC8E6C9), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Text( + '🌱', + style: TextStyle(fontSize: 28), + ), + ), + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + const Text( + 'Smart Garden', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Color(0xFF212121), + ), + ), + Text( + '스마트가든 챗봇', + style: TextStyle( + fontSize: 16, + color: Color(0xFF757575), + ), + ), + ], + ), + ], + ), + ), + // 캐릭터 영역 + Expanded( + child: Container( + margin: const EdgeInsets.symmetric( + horizontal: 20, + ), + decoration: BoxDecoration( + color: Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(12), + ), + child: _isVideoInitialized + ? ClipRRect( + borderRadius: BorderRadius.circular(12), + child: AspectRatio( + aspectRatio: _videoController + .value + .aspectRatio, + child: VideoPlayer(_videoController), + ), + ) + : Column( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Container( + width: 180, + height: 180, + decoration: BoxDecoration( + color: Color(0xFFF1F8E9), + borderRadius: + BorderRadius.circular(12), + border: Border.all( + color: Color(0xFFC8E6C9), + width: 1, + ), + ), + child: const Center( + child: Column( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + color: Color(0xFF81C784), + ), + SizedBox(height: 20), + Text( + '비디오 로딩 중...', + style: TextStyle( + fontSize: 12, + color: Color(0xFF757575), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 40), + // 캐릭터 정보 + 설명 문구 + Padding( + padding: const EdgeInsets.only( + left: 30, + bottom: 30, + right: 30, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 푸미 정보 + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Color(0xFFC8E6C9), + // ← 연한 초록 배경 추가 + borderRadius: BorderRadius.circular( + 24, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular( + 24, + ), + child: Image.asset( + 'assets/images/profile.png', + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + const Text( + '푸미', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Color(0xFF212121), + ), + ), + Text( + '스마트가든 AI 가이드', + style: TextStyle( + fontSize: 14, + color: Color(0xFF757575), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 16), + // 설명 문구 (반응형) + Container( + width: double.infinity, // ← 추가: 양쪽 꽉차게 + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Color(0xFFF1F8E9), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Color(0xFFC8E6C9), + width: 1, + ), + ), + child: const Text( + '안녕하세요!\n스마트가든의 식물들이 건강하게 자랄 수 있도록 도와드릴게요.\n궁금한 점을 언제든 물어보세요!', + style: TextStyle( + fontSize: 13, + color: Color(0xFF558B2F), + height: 1.6, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + // 우측 채팅 영역 (40%) + Expanded( + flex: 4, + child: Container( + margin: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Color(0xFFFAFAFA), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Color(0xFFE0E0E0), + width: 1, + ), + ), + child: Column( + children: [ + // 헤더 + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration(), + child: Row( + children: [ + const Icon( + Icons.eco, + color: Color(0xFF81C784), + size: 24, + ), + const SizedBox(width: 12), + const Text( + '푸미 일지', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w600, + color: Color(0xFF212121), + ), + textAlign: TextAlign.left, + ), + ], + ), + ), + // 채팅 메시지 영역 + Expanded( + child: Container( + margin: const EdgeInsets.symmetric( + horizontal: 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(8), + border: Border.all( + color: Color(0xFFE0E0E0), + width: 0.5, + ), + ), + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16), + itemCount: _messages.length, + itemBuilder: (context, index) { + final message = _messages[index]; + return _ChatBubble(message: message); + }, + ), + ), + ), + // 자주하는 질문 버튼들을 감싸는 컨테이너 + Container( + margin: const EdgeInsets.fromLTRB(16, 12, 16, 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Color(0xFFC8E6C9).withOpacity(0.3), // 배경 + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + // 온도 정보 + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.white, // ← 흰색 + borderRadius: BorderRadius.circular(25), + border: Border.all( + color: Color( + 0xFF1976D2, + ).withOpacity(0.2), + width: 0.5, + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => + _sendQuickQuestion('현재 온도는?'), + borderRadius: BorderRadius.circular( + 25, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular( + 6, + ), + ), + child: const Center( + child: Icon( + Icons.thermostat, + color: Color(0xFF4CAF50), + size: 25, + ), + ), + ), + const SizedBox(width: 8), + Text( + '온도 정보', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF000000), + ), + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox(width: 8), + // 습도 정보 + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.white, // ← 흰색 + borderRadius: BorderRadius.circular(25), + border: Border.all( + color: Color( + 0xFF388E3C, + ).withOpacity(0.2), + width: 0.5, + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => + _sendQuickQuestion('현재 습도는?'), + borderRadius: BorderRadius.circular( + 25, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular( + 6, + ), + ), + child: const Center( + child: Icon( + Icons.water_drop, + color: Color(0xFF4CAF50), + size: 25, + ), + ), + ), + const SizedBox(width: 8), + Text( + '습도 정보', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF000000), + ), + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox(width: 8), + // 물 주기 + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.white, // ← 흰색 + borderRadius: BorderRadius.circular(25), + border: Border.all( + color: Color( + 0xFFFFA000, + ).withOpacity(0.2), + width: 0.5, + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => + _sendQuickQuestion('물을 주세요'), + borderRadius: BorderRadius.circular( + 25, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular( + 6, + ), + ), + child: const Center( + child: Icon( + Icons.waves, + color: Color(0xFF4CAF50), + size: 25, + ), + ), + ), + const SizedBox(width: 8), + Text( + '물 주기', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF000000), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ), + ), + // 입력 필드 + 전송 버튼 (같은 줄) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Row( + children: [ + // 입력 필드 + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Color(0xFFE0E0E0), + width: 0.5, + ), + ), + child: TextField( + controller: _textController, + onSubmitted: (value) { + _sendMessage( + _textController.text, + ); // 엔터로 전송 + }, + decoration: InputDecoration( + hintText: '메시지를 입력하세요...', + hintStyle: TextStyle( + fontSize: 20, + color: Color(0xFF9E9E9E), + ), + border: InputBorder.none, + contentPadding: + const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + style: const TextStyle(fontSize: 20), + maxLines: 1, + ), + ), + ), + const SizedBox(width: 12), + // 전송 버튼 + SizedBox( + height: 48, + width: 60, + child: ElevatedButton( + onPressed: () { + _sendMessage(_textController.text); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xFF81C784), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 8, + ), + ), + padding: EdgeInsets.zero, + ), + child: const Text( + '전송', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + // Layer 이미지 (화면 전체) + IgnorePointer( + child: Image.asset( + 'assets/images/layer_img1.png', + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ), + ), + ], + ), + ); + } +} + +class _ChatBubble extends StatelessWidget { + final ChatMessage message; + + const _ChatBubble({required this.message}); + + 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: const EdgeInsets.only(bottom: 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, + ), + ), + ), + const SizedBox(width: 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: 12, color: Color(0xFF9E9E9E)), + ), + if (message.isUser) const SizedBox(width: 8), + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + decoration: BoxDecoration( + color: message.isUser + ? Color(0xFFE8F5E9) + : Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: SelectableText( + message.text, + style: TextStyle( + fontSize: 20, + color: message.isUser + ? Color(0xFF000000) + : Color(0xFF424242), + height: 1.4, + ), + ), + ), + ), + if (!message.isUser) const SizedBox(width: 8), + if (!message.isUser) + Text( + _formatTime(message.timestamp), + style: TextStyle(fontSize: 12, color: Color(0xFF9E9E9E)), + ), + ], + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/services/chat_service.dart b/lib/services/chat_service.dart new file mode 100644 index 0000000..fe9e5a7 --- /dev/null +++ b/lib/services/chat_service.dart @@ -0,0 +1,117 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import '../models/chat_message.dart'; + +class ChatService { + // 서버 URL + static const String serverUrl = 'http://49.238.167.71:8000/chat/'; + + // 타임아웃 설정 (초) + static const int timeoutSeconds = 30; + + /// 사용자 메시지를 서버로 전송하고 응답을 받는 메서드 + static Future sendMessage(String userMessage) async { + try { + // 요청 생성 + final requestBody = { + 'device_id': 1, + 'locale': 'ko-KR', + 'message': userMessage, + 'session_id': 'session-001', + 'user_id': 'user-123', + }; + + // JSON 형식 확인 + final jsonBody = jsonEncode(requestBody); + print('서버로 요청 전송 (Map): $requestBody'); + print('서버로 요청 전송 (JSON): $jsonBody'); + + final uri = Uri.parse(serverUrl); + print('요청 URI: $uri'); + print('요청 헤더: {Content-Type: application/json, accept: application/json}'); + print('요청 바디: ${jsonEncode(requestBody)}'); + + // POST 요청 전송 + final response = await http.post( + Uri.parse(serverUrl), + headers: { + 'Content-Type': 'application/json', + 'accept': 'application/json', + }, + body: jsonEncode(requestBody), + ).timeout( + const Duration(seconds: timeoutSeconds), + onTimeout: () => throw TimeoutException('서버 응답 시간 초과'), + ); + + print('서버 응답 상태코드: ${response.statusCode}'); + print('서버 응답 본문: ${response.body}'); + + // 상태 코드 확인 + if (response.statusCode == 200) { + final jsonResponse = jsonDecode(response.body); + + print('전체 응답: $jsonResponse'); + + // 'message' 필드 추출 + final responseMessage = jsonResponse['message'] as String?; + + if (responseMessage != null && responseMessage.isNotEmpty) { + print('응답 메시지: $responseMessage'); + return responseMessage; + } else { + print('message 필드가 없습니다'); + // 혹시 다른 필드에 응답이 있는지 확인 + print('응답 전체: ${jsonResponse.toString()}'); + return '응답을 받지 못했습니다'; + } + } else { + // 실패 + print('서버 오류: ${response.statusCode}'); + return '서버 오류 (${response.statusCode})'; + } + } on TimeoutException catch (e) { + print('타임아웃: $e'); + return '서버 응답이 없습니다. 시간 초과'; + } on FormatException catch (e) { + print('JSON 파싱 오류: $e'); + return 'JSON 형식 오류'; + } catch (e) { + print('오류 발생: $e'); + return '오류가 발생했습니다: $e'; + } + } + + /// 서버 연결 테스트 + static Future testConnection() async { + try { + print('서버 연결 테스트 중...'); + + final response = await http.get( + Uri.parse(serverUrl), + ).timeout( + const Duration(seconds: timeoutSeconds), + ); + + if (response.statusCode == 200 || response.statusCode == 405) { + print('서버 연결 성공'); + return true; + } else { + print('서버 연결 실패: ${response.statusCode}'); + return false; + } + } catch (e) { + print('서버 연결 오류: $e'); + return false; + } + } +} + +/// 타임아웃 예외 +class TimeoutException implements Exception { + final String message; + TimeoutException(this.message); + + @override + String toString() => message; +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 172a1c9..6f60961 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -112,6 +112,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.15.6" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" leak_tracker: dependency: transitive description: @@ -237,6 +253,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.6" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 27eca92..12a15bf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,6 +28,8 @@ environment: # the latest version available on pub.dev. To see which dependencies have newer # versions available, run `flutter pub outdated`. dependencies: + http: ^1.1.0 + flutter: sdk: flutter