884 lines
26 KiB
Dart
884 lines
26 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'dart:ui' as ui;
|
|
import '../../models/perkembangan_model.dart';
|
|
import '../../controllers/perkembangan_controller.dart';
|
|
import '../../services/perkembangan_service.dart';
|
|
import '../../services/anak_service.dart';
|
|
import '../../services/api_service.dart';
|
|
import 'package:intl/intl.dart';
|
|
|
|
// Pindahkan GrowthData ke level teratas
|
|
class GrowthData {
|
|
final int month;
|
|
final double height;
|
|
final double weight;
|
|
final DateTime? tanggal;
|
|
|
|
GrowthData({
|
|
required this.month,
|
|
required this.height,
|
|
required this.weight,
|
|
this.tanggal,
|
|
});
|
|
}
|
|
|
|
class PerkembanganScreen extends StatefulWidget {
|
|
final int anakId;
|
|
|
|
const PerkembanganScreen({Key? key, required this.anakId}) : super(key: key);
|
|
|
|
@override
|
|
_PerkembanganScreenState createState() => _PerkembanganScreenState();
|
|
}
|
|
|
|
class _PerkembanganScreenState extends State<PerkembanganScreen> with TickerProviderStateMixin {
|
|
late AnimationController _animationController;
|
|
late Animation<double> _fadeAnimation;
|
|
late PerkembanganController _perkembanganController;
|
|
final PerkembanganService _perkembanganService = PerkembanganService();
|
|
final AnakService _anakService = AnakService();
|
|
|
|
bool _isLoading = true;
|
|
String _anakName = '';
|
|
String _anakAge = '';
|
|
List<GrowthData> _growthData = [];
|
|
Map<String, dynamic> _growthStats = {};
|
|
|
|
// Untuk slider pemilihan anak
|
|
List<dynamic> _anakList = [];
|
|
int _currentAnakIndex = 0;
|
|
int _currentAnakId = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_perkembanganController = PerkembanganController();
|
|
_currentAnakId = widget.anakId;
|
|
|
|
_animationController = AnimationController(
|
|
vsync: this,
|
|
duration: Duration(milliseconds: 1000),
|
|
);
|
|
_fadeAnimation = Tween<double>(
|
|
begin: 0.0,
|
|
end: 1.0,
|
|
).animate(CurvedAnimation(
|
|
parent: _animationController,
|
|
curve: Interval(0.1, 1.0, curve: Curves.easeOut),
|
|
));
|
|
|
|
_loadAnakList();
|
|
_animationController.forward();
|
|
}
|
|
|
|
Future<void> _loadAnakList() async {
|
|
try {
|
|
setState(() {
|
|
_isLoading = true;
|
|
});
|
|
|
|
// Load daftar anak
|
|
final anakList = await _anakService.getAnakList();
|
|
|
|
if (!mounted) return;
|
|
|
|
if (anakList.isNotEmpty) {
|
|
// Cari index anak yang sesuai dengan anakId yang diberikan
|
|
int indexFound = anakList.indexWhere((anak) => anak['id'] == widget.anakId);
|
|
|
|
setState(() {
|
|
_anakList = anakList;
|
|
_currentAnakIndex = indexFound >= 0 ? indexFound : 0;
|
|
_currentAnakId = anakList[_currentAnakIndex]['id'];
|
|
});
|
|
|
|
// Load data perkembangan untuk anak yang dipilih
|
|
await _loadData();
|
|
} else {
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Tidak ada data anak'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
print('Error loading anak list: $e');
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Gagal memuat daftar anak: $e'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _loadData() async {
|
|
setState(() {
|
|
_isLoading = true;
|
|
});
|
|
|
|
try {
|
|
// Get child details
|
|
final anakDetail = await _anakService.getAnakDetail(_currentAnakId);
|
|
final namaAnak = anakDetail['nama_anak'] ?? 'Anak';
|
|
|
|
// Convert tanggal_lahir to usia in months
|
|
final tanggalLahir = DateTime.parse(anakDetail['tanggal_lahir']);
|
|
final now = DateTime.now();
|
|
final ageInDays = now.difference(tanggalLahir).inDays;
|
|
final ageInMonths = (ageInDays / 30).floor(); // Approximate
|
|
|
|
// Get growth data from API
|
|
print('Mengambil seluruh riwayat pertumbuhan anak ID: $_currentAnakId');
|
|
final perkembanganList = await _perkembanganService.getPerkembanganByAnakId(_currentAnakId);
|
|
|
|
// Convert API data to GrowthData model
|
|
final List<GrowthData> apiGrowthData = [];
|
|
|
|
if (perkembanganList.isNotEmpty) {
|
|
print('Memproses ${perkembanganList.length} data riwayat perkembangan');
|
|
|
|
for (var item in perkembanganList) {
|
|
try {
|
|
print('Processing item: $item');
|
|
|
|
// Ensure the data is valid
|
|
if (item['tanggal'] == null ||
|
|
item['tinggi_badan'] == null ||
|
|
item['berat_badan'] == null) {
|
|
print('Skipping item with null values: $item');
|
|
continue;
|
|
}
|
|
|
|
final tanggal = DateTime.parse(item['tanggal']);
|
|
// Hitung bulan sejak lahir untuk setiap pengukuran
|
|
final monthDiff = ((tanggal.year - tanggalLahir.year) * 12) +
|
|
(tanggal.month - tanggalLahir.month);
|
|
|
|
// Convert string values to double
|
|
double height, weight;
|
|
try {
|
|
height = double.parse(item['tinggi_badan'].toString());
|
|
weight = double.parse(item['berat_badan'].toString());
|
|
} catch (e) {
|
|
print('Error parsing height/weight: $e');
|
|
continue;
|
|
}
|
|
|
|
apiGrowthData.add(GrowthData(
|
|
month: monthDiff,
|
|
height: height,
|
|
weight: weight,
|
|
tanggal: tanggal, // Tambahkan tanggal untuk referensi
|
|
));
|
|
|
|
print('⭐ Menambahkan data pertumbuhan - Tanggal: ${tanggal.toString()}, Bulan ke-$monthDiff: TB=$height cm, BB=$weight kg');
|
|
} catch (e) {
|
|
print('Error processing item: $e');
|
|
}
|
|
}
|
|
|
|
// Sort data berdasarkan bulan untuk memastikan grafik berurutan
|
|
apiGrowthData.sort((a, b) => a.month.compareTo(b.month));
|
|
|
|
// Log data untuk verifikasi
|
|
print('\nData pertumbuhan yang akan ditampilkan dalam grafik:');
|
|
for (var data in apiGrowthData) {
|
|
print('Bulan ke-${data.month}: TB=${data.height} cm, BB=${data.weight} kg (Tanggal: ${data.tanggal})');
|
|
}
|
|
}
|
|
|
|
// Calculate statistics menggunakan data terbaru
|
|
Map<String, dynamic> stats = {};
|
|
if (apiGrowthData.isNotEmpty) {
|
|
// Ambil data terbaru (bukan data dengan bulan tertinggi, tapi data dengan tanggal terbaru)
|
|
final latest = apiGrowthData.reduce((a, b) =>
|
|
(a.tanggal?.isAfter(b.tanggal ?? DateTime(1900)) ?? false) ? a : b);
|
|
|
|
print('🔍 Menggunakan data terbaru (tanggal: ${latest.tanggal}) untuk status pertumbuhan saat ini');
|
|
|
|
// Get status from service
|
|
final statusPertumbuhan = _perkembanganService.hitungStatusPertumbuhan(
|
|
latest.weight,
|
|
latest.height,
|
|
ageInMonths,
|
|
anakDetail['jenis_kelamin'] ?? 'Laki-laki',
|
|
);
|
|
|
|
stats = {
|
|
'height': {
|
|
'value': latest.height,
|
|
'percentile': '75%', // Placeholder
|
|
'stdDev': '+1.2', // Placeholder
|
|
'status': statusPertumbuhan['status_tb'],
|
|
},
|
|
'weight': {
|
|
'value': latest.weight,
|
|
'percentile': '60%', // Placeholder
|
|
'stdDev': '+0.8', // Placeholder
|
|
'status': statusPertumbuhan['status_bb'],
|
|
},
|
|
};
|
|
}
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_anakName = namaAnak;
|
|
_anakAge = '$ageInMonths bulan';
|
|
_growthData = apiGrowthData;
|
|
_growthStats = stats;
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
|
|
} catch (e) {
|
|
print('Error loading data: $e');
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_isLoading = false;
|
|
_growthData = []; // Kosongkan data jika error
|
|
_growthStats = {};
|
|
});
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Gagal memuat data: $e'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _changeSelectedAnak(int direction) {
|
|
if (_anakList.isEmpty) return;
|
|
|
|
setState(() {
|
|
// Hitung index anak baru (dengan wrapping)
|
|
_currentAnakIndex = (_currentAnakIndex + direction) % _anakList.length;
|
|
if (_currentAnakIndex < 0) _currentAnakIndex = _anakList.length - 1;
|
|
|
|
// Update current anak ID
|
|
_currentAnakId = _anakList[_currentAnakIndex]['id'];
|
|
|
|
// Tandai bahwa loading dimulai
|
|
_isLoading = true;
|
|
});
|
|
|
|
// Tunggu state selesai diperbarui sebelum memuat data
|
|
Future.microtask(() {
|
|
// Load data untuk anak yang baru dipilih
|
|
_loadData();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_animationController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
// Metode khusus untuk refresh yang dipanggil oleh RefreshIndicator
|
|
Future<void> _handleRefresh() async {
|
|
print('🔄 Pull-to-refresh: Starting refresh...');
|
|
|
|
// Reset loading state tetapi jangan tampilkan loading spinner penuh
|
|
// agar RefreshIndicator masih terlihat
|
|
|
|
try {
|
|
// Bersihkan cache API terkait perkembangan terlebih dahulu
|
|
// untuk memastikan mengambil data terbaru dari server
|
|
final ApiService apiService = ApiService();
|
|
print('🧹 Membersihkan cache perkembangan untuk memastikan data terbaru...');
|
|
|
|
// Hapus cache khusus perkembangan
|
|
apiService.clearCache(); // Ini akan membersihkan semua cache termasuk perkembangan
|
|
|
|
// Refresh data anak
|
|
await _loadData();
|
|
|
|
print('✅ Pull-to-refresh: Data successfully refreshed');
|
|
|
|
// Berikan feedback ke user
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Row(
|
|
children: [
|
|
Icon(Icons.check_circle, color: Colors.white),
|
|
SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Data terbaru berhasil dimuat',
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
backgroundColor: Colors.green,
|
|
behavior: SnackBarBehavior.floating,
|
|
duration: Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
print('❌ Pull-to-refresh: Error during refresh: $e');
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Row(
|
|
children: [
|
|
Icon(Icons.error_outline, color: Colors.white),
|
|
SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Gagal memuat data terbaru: $e',
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
backgroundColor: Colors.red,
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
print('🔄 Pull-to-refresh: Completed');
|
|
return;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final screenSize = MediaQuery.of(context).size;
|
|
|
|
return Scaffold(
|
|
backgroundColor: Colors.grey[100],
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.blue.shade700,
|
|
elevation: 0,
|
|
title: Text(
|
|
'Perkembangan Anak',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
leading: IconButton(
|
|
icon: Icon(Icons.arrow_back, color: Colors.white),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
),
|
|
body: _isLoading
|
|
? Center(child: CircularProgressIndicator(color: Colors.blue.shade700))
|
|
: FadeTransition(
|
|
opacity: _fadeAnimation,
|
|
child: RefreshIndicator(
|
|
color: Colors.blue.shade700,
|
|
onRefresh: _handleRefresh,
|
|
key: PageStorageKey('refresh_graph'),
|
|
child: _buildGrowthGraphs(screenSize),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildGrowthGraphs(Size screenSize) {
|
|
// Pastikan konten scrollable bahkan jika kosong
|
|
return SingleChildScrollView(
|
|
key: PageStorageKey('growth_graphs_scroll'),
|
|
physics: AlwaysScrollableScrollPhysics(),
|
|
padding: EdgeInsets.all(16),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
minHeight: MediaQuery.of(context).size.height - 200, // Estimasi tinggi tab minus app bar
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Data Pertumbuhan',
|
|
style: TextStyle(
|
|
fontSize: screenSize.width * 0.05,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.blue.shade800,
|
|
),
|
|
),
|
|
SizedBox(height: 8),
|
|
|
|
// Selector anak
|
|
_buildAnakSelector(),
|
|
|
|
SizedBox(height: 8),
|
|
Text(
|
|
'$_anakName - $_anakAge',
|
|
style: TextStyle(
|
|
fontSize: screenSize.width * 0.035,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
SizedBox(height: 24),
|
|
|
|
if (_growthData.isEmpty)
|
|
_buildEmptyStateCard()
|
|
else
|
|
Column(
|
|
children: [
|
|
// Height card
|
|
_buildGrowthCard(
|
|
title: 'Tinggi Badan',
|
|
value: '${_growthStats['height']?['value'] ?? 0} cm',
|
|
icon: Icons.height,
|
|
color: Colors.blue,
|
|
dataKey: 'height',
|
|
maxY: 120,
|
|
minY: 45,
|
|
stats: _growthStats['height'] ?? {},
|
|
),
|
|
SizedBox(height: 20),
|
|
|
|
// Weight card
|
|
_buildGrowthCard(
|
|
title: 'Berat Badan',
|
|
value: '${_growthStats['weight']?['value'] ?? 0} kg',
|
|
icon: Icons.monitor_weight,
|
|
color: Colors.orange,
|
|
dataKey: 'weight',
|
|
maxY: 25,
|
|
minY: 2,
|
|
stats: _growthStats['weight'] ?? {},
|
|
),
|
|
],
|
|
),
|
|
|
|
// Tambahkan padding di bawah konten untuk pengalaman scroll yang lebih baik
|
|
SizedBox(height: 50),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAnakSelector() {
|
|
if (_anakList.isEmpty) return SizedBox.shrink();
|
|
|
|
return Container(
|
|
margin: EdgeInsets.symmetric(vertical: 8),
|
|
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(12),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.05),
|
|
blurRadius: 5,
|
|
offset: Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
IconButton(
|
|
icon: Icon(Icons.arrow_back_ios, size: 18, color: Colors.blue.shade700),
|
|
onPressed: () => _changeSelectedAnak(-1),
|
|
),
|
|
Expanded(
|
|
child: Column(
|
|
children: [
|
|
Text(
|
|
'Pilih Anak',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey.shade600,
|
|
),
|
|
),
|
|
SizedBox(height: 4),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
for (int i = 0; i < _anakList.length; i++)
|
|
Container(
|
|
width: 8,
|
|
height: 8,
|
|
margin: EdgeInsets.symmetric(horizontal: 2),
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: i == _currentAnakIndex
|
|
? Colors.blue.shade700
|
|
: Colors.grey.shade300,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: Icon(Icons.arrow_forward_ios, size: 18, color: Colors.blue.shade700),
|
|
onPressed: () => _changeSelectedAnak(1),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildEmptyStateCard() {
|
|
return Card(
|
|
elevation: 4,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(15),
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.all(20),
|
|
child: Column(
|
|
children: [
|
|
Icon(
|
|
Icons.bar_chart,
|
|
size: 60,
|
|
color: Colors.grey.shade400,
|
|
),
|
|
SizedBox(height: 16),
|
|
Text(
|
|
'Belum ada data pertumbuhan',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.grey.shade700,
|
|
),
|
|
),
|
|
SizedBox(height: 8),
|
|
Text(
|
|
'Tidak ada data pertumbuhan untuk ditampilkan',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: Colors.grey.shade600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildGrowthCard({
|
|
required String title,
|
|
required String value,
|
|
required IconData icon,
|
|
required Color color,
|
|
required String dataKey,
|
|
required double maxY,
|
|
required double minY,
|
|
required Map<String, dynamic> stats,
|
|
}) {
|
|
return Card(
|
|
elevation: 4,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(15),
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(icon, color: color),
|
|
SizedBox(width: 8),
|
|
Text(
|
|
title,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Text(
|
|
value,
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: color,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 8),
|
|
// Keterangan tentang grafik
|
|
Container(
|
|
padding: EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade100,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(
|
|
'Grafik menampilkan seluruh riwayat pengukuran untuk melihat perkembangan dari waktu ke waktu',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontStyle: FontStyle.italic,
|
|
color: Colors.grey.shade700,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(height: 16),
|
|
Container(
|
|
height: 200,
|
|
child: CustomPaint(
|
|
size: Size(double.infinity, 200),
|
|
painter: GraphPainter(
|
|
data: _growthData,
|
|
dataKey: dataKey,
|
|
maxY: maxY,
|
|
minY: minY,
|
|
lineColor: color,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Color _getStatusColor(String? status) {
|
|
if (status == null) return Colors.green;
|
|
|
|
switch (status.toLowerCase()) {
|
|
case 'kurang':
|
|
case 'pendek':
|
|
return Colors.orange;
|
|
case 'lebih':
|
|
case 'tinggi':
|
|
return Colors.blue;
|
|
case 'normal':
|
|
default:
|
|
return Colors.green;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pindahkan GraphPainter ke level teratas
|
|
class GraphPainter extends CustomPainter {
|
|
final List<GrowthData> data;
|
|
final String dataKey;
|
|
final double maxY;
|
|
final double minY;
|
|
final Color lineColor;
|
|
|
|
GraphPainter({
|
|
required this.data,
|
|
required this.dataKey,
|
|
required this.maxY,
|
|
required this.minY,
|
|
required this.lineColor,
|
|
});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
// Skip drawing if we don't have enough data
|
|
if (data.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
// Draw axes
|
|
final Paint axesPaint = Paint()
|
|
..color = Colors.grey.shade300
|
|
..strokeWidth = 1
|
|
..style = PaintingStyle.stroke;
|
|
|
|
// X axis
|
|
canvas.drawLine(
|
|
Offset(0, size.height),
|
|
Offset(size.width, size.height),
|
|
axesPaint,
|
|
);
|
|
|
|
// Y axis
|
|
canvas.drawLine(
|
|
Offset(0, 0),
|
|
Offset(0, size.height),
|
|
axesPaint,
|
|
);
|
|
|
|
// Draw grid lines
|
|
for (int i = 0; i < 5; i++) {
|
|
final y = size.height - (i * size.height / 4);
|
|
canvas.drawLine(
|
|
Offset(0, y),
|
|
Offset(size.width, y),
|
|
axesPaint,
|
|
);
|
|
|
|
// Draw y-axis labels
|
|
final value = minY + (i * (maxY - minY) / 4);
|
|
final textSpan = TextSpan(
|
|
text: value.toStringAsFixed(1),
|
|
style: TextStyle(
|
|
color: Colors.grey.shade600,
|
|
fontSize: 10,
|
|
),
|
|
);
|
|
final textPainter = TextPainter(
|
|
text: textSpan,
|
|
textDirection: ui.TextDirection.ltr,
|
|
);
|
|
textPainter.layout();
|
|
textPainter.paint(canvas, Offset(-25, y - 6));
|
|
}
|
|
|
|
// Calculate x step - handle case when there's only one data point
|
|
final xStep = data.length > 1
|
|
? size.width / (data.length - 1)
|
|
: size.width;
|
|
|
|
// Draw vertical grid lines and x-axis labels
|
|
for (int i = 0; i < data.length; i++) {
|
|
final x = data.length > 1 ? i * xStep : size.width / 2;
|
|
|
|
// Vertical grid line
|
|
if (i > 0 && i < data.length - 1) {
|
|
canvas.drawLine(
|
|
Offset(x, 0),
|
|
Offset(x, size.height),
|
|
axesPaint,
|
|
);
|
|
}
|
|
|
|
// X-axis label
|
|
String label;
|
|
if (data[i].tanggal != null) {
|
|
label = DateFormat('dd/MM').format(data[i].tanggal!);
|
|
} else {
|
|
label = '${data[i].month}';
|
|
}
|
|
|
|
final textSpan = TextSpan(
|
|
text: label,
|
|
style: TextStyle(
|
|
color: Colors.grey.shade600,
|
|
fontSize: 10,
|
|
),
|
|
);
|
|
final textPainter = TextPainter(
|
|
text: textSpan,
|
|
textDirection: ui.TextDirection.ltr,
|
|
);
|
|
textPainter.layout();
|
|
textPainter.paint(canvas, Offset(x - 10, size.height + 5));
|
|
}
|
|
|
|
// Only draw line chart if we have data
|
|
if (data.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
// Draw line chart
|
|
final Paint linePaint = Paint()
|
|
..color = lineColor
|
|
..strokeWidth = 3
|
|
..style = PaintingStyle.stroke;
|
|
|
|
final Paint fillPaint = Paint()
|
|
..color = lineColor.withOpacity(0.2)
|
|
..style = PaintingStyle.fill;
|
|
|
|
final Path linePath = Path();
|
|
final Path fillPath = Path();
|
|
|
|
// For single point, draw a dot instead of a line
|
|
if (data.length == 1) {
|
|
final rawY = dataKey == 'height' ? data[0].height : data[0].weight;
|
|
final normalizedY = (rawY - minY) / (maxY - minY);
|
|
final y = size.height - (normalizedY * size.height);
|
|
final x = size.width / 2;
|
|
|
|
// Draw point
|
|
final Paint pointPaint = Paint()
|
|
..color = Colors.white
|
|
..strokeWidth = 2
|
|
..style = PaintingStyle.fill;
|
|
|
|
final Paint pointStrokePaint = Paint()
|
|
..color = lineColor
|
|
..strokeWidth = 2
|
|
..style = PaintingStyle.stroke;
|
|
|
|
// Add to fill path for the background
|
|
fillPath.moveTo(x, size.height);
|
|
fillPath.lineTo(x, y);
|
|
fillPath.lineTo(x, size.height);
|
|
fillPath.close();
|
|
|
|
canvas.drawCircle(Offset(x, y), 5, pointPaint);
|
|
canvas.drawCircle(Offset(x, y), 5, pointStrokePaint);
|
|
canvas.drawPath(fillPath, fillPaint);
|
|
return;
|
|
}
|
|
|
|
// Multiple points - draw a line
|
|
for (int i = 0; i < data.length; i++) {
|
|
final x = i * xStep;
|
|
final rawY = dataKey == 'height' ? data[i].height : data[i].weight;
|
|
|
|
// Ensure valid bounds to prevent NaN values
|
|
final normalizedY = (maxY > minY)
|
|
? ((rawY - minY) / (maxY - minY)).clamp(0.0, 1.0)
|
|
: 0.5; // Default to middle if min=max
|
|
|
|
final y = size.height - (normalizedY * size.height);
|
|
|
|
if (i == 0) {
|
|
linePath.moveTo(x, y);
|
|
fillPath.moveTo(x, size.height);
|
|
fillPath.lineTo(x, y);
|
|
} else {
|
|
linePath.lineTo(x, y);
|
|
fillPath.lineTo(x, y);
|
|
}
|
|
|
|
// Draw point
|
|
final Paint pointPaint = Paint()
|
|
..color = Colors.white
|
|
..strokeWidth = 2
|
|
..style = PaintingStyle.fill;
|
|
|
|
final Paint pointStrokePaint = Paint()
|
|
..color = lineColor
|
|
..strokeWidth = 2
|
|
..style = PaintingStyle.stroke;
|
|
|
|
canvas.drawCircle(Offset(x, y), 5, pointPaint);
|
|
canvas.drawCircle(Offset(x, y), 5, pointStrokePaint);
|
|
}
|
|
|
|
// Complete fill path
|
|
fillPath.lineTo((data.length - 1) * xStep, size.height);
|
|
fillPath.close();
|
|
|
|
canvas.drawPath(fillPath, fillPaint);
|
|
canvas.drawPath(linePath, linePaint);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
|
}
|