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

221 lines
6.2 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';
/// Grafik ringkas gabungan berat dan tinggi untuk card overview.
class MiniGrowthPreview extends StatelessWidget {
const MiniGrowthPreview({
super.key,
required this.childName,
required this.history,
this.compact = false,
});
final String childName;
final List<RiwayatItemModel> history;
final bool compact;
@override
Widget build(BuildContext context) {
final safeHistory = _sanitizeGrowthItems(history);
final points = safeHistory.length > 6
? safeHistory.skip(safeHistory.length - 6).toList()
: safeHistory;
final chartHeight = compact ? 72.0 : 64.0;
if (points.isEmpty) {
return Container(
height: chartHeight,
width: double.infinity,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFFF8FAFA),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE8EEEC)),
),
child: Text(
'Belum ada data grafik',
style: AppTypography.caption.copyWith(fontSize: 11),
),
);
}
final weights = points.map((e) => e.berat).toList();
final heights = points.map((e) => e.tinggi).toList();
final weightSpots = _normalizedSpots(weights);
final heightSpots = _normalizedSpots(heights);
const weightColor = Color(0xFF0B7A86);
const heightColor = Color(0xFF4F7EE8);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!compact && childName.isNotEmpty) ...[
Text(
childName,
style: AppTypography.h3,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
],
if (compact) ...[
Row(
children: const [
_MiniLegend(color: weightColor, label: 'BB'),
SizedBox(width: 10),
_MiniLegend(color: heightColor, label: 'TB'),
],
),
const SizedBox(height: 4),
],
SizedBox(
height: chartHeight,
width: double.infinity,
child: LineChart(
LineChartData(
minX: 0,
maxX: math.max(1, points.length - 1).toDouble(),
minY: -0.08,
maxY: 1.08,
gridData: const FlGridData(show: false),
titlesData: const FlTitlesData(show: false),
borderData: FlBorderData(show: false),
lineTouchData: const LineTouchData(enabled: false),
lineBarsData: [
_line(weightSpots, weightColor, showArea: true),
_line(heightSpots, heightColor),
],
),
duration: const Duration(milliseconds: 420),
curve: Curves.easeOutCubic,
),
),
],
);
}
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 = false,
}) {
return LineChartBarData(
spots: spots,
isCurved: true,
curveSmoothness: 0.35,
color: color,
barWidth: 2.5,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, bar, index) => FlDotCirclePainter(
radius: index == spots.length - 1 ? 3.8 : 2.2,
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 _MiniLegend extends StatelessWidget {
const _MiniLegend({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: 7,
height: 7,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 4),
Text(
label,
style: AppTypography.caption.copyWith(
color: const Color(0xFF5D6B68),
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
],
);
}
}
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);
});
final safe = <RiwayatItemModel>[];
for (final item in sorted) {
if (!_hasValidMeasurement(item)) continue;
final previous = safe.isEmpty ? null : safe.last;
if (previous != null) {
final heightDelta = item.tinggi - previous.tinggi;
final weightDelta = item.berat - previous.berat;
final weightRatioTooSharp =
previous.berat > 0 && (weightDelta.abs() / previous.berat) > 0.55;
if (heightDelta < -3 ||
heightDelta.abs() > 25 ||
weightDelta.abs() > 10 ||
weightRatioTooSharp) {
continue;
}
}
safe.add(item);
}
return safe;
}
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;
}