TKK_E32231503/lib/screens/chart_screen.dart

741 lines
25 KiB
Dart

import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import '../services/api_service.dart';
import '../models/sensor_data.dart';
class ChartScreen extends StatefulWidget {
const ChartScreen({super.key});
@override
State<ChartScreen> createState() => _ChartScreenState();
}
class _ChartScreenState extends State<ChartScreen> {
final ApiService _apiService = ApiService();
List<SensorData> historyData = [];
bool isLoading = true;
String selectedPeriod = '50'; // 50, 100, 200
@override
void initState() {
super.initState();
_loadHistoryData();
}
Future<void> _loadHistoryData() async {
setState(() {
isLoading = true;
});
try {
final data = await _apiService.getDataHistory(limit: int.parse(selectedPeriod));
if (mounted) {
setState(() {
historyData = data;
isLoading = false;
});
}
} catch (e) {
print('Error loading history: $e');
if (mounted) {
setState(() {
isLoading = false;
});
}
}
}
List<FlSpot> _getSuhuSpots() {
return historyData.asMap().entries.map((entry) {
return FlSpot(entry.key.toDouble(), entry.value.suhu);
}).toList();
}
List<FlSpot> _getBeratSpots() {
return historyData.asMap().entries.map((entry) {
return FlSpot(entry.key.toDouble(), entry.value.berat);
}).toList();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Grafik History'),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
actions: [
PopupMenuButton<String>(
icon: const Icon(Icons.filter_list),
onSelected: (value) {
setState(() {
selectedPeriod = value;
_loadHistoryData();
});
},
itemBuilder: (context) => [
const PopupMenuItem(value: '50', child: Text('50 Data Terakhir')),
const PopupMenuItem(value: '100', child: Text('100 Data Terakhir')),
const PopupMenuItem(value: '200', child: Text('200 Data Terakhir')),
],
),
],
),
body: isLoading
? const Center(child: CircularProgressIndicator())
: historyData.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
'Belum ada data history',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
),
),
],
),
)
: RefreshIndicator(
onRefresh: _loadHistoryData,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
// Info card
_buildInfoCard(),
const SizedBox(height: 16),
// Temperature chart
_buildTemperatureChart(),
const SizedBox(height: 16),
// Weight chart
_buildWeightChart(),
],
),
),
);
}
Widget _buildInfoCard() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.blue.shade400, Colors.blue.shade600],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.white, size: 32),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'History Data',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
'$selectedPeriod data terakhir dari database',
style: const TextStyle(
fontSize: 12,
color: Colors.white70,
),
),
],
),
),
IconButton(
onPressed: _loadHistoryData,
icon: const Icon(Icons.refresh, color: Colors.white),
),
],
),
);
}
Widget _buildTemperatureChart() {
final suhuSpots = _getSuhuSpots();
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.thermostat, color: Colors.orange, size: 24),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Grafik Suhu',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 10,
getDrawingHorizontalLine: (value) => FlLine(
color: Colors.grey.shade200,
strokeWidth: 1,
),
),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: suhuSpots.length > 20 ? 20 : 10,
getTitlesWidget: (value, meta) {
if (value.toInt() >= 0 && value.toInt() < historyData.length) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'#${value.toInt()}',
style: const TextStyle(fontSize: 10, color: Colors.grey),
),
);
}
return const Text('');
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: 10,
reservedSize: 40,
getTitlesWidget: (value, meta) => Text(
'${value.toInt()}°C',
style: const TextStyle(fontSize: 10, color: Colors.grey),
),
),
),
),
borderData: FlBorderData(
show: true,
border: Border(
bottom: BorderSide(color: Colors.grey.shade300),
left: BorderSide(color: Colors.grey.shade300),
),
),
minX: 0,
maxX: suhuSpots.isNotEmpty ? suhuSpots.length.toDouble() - 1 : 10,
minY: 20,
maxY: 60,
lineBarsData: [
LineChartBarData(
spots: suhuSpots,
isCurved: true,
color: Colors.orange,
barWidth: 3,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
Colors.orange.withOpacity(0.3),
Colors.orange.withOpacity(0.1),
Colors.transparent,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipColor: (touchedSpot) => Colors.black87,
tooltipRoundedRadius: 8,
getTooltipItems: (spots) {
return spots.map((spot) {
final index = spot.x.toInt();
if (index >= 0 && index < historyData.length) {
final data = historyData[index];
return LineTooltipItem(
'${spot.y.toStringAsFixed(1)}°C\n',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
children: [
TextSpan(
text: data.timestamp,
style: const TextStyle(
fontSize: 10,
color: Colors.white70,
),
),
],
);
}
return null;
}).toList();
},
),
),
),
),
),
],
),
);
}
Widget _buildWeightChart() {
final beratSpots = _getBeratSpots();
final avgTarget = historyData.isNotEmpty
? historyData.map((e) => e.target).reduce((a, b) => a + b) / historyData.length
: 0.0;
// Filter data yang valid untuk ditampilkan (ganti nilai 0 dengan nilai sebelumnya atau null)
List<FlSpot> displaySpots = [];
double? lastValidValue;
for (var spot in beratSpots) {
if (spot.y > 10) {
// Data valid, gunakan nilai asli
displaySpots.add(spot);
lastValidValue = spot.y;
} else if (lastValidValue != null) {
// Data invalid (0), gunakan nilai terakhir yang valid
displaySpots.add(FlSpot(spot.x, lastValidValue));
}
// Jika lastValidValue null dan spot.y invalid, skip point ini
}
// Jika masih kosong setelah filter, gunakan data asli
if (displaySpots.isEmpty) {
displaySpots = beratSpots;
}
// Hitung minY dan maxY yang aman untuk menghindari overflow
double minY = 0;
double maxY = 200;
if (displaySpots.isNotEmpty) {
double dataMin = displaySpots.map((e) => e.y).reduce((a, b) => a < b ? a : b);
double dataMax = displaySpots.map((e) => e.y).reduce((a, b) => a > b ? a : b);
// Sertakan target dalam perhitungan
if (avgTarget > 0) {
dataMin = dataMin < avgTarget ? dataMin : avgTarget;
dataMax = dataMax > avgTarget ? dataMax : avgTarget;
}
// Tambah padding 25%
double range = dataMax - dataMin;
double padding = range > 0 ? range * 0.25 : 50;
minY = (dataMin - padding).clamp(0, double.infinity);
maxY = dataMax + padding;
// Pastikan range minimal 100
if (maxY - minY < 100) {
double center = (maxY + minY) / 2;
minY = (center - 50).clamp(0, double.infinity);
maxY = center + 50;
}
} else if (avgTarget > 0) {
minY = 0;
maxY = (avgTarget * 1.5).clamp(100, double.infinity);
}
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.scale, color: Colors.blue, size: 24),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Grafik Berat',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 50,
getDrawingHorizontalLine: (value) {
if ((value - avgTarget).abs() < 10) {
return FlLine(
color: Colors.green.shade300,
strokeWidth: 2,
dashArray: [5, 5],
);
}
return FlLine(
color: Colors.grey.shade200,
strokeWidth: 1,
);
},
),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: beratSpots.length > 20 ? 20 : 10,
getTitlesWidget: (value, meta) {
if (value.toInt() >= 0 && value.toInt() < historyData.length) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'#${value.toInt()}',
style: const TextStyle(fontSize: 10, color: Colors.grey),
),
);
}
return const Text('');
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: 50,
reservedSize: 45,
getTitlesWidget: (value, meta) {
final isTarget = (value - avgTarget).abs() < 10;
return Text(
'${value.toInt()}g',
style: TextStyle(
fontSize: 10,
color: isTarget ? Colors.green : Colors.grey,
fontWeight: isTarget ? FontWeight.bold : FontWeight.normal,
),
);
},
),
),
),
borderData: FlBorderData(
show: true,
border: Border(
bottom: BorderSide(color: Colors.grey.shade300),
left: BorderSide(color: Colors.grey.shade300),
),
),
minX: 0,
maxX: beratSpots.isNotEmpty ? beratSpots.length.toDouble() - 1 : 10,
minY: minY,
maxY: maxY,
extraLinesData: ExtraLinesData(
horizontalLines: [
if (avgTarget > 0)
HorizontalLine(
y: avgTarget,
color: Colors.green.shade400,
strokeWidth: 2,
dashArray: [8, 4],
label: HorizontalLineLabel(
show: true,
alignment: Alignment.topRight,
style: TextStyle(
color: Colors.green.shade700,
fontSize: 10,
fontWeight: FontWeight.bold,
),
labelResolver: (line) => 'Target',
),
),
],
),
lineBarsData: [
LineChartBarData(
spots: displaySpots,
isCurved: true,
color: Colors.blue,
barWidth: 3,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
Colors.blue.withOpacity(0.3),
Colors.blue.withOpacity(0.1),
Colors.transparent,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipColor: (touchedSpot) => Colors.black87,
tooltipRoundedRadius: 8,
getTooltipItems: (spots) {
return spots.map((spot) {
final index = spot.x.toInt();
if (index >= 0 && index < historyData.length) {
final data = historyData[index];
return LineTooltipItem(
'${spot.y.toStringAsFixed(0)}g\n',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
children: [
TextSpan(
text: 'Target: ${data.target.toStringAsFixed(0)}g\n',
style: const TextStyle(
fontSize: 10,
color: Colors.green,
),
),
TextSpan(
text: data.timestamp,
style: const TextStyle(
fontSize: 10,
color: Colors.white70,
),
),
],
);
}
return null;
}).toList();
},
),
),
),
),
),
],
),
);
}
Widget _buildStatisticsCard() {
if (historyData.isEmpty) return const SizedBox.shrink();
final avgSuhu = historyData.map((e) => e.suhu).reduce((a, b) => a + b) / historyData.length;
final maxSuhu = historyData.map((e) => e.suhu).reduce((a, b) => a > b ? a : b);
final minSuhu = historyData.map((e) => e.suhu).reduce((a, b) => a < b ? a : b);
final avgBerat = historyData.map((e) => e.berat).reduce((a, b) => a + b) / historyData.length;
final maxBerat = historyData.map((e) => e.berat).reduce((a, b) => a > b ? a : b);
final minBerat = historyData.map((e) => e.berat).reduce((a, b) => a < b ? a : b);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Statistik',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildStatItem(
icon: Icons.thermostat,
iconColor: Colors.orange,
label: 'Rata-rata Suhu',
value: '${avgSuhu.toStringAsFixed(1)}°C',
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatItem(
icon: Icons.scale,
iconColor: Colors.blue,
label: 'Rata-rata Berat',
value: '${avgBerat.toStringAsFixed(0)}g',
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildStatItem(
icon: Icons.arrow_upward,
iconColor: Colors.red,
label: 'Max Suhu',
value: '${maxSuhu.toStringAsFixed(1)}°C',
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatItem(
icon: Icons.arrow_downward,
iconColor: Colors.green,
label: 'Min Suhu',
value: '${minSuhu.toStringAsFixed(1)}°C',
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildStatItem(
icon: Icons.arrow_upward,
iconColor: Colors.red,
label: 'Max Berat',
value: '${maxBerat.toStringAsFixed(0)}g',
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatItem(
icon: Icons.arrow_downward,
iconColor: Colors.green,
label: 'Min Berat',
value: '${minBerat.toStringAsFixed(0)}g',
),
),
],
),
],
),
);
}
Widget _buildStatItem({
required IconData icon,
required Color iconColor,
required String label,
required String value,
}) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Icon(icon, color: iconColor, size: 24),
const SizedBox(height: 8),
Text(
value,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
fontSize: 11,
color: Colors.grey,
),
),
],
),
);
}
}