diff --git a/assets/images/profile.png b/assets/images/profile.png new file mode 100644 index 0000000..308702e Binary files /dev/null and b/assets/images/profile.png differ diff --git a/lib/main.dart b/lib/main.dart index 13dad2c..b5a6150 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,109 +1,740 @@ import 'package:flutter/material.dart'; -import 'package:smartgarden_chat/widgets/sidebar.dart'; -import 'package:smartgarden_chat/widgets/header.dart'; -import 'package:smartgarden_chat/widgets/main_dashboard.dart'; +import 'package:video_player/video_player.dart'; -import 'package:smartgarden_chat/widgets/temp_data_page.dart'; -import 'package:smartgarden_chat/widgets/humid_data_page.dart'; -import 'package:smartgarden_chat/widgets/co2_data_page.dart'; -import 'package:smartgarden_chat/widgets/tvoc_data_page.dart'; -import 'package:smartgarden_chat/widgets/dust_data_page.dart'; -import 'package:smartgarden_chat/widgets/device_manage_page.dart'; - -import 'package:smartgarden_chat/widgets/chatbot_detail_page.dart'; - -void main() => runApp(const SmartGardenApp()); +void main() { + runApp(const SmartGardenApp()); +} class SmartGardenApp extends StatelessWidget { - const SmartGardenApp({super.key}); + const SmartGardenApp({Key? key}) : super(key: key); + @override Widget build(BuildContext context) { return MaterialApp( + title: 'Smart Garden', + theme: ThemeData( + primarySwatch: Colors.green, + useMaterial3: true, + ), + home: const SmartGardenScreen(), debugShowCheckedModeBanner: false, - theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: const Color(0xFF0F1115)), - home: const MainDashboardScreen(), ); } } -class MainDashboardScreen extends StatefulWidget { - const MainDashboardScreen({super.key}); - @override - State createState() => _MainDashboardScreenState(); +class ChatMessage { + final String text; + final bool isUser; + final DateTime timestamp; + + ChatMessage({ + required this.text, + required this.isUser, + required this.timestamp, + }); } -class _MainDashboardScreenState extends State { - // ✨ 초기값을 false로 설정하여 앱 시작 시 사이드바가 숨겨진 상태로 만듭니다. - bool isSidebarOpen = false; - String selectedPage = '스마트가든 챗봇'; - bool isChatbotDetail = false; +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.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.white, body: Row( children: [ - AnimatedContainer( - duration: const Duration(milliseconds: 250), - curve: Curves.easeInOut, - width: isSidebarOpen ? 260 : 0, - child: Sidebar( - isSidebarOpen: isSidebarOpen, - selectedPage: selectedPage, - onPageChanged: (page) { - setState(() { - selectedPage = page; - isChatbotDetail = false; // 다른 메뉴 클릭 시 상세 뷰 초기화 - }); - }, + // 좌측 캐릭터 영역 (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( + borderRadius: BorderRadius.circular(12), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + 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, // 16 → 18 + fontWeight: FontWeight.w600, + color: Color(0xFF212121), + ), + ), + Text( + '스마트가든 AI 가이드', + style: TextStyle( + fontSize: 18, // 12 → 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( - child: Column( - children: [ - Header( - title: isChatbotDetail ? '$selectedPage - 상세' : selectedPage, - isSidebarOpen: isSidebarOpen, - onToggleSidebar: () => setState(() => isSidebarOpen = !isSidebarOpen), - ), - Expanded( - child: Container( - padding: const EdgeInsets.all(24.0), - color: const Color(0xFF13161A), - child: _buildBody(), // ✨ 개인 위젯 호출 통로 + 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( + color: Colors.white, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + border: Border( + bottom: BorderSide( + color: Color(0xFFE0E0E0), + width: 1, + ), + ), + ), + child: 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( + color: Colors.white, + 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); + }, + ), + ), + ), + // 자주하는 질문 버튼들 (채팅 아래, 입력창 위) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Row( + children: [ + // 온도 정보 + Expanded( + child: Container( + decoration: BoxDecoration( + color: Color(0xFFBBDEFB), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Color(0xFF1976D2).withOpacity(0.2), + width: 0.5, + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _sendQuickQuestion('현재 온도는?'), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + '🌡️', + style: TextStyle(fontSize: 20), + ), + const SizedBox(width: 8), + Text( + '온도 정보', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF1976D2), + ), + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox(width: 8), + // 습도 정보 + Expanded( + child: Container( + decoration: BoxDecoration( + color: Color(0xFFC8E6C9), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Color(0xFF388E3C).withOpacity(0.2), + width: 0.5, + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _sendQuickQuestion('현재 습도는?'), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + '💧', + style: TextStyle(fontSize: 20), + ), + const SizedBox(width: 8), + Text( + '습도 정보', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF388E3C), + ), + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox(width: 8), + // 물 주기 + Expanded( + child: Container( + decoration: BoxDecoration( + color: Color(0xFFFFE0B2), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Color(0xFFFFA000).withOpacity(0.2), + width: 0.5, + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _sendQuickQuestion('물을 주세요'), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + '💦', + style: TextStyle(fontSize: 20), + ), + const SizedBox(width: 8), + Text( + '물 주기', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFFFFA000), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ), + ), + // 입력 필드 + 전송 버튼 (같은 줄) + 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, + ), + ), + ), + ), + ], + ), + ), + ], + ), ), ), ], ), ); } +} - // ✨ 질문자님이 정의하신 아코디언 메뉴명과 개인 위젯 매핑 분기점 - Widget _buildBody() { - switch (selectedPage) { - case '스마트가든 챗봇': - return isChatbotDetail - ? ChatbotDetailPage(onBack: () => setState(() => isChatbotDetail = false)) - : DashboardGrid(onCharacterTap: () => setState(() => isChatbotDetail = true)); - case '온도 데이터': - return const TempDataPage(); - case '습도 데이터': - return const HumidDataPage(); - case '이산화탄소 데이터': - return const Co2DataPage(); - case 'TVOC 데이터': - return const TvocDataPage(); - case '미세먼지 데이터': - return const DustDataPage(); - case '장비 관리': - return const DeviceManagePage(); - default: - return isChatbotDetail - ? ChatbotDetailPage(onBack: () => setState(() => isChatbotDetail = false)) - : DashboardGrid(onCharacterTap: () => setState(() => isChatbotDetail = true)); - } +class _ChatBubble extends StatelessWidget { + final ChatMessage message; + + const _ChatBubble({required this.message}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + mainAxisAlignment: + message.isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + children: [ + if (!message.isUser) ...[ + Container( + width: 40, // 32 → 40 + height: 40, // 32 → 40 + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.asset( + 'assets/images/profile.png', + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(width: 10), // 8 → 10 + ], + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + decoration: BoxDecoration( + color: message.isUser + ? Color(0xFFBBDEFB) + : Color(0xFFEEEEEE), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + message.text, + style: TextStyle( + fontSize: 20, + color: message.isUser + ? Color(0xFF1976D2) + : Color(0xFF424242), + height: 1.4, + ), + ), + ), + ), + if (message.isUser) const SizedBox(width: 8), + ], + ), + ); + } +} + +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/services/temp_data_service.dart b/lib/services/temp_data_service.dart deleted file mode 100644 index 066e78a..0000000 --- a/lib/services/temp_data_service.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -/// 📌 [독립 함수] 지난 24시간 치의 가상(더미) 온도 데이터 리스트를 생성한다. -/// -/// 실제 DB 연동 전, 개발 단계에서 차트/리스트 UI를 채우기 위한 용도입니다. -/// 값이 들쭉날쭉 튀지 않고 자연스럽게 보이도록, 직전 값에서 조금씩만 -/// 변하는 "랜덤 워크(random walk)" 방식으로 만듭니다. -/// -/// - [count] : 만들 데이터 개수 (기본 24개 = 1시간 간격 24시간) -/// - [minTemp] : 가상 온도 하한 -/// - [maxTemp] : 가상 온도 상한 -List generateDummyTemperatures({ - int count = 24, - double minTemp = 18.0, - double maxTemp = 28.0, -}) { - final random = Random(); - final list = []; - - // 시작 온도는 범위 내 임의 값 - double last = minTemp + random.nextDouble() * (maxTemp - minTemp); - - for (int i = 0; i < count; i++) { - // 직전 값에서 -0.5 ~ +0.5°C 내외로 흔들기 (clamp는 num 반환 → toDouble) - last = (last + (random.nextDouble() - 0.5)).clamp(minTemp, maxTemp).toDouble(); - list.add(double.parse(last.toStringAsFixed(1))); // 소수점 1자리로 정리 - } - return list; -} - -/// 📡 [스트림 서비스] 앱이 켜져 있는 동안 일정 주기마다 -/// 새로운 가상 온도 데이터를 계속 추가하며 갱신된 리스트를 흘려보낸다. -/// -/// 사용 흐름: -/// final service = TempDataService()..start(); -/// service.stream.listen((temps) { ... }); // StreamBuilder로 구독 -/// service.dispose(); // 화면이 사라질 때 반드시 호출 -/// -/// 👉 나중에 실제 DB 연동 시에는 [_generateNextTemperature] 내부만 -/// "DB에서 최신 온도 1건 조회" 로직으로 교체하면 됩니다. -class TempDataService { - TempDataService({ - this.minTemp = 18.0, - this.maxTemp = 28.0, - this.historyCount = 24, - this.interval = const Duration(seconds: 3), // ⏱️ 테스트용 3초 - this.maxLength = 200, // 메모리 무한 증가 방지용 보관 한도 - }); - - final double minTemp; // 최저 가상 온도 - final double maxTemp; // 최고 가상 온도 - final int historyCount; // 시작 시 채워둘 24시간 데이터 개수 - final Duration interval; // 새 데이터 추가 주기 - final int maxLength; // 리스트에 보관할 최대 개수 - - final _random = Random(); - final List _temperatures = []; - Timer? _timer; - - // broadcast: 여러 위젯이 동시에 구독할 수 있게 함 - final _controller = StreamController>.broadcast(); - - /// 갱신될 때마다 "전체 온도 리스트"가 흘러나오는 스트림 - Stream> get stream => _controller.stream; - - /// 현재까지 쌓인 데이터 (읽기 전용 복사본) - List get current => List.unmodifiable(_temperatures); - - /// 스트림 시작: 24시간 더미 데이터로 시드한 뒤, [interval]마다 새 값 추가 - void start() { - _temperatures - ..clear() - ..addAll(generateDummyTemperatures( - count: historyCount, - minTemp: minTemp, - maxTemp: maxTemp, - )); - _controller.add(current); // 초기 데이터를 즉시 한 번 전달 - - _timer?.cancel(); - _timer = Timer.periodic(interval, (_) { - _temperatures.add(_generateNextTemperature()); - - // 오래된 데이터부터 버려서 길이 유지 - while (_temperatures.length > maxLength) { - _temperatures.removeAt(0); - } - _controller.add(current); - }); - } - - /// 다음(새) 가상 온도 한 건을 생성한다. 직전 값 기준 랜덤 워크. - double _generateNextTemperature() { - final last = _temperatures.isEmpty - ? minTemp + _random.nextDouble() * (maxTemp - minTemp) - : _temperatures.last; - final next = (last + (_random.nextDouble() - 0.5)).clamp(minTemp, maxTemp); - return double.parse(next.toStringAsFixed(1)); - } - - /// 타이머 정지 + 스트림 정리. 위젯 dispose() 에서 꼭 호출할 것. - void dispose() { - _timer?.cancel(); - _controller.close(); - } -} diff --git a/pubspec.yaml b/pubspec.yaml index d0125d0..81bdcd9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -67,6 +67,7 @@ flutter: - assets/videos/know.mp4 - assets/videos/thinking.mp4 - assets/videos/thirst.mp4 + - assets/images/profile.png # To add assets to your application, add an assets section, like this: # assets: