first commit
This commit is contained in:
109
lib/main.dart
Normal file
109
lib/main.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
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: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());
|
||||
|
||||
class SmartGardenApp extends StatelessWidget {
|
||||
const SmartGardenApp({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: const Color(0xFF0F1115)),
|
||||
home: const MainDashboardScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MainDashboardScreen extends StatefulWidget {
|
||||
const MainDashboardScreen({super.key});
|
||||
@override
|
||||
State<MainDashboardScreen> createState() => _MainDashboardScreenState();
|
||||
}
|
||||
|
||||
class _MainDashboardScreenState extends State<MainDashboardScreen> {
|
||||
// ✨ 초기값을 false로 설정하여 앱 시작 시 사이드바가 숨겨진 상태로 만듭니다.
|
||||
bool isSidebarOpen = false;
|
||||
String selectedPage = '스마트가든 챗봇';
|
||||
bool isChatbotDetail = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
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; // 다른 메뉴 클릭 시 상세 뷰 초기화
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
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(), // ✨ 개인 위젯 호출 통로
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ✨ 질문자님이 정의하신 아코디언 메뉴명과 개인 위젯 매핑 분기점
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
107
lib/services/temp_data_service.dart
Normal file
107
lib/services/temp_data_service.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
/// 📌 [독립 함수] 지난 24시간 치의 가상(더미) 온도 데이터 리스트를 생성한다.
|
||||
///
|
||||
/// 실제 DB 연동 전, 개발 단계에서 차트/리스트 UI를 채우기 위한 용도입니다.
|
||||
/// 값이 들쭉날쭉 튀지 않고 자연스럽게 보이도록, 직전 값에서 조금씩만
|
||||
/// 변하는 "랜덤 워크(random walk)" 방식으로 만듭니다.
|
||||
///
|
||||
/// - [count] : 만들 데이터 개수 (기본 24개 = 1시간 간격 24시간)
|
||||
/// - [minTemp] : 가상 온도 하한
|
||||
/// - [maxTemp] : 가상 온도 상한
|
||||
List<double> generateDummyTemperatures({
|
||||
int count = 24,
|
||||
double minTemp = 18.0,
|
||||
double maxTemp = 28.0,
|
||||
}) {
|
||||
final random = Random();
|
||||
final list = <double>[];
|
||||
|
||||
// 시작 온도는 범위 내 임의 값
|
||||
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<double> _temperatures = [];
|
||||
Timer? _timer;
|
||||
|
||||
// broadcast: 여러 위젯이 동시에 구독할 수 있게 함
|
||||
final _controller = StreamController<List<double>>.broadcast();
|
||||
|
||||
/// 갱신될 때마다 "전체 온도 리스트"가 흘러나오는 스트림
|
||||
Stream<List<double>> get stream => _controller.stream;
|
||||
|
||||
/// 현재까지 쌓인 데이터 (읽기 전용 복사본)
|
||||
List<double> 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();
|
||||
}
|
||||
}
|
||||
253
lib/widgets/chatbot_detail_page.dart
Normal file
253
lib/widgets/chatbot_detail_page.dart
Normal file
@@ -0,0 +1,253 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:video_player/video_player.dart'; // 비디오 패키지 임포트
|
||||
|
||||
class ChatbotDetailPage extends StatefulWidget {
|
||||
final VoidCallback onBack;
|
||||
|
||||
const ChatbotDetailPage({super.key, required this.onBack});
|
||||
|
||||
@override
|
||||
State<ChatbotDetailPage> createState() => _ChatbotDetailPageState();
|
||||
}
|
||||
|
||||
class _ChatbotDetailPageState extends State<ChatbotDetailPage> {
|
||||
late VideoPlayerController _knowVideoController;
|
||||
bool _isVideoInitialized = false;
|
||||
bool _hasVideoError = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeKnowVideo();
|
||||
}
|
||||
|
||||
void _initializeKnowVideo() {
|
||||
const String videoAssetPath = 'assets/assets/videos/know.mp4';
|
||||
_knowVideoController = VideoPlayerController.asset(videoAssetPath);
|
||||
|
||||
_knowVideoController.initialize().then((_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isVideoInitialized = true;
|
||||
});
|
||||
_knowVideoController.setLooping(true);
|
||||
_knowVideoController.setVolume(0.0);
|
||||
_knowVideoController.play();
|
||||
}).catchError((error) {
|
||||
debugPrint("1차 지식 영상 로딩 실패, 백업 경로 시도 중...: $error");
|
||||
|
||||
_knowVideoController = VideoPlayerController.asset('assets/videos/know.mp4');
|
||||
_knowVideoController.initialize().then((_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isVideoInitialized = true;
|
||||
});
|
||||
_knowVideoController.setLooping(true);
|
||||
_knowVideoController.setVolume(0.0);
|
||||
_knowVideoController.play();
|
||||
}).catchError((retryError) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_hasVideoError = true;
|
||||
});
|
||||
debugPrint("최종 지식 비디오 초기화 에러: $retryError");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_knowVideoController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// 상단 미니 센서 바
|
||||
SizedBox(
|
||||
height: 100,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildMiniSensor('온도', '24.5°C', Colors.orangeAccent),
|
||||
const SizedBox(width: 10),
|
||||
_buildMiniSensor('습도', '65%', Colors.blueAccent),
|
||||
const SizedBox(width: 10),
|
||||
_buildMiniSensor('CO2', '420 ppm', Colors.greenAccent),
|
||||
const SizedBox(width: 10),
|
||||
_buildMiniSensor('TVOC', '0.15 mg/m³', Colors.tealAccent),
|
||||
const SizedBox(width: 10),
|
||||
_buildMiniSensor('미세먼지', '15', Colors.white70),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 메인 콘텐츠 영역
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
// 🤖 [좌측] 푸미 캐릭터 전용 패널 (✨ flex: 60 으로 수정)
|
||||
Expanded(
|
||||
flex: 60,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1D23),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.05)),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 🔙 "목록으로 돌아가기" 버튼
|
||||
Positioned(
|
||||
top: 20,
|
||||
left: 20,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: widget.onBack,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFB4E49C).withOpacity(0.4)),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.arrow_back_ios_new, color: Color(0xFFB4E49C), size: 14),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'목록으로 돌아가기',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 정중앙 know.mp4 대형 배치
|
||||
Center(
|
||||
child: _hasVideoError
|
||||
? Icon(Icons.smart_toy, size: 160, color: const Color(0xFFB4E49C).withOpacity(0.3))
|
||||
: _isVideoInitialized
|
||||
? SizedBox(
|
||||
width: 600, // ✨ 메인 화면과 짝을 맞춰 550 스케일로 정돈 극대화!
|
||||
child: AspectRatio(
|
||||
aspectRatio: _knowVideoController.value.aspectRatio,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: VideoPlayer(_knowVideoController),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox(
|
||||
width: 100,
|
||||
height: 100,
|
||||
child: Center(child: CircularProgressIndicator(color: Color(0xFFB4E49C))),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
|
||||
// 💬 [우측] 채팅창 (✨ flex: 40 으로 수정)
|
||||
Expanded(
|
||||
flex: 40,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1D23),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(backgroundColor: Color(0xFFB4E49C), radius: 4),
|
||||
SizedBox(width: 10),
|
||||
Text('푸미 일지', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: [
|
||||
_buildChatBubble("상태 분석이 완료되었습니다. 무엇을 도와드릴까요?", true),
|
||||
_buildChatBubble("현재 습도가 적당하니?", false),
|
||||
_buildChatBubble("네, 습도 65%로 최적의 상태입니다.", true),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 하단 입력창
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Ask AI...',
|
||||
hintStyle: const TextStyle(color: Colors.white24, fontSize: 14),
|
||||
filled: true,
|
||||
fillColor: Colors.white.withOpacity(0.05),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(15), borderSide: BorderSide.none),
|
||||
suffixIcon: const Icon(Icons.send, color: Color(0xFFB4E49C), size: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 채팅 말풍선 헬퍼
|
||||
Widget _buildChatBubble(String msg, bool isBot) {
|
||||
return Align(
|
||||
alignment: isBot ? Alignment.centerLeft : Alignment.centerRight,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 5),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isBot ? Colors.white.withOpacity(0.08) : const Color(0xFFB4E49C).withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: Text(msg, style: const TextStyle(color: Colors.white, fontSize: 13)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 미니 센서 카드 헬퍼
|
||||
Widget _buildMiniSensor(String t, String v, Color c) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(color: const Color(0xFF1A1D23), borderRadius: BorderRadius.circular(15)),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(t, style: const TextStyle(color: Colors.white54, fontSize: 11)),
|
||||
Text(v, style: TextStyle(color: c, fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
15
lib/widgets/co2_data_page.dart
Normal file
15
lib/widgets/co2_data_page.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class Co2DataPage extends StatelessWidget {
|
||||
const Co2DataPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'🍃 실시간 CO2 농도 변화 추이 분석',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white60),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
198
lib/widgets/common_chart_card.dart
Normal file
198
lib/widgets/common_chart_card.dart
Normal file
@@ -0,0 +1,198 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
|
||||
/// 📊 [공통 위젯] 모든 센서(온도/습도/CO2 등)가 공유하는 실시간 차트 카드.
|
||||
///
|
||||
/// DashboardGrid 의 `_buildSensorCard` 스타일(배경 #1F242C, 라운드 16,
|
||||
/// white10 테두리, 헤더 + 강조 색상)을 그대로 따르며, 하단에 fl_chart
|
||||
/// 라인 차트를 붙여 [stream] 으로 흘러오는 데이터를 실시간으로 그린다.
|
||||
///
|
||||
/// 사용 예:
|
||||
/// ```dart
|
||||
/// SensorChartCard(
|
||||
/// title: '온도',
|
||||
/// icon: Icons.thermostat,
|
||||
/// accentColor: Colors.orangeAccent,
|
||||
/// unit: '°C',
|
||||
/// stream: tempService.stream,
|
||||
/// )
|
||||
/// ```
|
||||
class SensorChartCard extends StatelessWidget {
|
||||
const SensorChartCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.accentColor,
|
||||
required this.unit,
|
||||
required this.stream,
|
||||
});
|
||||
|
||||
/// 카드 제목 (예: '온도', '습도', '이산화탄소')
|
||||
final String title;
|
||||
|
||||
/// 헤더 우측에 표시할 아이콘
|
||||
final IconData icon;
|
||||
|
||||
/// 라인/아이콘/현재값에 쓰일 강조 색상
|
||||
final Color accentColor;
|
||||
|
||||
/// 값 뒤에 붙는 단위 (예: '°C', '%', 'ppm')
|
||||
final String unit;
|
||||
|
||||
/// 갱신될 때마다 "전체 측정값 리스트"가 흘러나오는 스트림
|
||||
final Stream<List<double>> stream;
|
||||
|
||||
static const Color _cardColor = Color(0xFF1F242C);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _cardColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
child: StreamBuilder<List<double>>(
|
||||
stream: stream,
|
||||
builder: (context, snapshot) {
|
||||
final data = snapshot.data ?? const <double>[];
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(data),
|
||||
const SizedBox(height: 20),
|
||||
Expanded(child: _buildChart(data)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 🏷️ 헤더: 제목 + 현재값 + 아이콘 (센서 카드와 동일한 톤)
|
||||
Widget _buildHeader(List<double> data) {
|
||||
final hasData = data.isNotEmpty;
|
||||
final latest = hasData ? data.last : null;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.white70,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
hasData ? latest!.toStringAsFixed(1) : '--',
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
unit,
|
||||
style: const TextStyle(fontSize: 14, color: Colors.white38),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Icon(icon, color: accentColor.withOpacity(0.8), size: 22),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 📈 라인 차트 (fl_chart)
|
||||
Widget _buildChart(List<double> data) {
|
||||
if (data.isEmpty) {
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Color(0xFFB4E49C),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final spots = <FlSpot>[
|
||||
for (var i = 0; i < data.length; i++) FlSpot(i.toDouble(), data[i]),
|
||||
];
|
||||
|
||||
final minY = data.reduce((a, b) => a < b ? a : b);
|
||||
final maxY = data.reduce((a, b) => a > b ? a : b);
|
||||
// 위아래로 약간 여백을 둬서 선이 테두리에 붙지 않게 한다.
|
||||
final padding = ((maxY - minY).abs() * 0.15).clamp(1.0, double.infinity);
|
||||
|
||||
return LineChart(
|
||||
LineChartData(
|
||||
minX: 0,
|
||||
maxX: (data.length - 1).toDouble(),
|
||||
minY: minY - padding,
|
||||
maxY: maxY + padding,
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: ((maxY + padding) - (minY - padding)) / 4,
|
||||
getDrawingHorizontalLine: (value) =>
|
||||
const FlLine(color: Colors.white10, strokeWidth: 1),
|
||||
),
|
||||
titlesData: const FlTitlesData(show: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
lineTouchData: LineTouchData(
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
getTooltipColor: (_) => _cardColor.withOpacity(0.9),
|
||||
getTooltipItems: (touchedSpots) => [
|
||||
for (final s in touchedSpots)
|
||||
LineTooltipItem(
|
||||
'${s.y.toStringAsFixed(1)} $unit',
|
||||
const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: spots,
|
||||
isCurved: true,
|
||||
color: accentColor,
|
||||
barWidth: 2.5,
|
||||
dotData: const FlDotData(show: false),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
accentColor.withOpacity(0.25),
|
||||
accentColor.withOpacity(0.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
15
lib/widgets/device_manage_page.dart
Normal file
15
lib/widgets/device_manage_page.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DeviceManagePage extends StatelessWidget {
|
||||
const DeviceManagePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'⚙️ 모터, 팬, LED 광원 장비 제어 관리 시스템',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white60),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
15
lib/widgets/dust_data_page.dart
Normal file
15
lib/widgets/dust_data_page.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DustDataPage extends StatelessWidget {
|
||||
const DustDataPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'😷 실시간 미세먼지(PM2.5 / PM10) 모니터링',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white60),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
51
lib/widgets/header.dart
Normal file
51
lib/widgets/header.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class Header extends StatelessWidget {
|
||||
// ✨ 메인 화면(main.dart)으로부터 동적 타이틀과 토글 함수를 전달받도록 변수 설정
|
||||
final String title;
|
||||
final bool isSidebarOpen;
|
||||
final VoidCallback onToggleSidebar;
|
||||
|
||||
const Header({
|
||||
super.key,
|
||||
required this.title, // ✨ 필수 파라미터로 등록
|
||||
required this.isSidebarOpen,
|
||||
required this.onToggleSidebar,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 70,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
color: const Color(0xFF13161A),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
// 사이드바 토글 메뉴 버튼
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isSidebarOpen ? Icons.menu_open : Icons.menu,
|
||||
color: const Color(0xFFB4E49C),
|
||||
),
|
||||
onPressed: onToggleSidebar,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// ✨ 고정된 텍스트 대신 전달받은 title 변수를 출력합니다!
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.help_outline, size: 16, color: Colors.white38),
|
||||
],
|
||||
),
|
||||
const Icon(Icons.notifications_none, color: Colors.white70, size: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
15
lib/widgets/humid_data_page.dart
Normal file
15
lib/widgets/humid_data_page.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class HumidDataPage extends StatelessWidget {
|
||||
const HumidDataPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'💧 실시간 토양 및 대기 습도 데이터 현황',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white60),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
324
lib/widgets/main_dashboard.dart
Normal file
324
lib/widgets/main_dashboard.dart
Normal file
@@ -0,0 +1,324 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:video_player/video_player.dart'; // 비디오 패키지 임포트
|
||||
|
||||
class DashboardGrid extends StatefulWidget {
|
||||
final VoidCallback onCharacterTap;
|
||||
|
||||
const DashboardGrid({super.key, required this.onCharacterTap});
|
||||
|
||||
@override
|
||||
State<DashboardGrid> createState() => _DashboardGridState();
|
||||
}
|
||||
|
||||
class _DashboardGridState extends State<DashboardGrid> {
|
||||
late VideoPlayerController _videoController;
|
||||
bool _isVideoInitialized = false;
|
||||
bool _hasVideoError = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeWebVideo();
|
||||
}
|
||||
|
||||
void _initializeWebVideo() {
|
||||
const String videoAssetPath = 'assets/assets/videos/basic.mp4';
|
||||
_videoController = VideoPlayerController.asset(videoAssetPath);
|
||||
|
||||
_videoController.initialize().then((_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isVideoInitialized = true;
|
||||
});
|
||||
_videoController.setLooping(true); // 🔄 무한 반복 재생
|
||||
_videoController.setVolume(0.0); // 🔇 크롬 자동재생 정책 우회
|
||||
_videoController.play(); // ▶️ 자동 재생 시작
|
||||
}).catchError((error) {
|
||||
debugPrint("1차 웹 비디오 로딩 실패, 백업 경로 시도 중...: $error");
|
||||
|
||||
_videoController = VideoPlayerController.asset('assets/videos/basic.mp4');
|
||||
_videoController.initialize().then((_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isVideoInitialized = true;
|
||||
});
|
||||
_videoController.setLooping(true);
|
||||
_videoController.setVolume(0.0);
|
||||
_videoController.play();
|
||||
}).catchError((retryError) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_hasVideoError = true;
|
||||
});
|
||||
debugPrint("최종 비디오 초기화 에러: $retryError");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_videoController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 🤖 1. 좌측 영역: 푸미 캐릭터 비디오 화면 (✨ flex: 60 으로 수정)
|
||||
Expanded(
|
||||
flex: 60,
|
||||
child: GestureDetector(
|
||||
onTap: widget.onCharacterTap,
|
||||
child: _buildCharacterCard(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
|
||||
// 📊 2. 우측 영역: 센서 데이터 모음 (✨ flex: 40 으로 수정)
|
||||
Expanded(
|
||||
flex: 40,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 10,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _buildSensorCard('온도', '24.5°C', Icons.thermostat, Colors.orangeAccent)),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: _buildSensorCard('습도', '65%', Icons.water_drop, Colors.blueAccent)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
flex: 10,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _buildSensorCard('이산화탄소', '420 ppm', Icons.cloud, Colors.greenAccent)),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: _buildSensorCard('TVOC', '0.15 mg/m³', Icons.biotech, Colors.tealAccent)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
flex: 11,
|
||||
child: _buildDustCard(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 🤖 [좌측] 캐릭터 전용 카드 빌더
|
||||
Widget _buildCharacterCard() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1F242C),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
_buildMiniInfo(Icons.eco_outlined, '학습된 식물: 51종'),
|
||||
const SizedBox(width: 20),
|
||||
_buildMiniInfo(Icons.trending_up, '예측 정확도: 98.7%'),
|
||||
],
|
||||
),
|
||||
|
||||
// 🎬 중앙 로봇 에셋 비디오 플레이어
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (_hasVideoError)
|
||||
const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.video_library_outlined, size: 80, color: Colors.white24),
|
||||
SizedBox(height: 12),
|
||||
Text('비디오 에셋을 로드할 수 없습니다.', style: TextStyle(color: Colors.white38, fontSize: 13)),
|
||||
],
|
||||
)
|
||||
else if (_isVideoInitialized)
|
||||
SizedBox(
|
||||
width: 600,
|
||||
child: AspectRatio(
|
||||
aspectRatio: _videoController.value.aspectRatio,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: VideoPlayer(_videoController),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(
|
||||
height: 280,
|
||||
child: Center(child: CircularProgressIndicator(color: Color(0xFFB4E49C))),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 안내 뱃지 문구
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFB4E49C).withOpacity(0.06),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: const Color(0xFFB4E49C).withOpacity(0.2)),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.touch_app_outlined, size: 14, color: Color(0xFFB4E49C)),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'여기를 클릭하면 AI 채팅창으로 이동합니다.',
|
||||
style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(color: Colors.white10, height: 24),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('푸미', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Text('상태: ', style: TextStyle(color: Colors.white54, fontSize: 13)),
|
||||
Text('최상 - 모니터링 중', style: TextStyle(color: const Color(0xFFB4E49C), fontSize: 13, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 📊 센서 카드 빌더
|
||||
Widget _buildSensorCard(String title, String value, IconData icon, Color accentColor) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1F242C),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontSize: 16, color: Colors.white70, fontWeight: FontWeight.w500)),
|
||||
Icon(icon, color: accentColor.withOpacity(0.8), size: 22),
|
||||
],
|
||||
),
|
||||
Text(value, style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||
Container(
|
||||
height: 4,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(color: Colors.white10, borderRadius: BorderRadius.circular(2)),
|
||||
child: FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: 0.65,
|
||||
child: Container(decoration: BoxDecoration(color: accentColor, borderRadius: BorderRadius.circular(2))),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 💨 미세먼지 카드 빌더
|
||||
Widget _buildDustCard() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1F242C),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('미세먼지', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.air, color: Colors.white54, size: 36),
|
||||
SizedBox(width: 16),
|
||||
Text('15', style: TextStyle(fontSize: 52, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||
SizedBox(width: 4),
|
||||
Text('µg/m³', style: TextStyle(fontSize: 14, color: Colors.white38)),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.03),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.greenAccent, size: 28),
|
||||
SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('PM2.5 : 1.50', style: TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
SizedBox(height: 2),
|
||||
Text('PM10 : 1.30', style: TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMiniInfo(IconData icon, String text) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 14, color: const Color(0xFFB4E49C)),
|
||||
const SizedBox(width: 6),
|
||||
Text(text, style: const TextStyle(color: Colors.white54, fontSize: 12)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
177
lib/widgets/sidebar.dart
Normal file
177
lib/widgets/sidebar.dart
Normal file
@@ -0,0 +1,177 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class Sidebar extends StatelessWidget {
|
||||
final bool isSidebarOpen;
|
||||
final String selectedPage;
|
||||
final Function(String) onPageChanged;
|
||||
|
||||
const Sidebar({
|
||||
super.key,
|
||||
required this.isSidebarOpen,
|
||||
required this.selectedPage,
|
||||
required this.onPageChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 260,
|
||||
color: const Color(0xFF181C22), // 어두운 테마 배경색 유지
|
||||
padding: const EdgeInsets.symmetric(vertical: 24.0, horizontal: 8.0),
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: isSidebarOpen ? const Interval(0.6, 1.0, curve: Curves.easeIn) : Curves.easeOut,
|
||||
opacity: isSidebarOpen ? 1.0 : 0.0,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 최상단 앱 타이틀
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Text(
|
||||
'Smart Garden',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFB4E49C), // 페이모바일 포인트 컬러 유지
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 📜 대분류 및 하위 메뉴 영역
|
||||
Expanded(
|
||||
child: ListView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
children: [
|
||||
// 1. Dashboard 그룹
|
||||
_buildExpansionMenu(
|
||||
icon: Icons.dashboard_outlined,
|
||||
title: 'Dashboard',
|
||||
children: [
|
||||
_buildSubMenuItem('스마트가든 챗봇'),
|
||||
],
|
||||
),
|
||||
|
||||
// 2. 데이터 조회 그룹
|
||||
_buildExpansionMenu(
|
||||
icon: Icons.analytics_outlined,
|
||||
title: '데이터 조회',
|
||||
initiallyExpanded: true, // 자주 보는 데이터 조회를 기본으로 열어둡니다.
|
||||
children: [
|
||||
_buildSubMenuItem('온도 데이터'),
|
||||
_buildSubMenuItem('습도 데이터'),
|
||||
_buildSubMenuItem('이산화탄소 데이터'),
|
||||
_buildSubMenuItem('TVOC 데이터'),
|
||||
_buildSubMenuItem('미세먼지 데이터'),
|
||||
],
|
||||
),
|
||||
|
||||
// 3. 장비 관리 (하위 메뉴가 없는 단일 대분류 버튼)
|
||||
_buildSingleMenu(
|
||||
icon: Icons.widgets_outlined,
|
||||
title: '장비 관리',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 하단 로그아웃 버튼
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFB4E49C),
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text('Log Out', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ✨ 그룹형 (대분류 > 소분류) 메뉴를 만드는 빌더
|
||||
Widget _buildExpansionMenu({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required List<Widget> children,
|
||||
bool initiallyExpanded = false,
|
||||
}) {
|
||||
return Theme(
|
||||
// ExpansionTile 고유의 상하단 구분선(Border)을 제거하는 테마 트릭
|
||||
data: ThemeData.dark().copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
initiallyExpanded: initiallyExpanded,
|
||||
leading: Icon(icon, color: Colors.white70, size: 22),
|
||||
title: Text(
|
||||
title,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.bold),
|
||||
),
|
||||
trailing: const Icon(Icons.keyboard_arrow_down, color: Colors.white38, size: 18),
|
||||
childrenPadding: const EdgeInsets.only(left: 16.0), // 하위 메뉴 들여쓰기 구조 설정
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ✨ 하위(소분류) 메뉴 아이템 빌더
|
||||
Widget _buildSubMenuItem(String title) {
|
||||
bool isSelected = (title == selectedPage);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 2, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFFB4E49C).withOpacity(0.15) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: isSelected ? const Color(0xFFB4E49C) : Colors.white60,
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
dense: true,
|
||||
visualDensity: const VisualDensity(vertical: -2), // 메뉴 간격을 좀 더 콤팩트하게 조절
|
||||
onTap: () => onPageChanged(title),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ✨ 하위 메뉴가 없는 단독 대분류 메뉴 빌더 (ex: 장비 관리)
|
||||
Widget _buildSingleMenu({required IconData icon, required String title}) {
|
||||
bool isSelected = (title == selectedPage);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFFB4E49C).withOpacity(0.15) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Icon(icon, color: isSelected ? const Color(0xFFB4E49C) : Colors.white70, size: 22),
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: isSelected ? const Color(0xFFB4E49C) : Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
dense: true,
|
||||
onTap: () => onPageChanged(title),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
15
lib/widgets/temp_data_page.dart
Normal file
15
lib/widgets/temp_data_page.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class TempDataPage extends StatelessWidget {
|
||||
const TempDataPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'🌡️ 실시간 온도 데이터 수집 분석 현황',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white60),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
15
lib/widgets/tvoc_data_page.dart
Normal file
15
lib/widgets/tvoc_data_page.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class TvocDataPage extends StatelessWidget {
|
||||
const TvocDataPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'🧪 실시간 총휘발성유기화합물(TVOC) 측정값',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white60),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user