first commit

This commit is contained in:
2026-06-15 18:12:31 +09:00
commit 319cabe9f8
148 changed files with 6242 additions and 0 deletions

View 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),
],
),
),
),
],
),
);
}
}