MIF_E31231313/Android/flutter_app/lib/widgets/growth_chart_card.dart

410 lines
13 KiB
Dart

import 'dart:math' as math;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:s_gizi/app_design.dart';
import 'package:s_gizi/models/riwayat_response_model.dart';
class GrowthChartCard extends StatelessWidget {
const GrowthChartCard({super.key, required this.history, this.onViewDetail});
final List<RiwayatItemModel> history;
final VoidCallback? onViewDetail;
@override
Widget build(BuildContext context) {
final safeHistory = _sanitizeGrowthItems(history);
final points = safeHistory.length > 6
? safeHistory.skip(safeHistory.length - 6).toList()
: safeHistory;
if (points.isEmpty) {
return const HealthCard(
child: Text(
'Belum ada data pengukuran untuk menampilkan grafik pertumbuhan.',
style: AppTypography.body,
),
);
}
final labels = points
.map((e) => _shortDate(DateTime.tryParse(e.tanggalUkur)))
.toList();
final weights = points.map((e) => e.berat).toList();
final heights = points.map((e) => e.tinggi).toList();
final weightTrend = _latestTrendText(weights, 'Berat');
final heightTrend = _latestTrendText(heights, 'Tinggi');
return HealthCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Grafik Pertumbuhan Anak', style: AppTypography.h3),
const SizedBox(height: 4),
Text(
'Monitoring singkat berat dan tinggi badan',
style: AppTypography.caption,
),
],
),
),
if (onViewDetail != null)
IconButton(
onPressed: onViewDetail,
icon: const Icon(Icons.chevron_right_rounded),
color: const Color(0xFF8B959C),
),
],
),
const SizedBox(height: 10),
const Row(
children: [
_LegendDot(color: Color(0xFF0B7A86), label: 'Berat Badan'),
SizedBox(width: 14),
_LegendDot(color: Color(0xFF4F7EE8), label: 'Tinggi Badan'),
],
),
const SizedBox(height: 12),
_CombinedGrowthLineChart(
weights: weights,
heights: heights,
labels: labels,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_TrendSummaryChip(text: weightTrend),
_TrendSummaryChip(text: heightTrend),
],
),
],
),
);
}
String _shortDate(DateTime? date) {
if (date == null) return '-';
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'Mei',
'Jun',
'Jul',
'Agu',
'Sep',
'Okt',
'Nov',
'Des',
];
return '${date.day} ${months[date.month - 1]}';
}
String _latestTrendText(List<double> values, String label) {
if (values.length < 2) return '$label stabil';
final delta = values.last - values[values.length - 2];
final threshold = label.toLowerCase().contains('berat') ? 0.1 : 0.2;
if (delta > threshold) return '$label naik';
if (delta < -threshold) return '$label menurun';
return '$label stabil';
}
// ignore: unused_element
String _trendText(List<double> values, String label) {
if (values.length < 2) return '$label stabil';
final delta = values.last - values.first;
if (delta > 0.2) return '$label naik stabil';
if (delta < -0.2) return '$label menurun';
return '$label stabil';
}
}
class _CombinedGrowthLineChart extends StatelessWidget {
const _CombinedGrowthLineChart({
required this.weights,
required this.heights,
required this.labels,
});
final List<double> weights;
final List<double> heights;
final List<String> labels;
@override
Widget build(BuildContext context) {
final weightSpots = _normalizedSpots(weights);
final heightSpots = _normalizedSpots(heights);
return SizedBox(
height: 170,
child: Stack(
children: [
Positioned.fill(
child: LineChart(
LineChartData(
minX: 0,
maxX: math.max(1, labels.length - 1).toDouble(),
minY: -0.08,
maxY: 1.08,
gridData: FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (_) =>
const FlLine(color: Color(0xFFE8EEEC), strokeWidth: 1),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 26,
interval: 1,
getTitlesWidget: (value, meta) {
final idx = value.toInt();
if (idx < 0 || idx >= labels.length) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
labels[idx],
style: AppTypography.caption.copyWith(fontSize: 10),
),
);
},
),
),
),
borderData: FlBorderData(show: false),
lineTouchData: LineTouchData(
handleBuiltInTouches: true,
touchTooltipData: LineTouchTooltipData(
getTooltipColor: (_) => Colors.white,
tooltipRoundedRadius: 14,
getTooltipItems: (spots) => spots.map((spot) {
final idx = spot.x.toInt().clamp(0, labels.length - 1);
return LineTooltipItem(
'${labels[idx]}\nBB ${weights[idx].toStringAsFixed(1)} kg\nTB ${heights[idx].toStringAsFixed(1)} cm',
AppTypography.caption.copyWith(
color: SgColors.textPrimary,
fontWeight: FontWeight.w800,
),
);
}).toList(),
),
),
lineBarsData: [
_line(weightSpots, const Color(0xFF0B7A86), true),
_line(heightSpots, const Color(0xFF4F7EE8), false),
],
),
duration: const Duration(milliseconds: 520),
curve: Curves.easeOutCubic,
),
),
_LastValueLabel(
value: weights.last.toStringAsFixed(1),
color: const Color(0xFF0B7A86),
topFactor: 1 - weightSpots.last.y,
),
_LastValueLabel(
value: heights.last.toStringAsFixed(0),
color: const Color(0xFF4F7EE8),
topFactor: 1 - heightSpots.last.y,
offsetY: 20,
),
],
),
);
}
List<FlSpot> _normalizedSpots(List<double> values) {
final min = values.reduce((a, b) => a < b ? a : b);
final max = values.reduce((a, b) => a > b ? a : b);
final range = (max - min).abs() < 0.01 ? 1.0 : max - min;
return [
for (var i = 0; i < values.length; i++)
FlSpot(i.toDouble(), (values[i] - min) / range),
];
}
LineChartBarData _line(List<FlSpot> spots, Color color, bool showArea) {
return LineChartBarData(
spots: spots,
isCurved: true,
curveSmoothness: 0.35,
color: color,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, bar, index) => FlDotCirclePainter(
radius: index == spots.length - 1 ? 4.2 : 2.6,
color: Colors.white,
strokeWidth: 2,
strokeColor: color,
),
),
belowBarData: BarAreaData(
show: showArea,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
color.withValues(alpha: 0.15),
color.withValues(alpha: 0.02),
],
),
),
);
}
}
class _TrendSummaryChip extends StatelessWidget {
const _TrendSummaryChip({required this.text});
final String text;
@override
Widget build(BuildContext context) {
final lower = text.toLowerCase();
final isDown = text.startsWith('') || lower.contains('menurun');
final isStable = text.startsWith('') || lower.contains('stabil');
final color = isDown
? const Color(0xFFE57373)
: isStable
? const Color(0xFF7E8B87)
: const Color(0xFF0B7A86);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.22)),
),
child: Text(
text,
style: AppTypography.caption.copyWith(
color: color,
fontWeight: FontWeight.w800,
),
),
);
}
}
class _LastValueLabel extends StatelessWidget {
const _LastValueLabel({
required this.value,
required this.color,
required this.topFactor,
this.offsetY = 0,
});
final String value;
final Color color;
final double topFactor;
final double offsetY;
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: LayoutBuilder(
builder: (context, constraints) {
return Stack(
children: [
Positioned(
right: 0,
top:
(topFactor.clamp(0.04, 0.78) *
(constraints.maxHeight - 44)) +
offsetY,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 3,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.35)),
),
child: Text(
value,
style: AppTypography.caption.copyWith(
color: color,
fontSize: 10,
fontWeight: FontWeight.w900,
),
),
),
),
],
);
},
),
);
}
}
List<RiwayatItemModel> _sanitizeGrowthItems(List<RiwayatItemModel> items) {
final sorted = [...items]
..sort((a, b) {
final ad = DateTime.tryParse(a.tanggalUkur) ?? DateTime(1900);
final bd = DateTime.tryParse(b.tanggalUkur) ?? DateTime(1900);
return ad.compareTo(bd);
});
return sorted.where(_hasValidMeasurement).toList();
}
bool _hasValidMeasurement(RiwayatItemModel item) {
final date = DateTime.tryParse(item.tanggalUkur);
if (date == null) return false;
if (!item.berat.isFinite || !item.tinggi.isFinite) return false;
if (item.berat < 1.5 || item.berat > 45) return false;
if (item.tinggi < 45 || item.tinggi > 125) return false;
if (item.berat > item.tinggi) return false;
return true;
}
class _LegendDot extends StatelessWidget {
const _LegendDot({required this.color, required this.label});
final Color color;
final String label;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(label, style: AppTypography.caption),
],
);
}
}