import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'dart:convert'; import 'package:fl_chart/fl_chart.dart'; import 'package:intl/intl.dart'; // --- Global Constants --- const Color redColor = Color(0xFFE57373); const Color blueColor = Color(0xFF64B5F6); const Color purpleColor = Color(0xFFBA68C8); const Color greenColor = Color(0xFF4CAF50); const Color lightRedColor = Color(0xFFFFEBEE); const Color lightBlueColor = Color(0xFFE3F2FD); const Color lightPurpleColor = Color(0xFFF3E5F5); const Color lightGreenColor = Color(0xFFE8F5E9); const Color normalStatusColor = Color(0xFFC8E6C9); const Color normalStatusTextColor = Color(0xFF388E3C); const Color attentionStatusColor = Color(0xFFFFF3E0); const Color attentionStatusTextColor = Color(0xFFEF6C00); const TextStyle bodyTextStyle = TextStyle( color: Colors.black87, fontSize: 13, height: 1.5, ); class HistoryEntry { final String createdAt; final int bpm; final double temperature; final String systolic; final String diastolic; final int spo2; HistoryEntry({ required this.createdAt, required this.bpm, required this.temperature, required this.systolic, required this.diastolic, required this.spo2, }); factory HistoryEntry.fromJson(Map json) { return HistoryEntry( createdAt: json['created_at']?.toString() ?? '', bpm: int.tryParse(json['bpm']?.toString() ?? '0') ?? 0, temperature: double.tryParse(json['temperature']?.toString() ?? '0.0') ?? 0.0, systolic: json['systolic']?.toString() ?? '0', diastolic: json['diastolic']?.toString() ?? '0', spo2: int.tryParse(json['spo2']?.toString() ?? '0') ?? 0, ); } String get status { if (bpm < 60 || bpm > 100 || temperature < 36.0 || temperature > 37.5 || spo2 < 95) { return 'Perlu Perhatian'; } return 'Normal'; } Color get statusColor => status == 'Normal' ? normalStatusColor : attentionStatusColor; Color get statusTextColor => status == 'Normal' ? normalStatusTextColor : attentionStatusTextColor; } class RiwayatScreen extends StatefulWidget { const RiwayatScreen({super.key}); @override State createState() => _RiwayatScreenState(); } class _RiwayatScreenState extends State { List allHistoryData = []; List filteredDataList = []; bool isLoading = true; String errorMessage = ""; String selectedPeriod = 'Harian'; int currentPage = 1; final int itemsPerPage = 5; final String apiUrl = "http://10.105.126.239/iot/api.php"; @override void initState() { super.initState(); fetchHistory(); } DateTime? _parseDateTime(String rawTime) { if (rawTime.isEmpty) return null; try { List parts = rawTime.split(' '); if (parts.length == 2) { List dateParts = parts[0].split('-'); List timeParts = parts[1].split(':'); if (dateParts.length == 3 && timeParts.length >= 2) { return DateTime( int.parse(dateParts[0]), int.parse(dateParts[1]), int.parse(dateParts[2]), int.parse(timeParts[0]), int.parse(timeParts[1]), timeParts.length > 2 ? int.parse(timeParts[2]) : 0, ); } } } catch (_) {} return null; } Future fetchHistory() async { setState(() { isLoading = true; errorMessage = ""; }); try { final response = await http .get( Uri.parse('$apiUrl?action=history'), headers: {'Cache-Control': 'no-cache'}, ) .timeout(const Duration(seconds: 10)); if (response.statusCode == 200) { final data = json.decode(response.body); if (data['status'] == 'success') { final rawDataList = data['data'] ?? []; allHistoryData = (rawDataList as List) .map((entry) => HistoryEntry.fromJson(entry)) .toList(); allHistoryData.sort((a, b) { DateTime? dateA = _parseDateTime(a.createdAt); DateTime? dateB = _parseDateTime(b.createdAt); return (dateB ?? DateTime(0)).compareTo(dateA ?? DateTime(0)); }); _filterDataByPeriod(); } else { errorMessage = data['message'] ?? 'Gagal mengambil data'; } } else { errorMessage = "Server error: ${response.statusCode}"; } } catch (e) { errorMessage = "Tidak bisa terhubung ke server.\nDetail: $e"; } finally { setState(() => isLoading = false); } } void _filterDataByPeriod() { if (allHistoryData.isEmpty) { setState(() => filteredDataList = []); return; } // Ambil tanggal terbaru sebagai referensi DateTime? latestDate; for (var entry in allHistoryData) { DateTime? date = _parseDateTime(entry.createdAt); if (date != null && (latestDate == null || date.isAfter(latestDate))) { latestDate = date; } } if (latestDate == null) { setState(() => filteredDataList = allHistoryData); return; } DateTime refDay = DateTime( latestDate.year, latestDate.month, latestDate.day, ); List filtered = []; for (var entry in allHistoryData) { DateTime? entryDate = _parseDateTime(entry.createdAt); if (entryDate == null) continue; DateTime entryDay = DateTime( entryDate.year, entryDate.month, entryDate.day, ); bool isMatch = false; if (selectedPeriod == 'Harian') { isMatch = entryDay.isAtSameMomentAs(refDay); } else if (selectedPeriod == 'Mingguan') { DateTime sevenDaysAgo = refDay.subtract(const Duration(days: 7)); isMatch = entryDay.isAfter(sevenDaysAgo) || entryDay.isAtSameMomentAs(refDay); } else if (selectedPeriod == 'Bulanan') { isMatch = entryDate.month == refDay.month && entryDate.year == refDay.year; } if (isMatch) filtered.add(entry); } setState(() { filteredDataList = filtered; currentPage = 1; }); } List _getSpots(String type) { if (filteredDataList.isEmpty) return []; return filteredDataList.asMap().entries.map((entry) { int index = entry.key; HistoryEntry data = entry.value; double value = switch (type) { 'bpm' => data.bpm.toDouble(), 'temperature' => data.temperature, 'bp_systolic' => double.tryParse(data.systolic) ?? 0, 'bp_diastolic' => double.tryParse(data.diastolic) ?? 0, 'spo2' => data.spo2.toDouble(), _ => 0.0, }; return FlSpot(index.toDouble(), value); }).toList(); } List _getXLabels() { if (filteredDataList.isEmpty) return []; int dataCount = filteredDataList.length; return filteredDataList.asMap().entries.map((entry) { int index = entry.key; DateTime? date = _parseDateTime(entry.value.createdAt); if (date == null) return ''; if (dataCount > 10) { int interval = (dataCount / 6).ceil(); if (index % interval == 0 || index == dataCount - 1) { return '${date.day}/${date.month}'; } return ''; } return '${date.day}/${date.month}'; }).toList(); } Widget _buildTopHeader() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.favorite, color: redColor, size: 28), const SizedBox(width: 8), const Text( "Health Monitor", style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold), ), ], ), const SizedBox(height: 16), const Text( "Riwayat Kesehatan", style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), ), const SizedBox(height: 16), _buildPeriodSelector(), ], ), ); } Widget _buildPeriodSelector() { return Row( children: ['Harian', 'Mingguan', 'Bulanan'].map((period) { final isSelected = period == selectedPeriod; return Expanded( child: GestureDetector( onTap: () { setState(() { selectedPeriod = period; _filterDataByPeriod(); }); }, child: Column( children: [ Text( period, style: TextStyle( fontWeight: isSelected ? FontWeight.bold : FontWeight.w500, color: isSelected ? blueColor : Colors.black54, ), ), const SizedBox(height: 4), Container( height: 3, width: 55, color: isSelected ? blueColor : Colors.transparent, ), ], ), ), ); }).toList(), ); } Widget _buildMetricCards() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 20.0), child: Column( children: [ Row( children: [ Expanded( child: _buildSingleMetricCard( "Detak Jantung", "BPM", 'bpm', redColor, lightRedColor, Icons.favorite, ), ), const SizedBox(width: 12), Expanded( child: _buildSingleMetricCard( "SpO2", "%", 'spo2', greenColor, lightGreenColor, Icons.air, ), ), ], ), const SizedBox(height: 16), Row( children: [ Expanded( child: HealthMetricCard( title: "Tekanan Darah", unit: "mmHg", spots: _getSpots('bp_systolic'), spotsDiastolic: _getSpots('bp_diastolic'), xLabels: _getXLabels(), color: purpleColor, bgColor: lightPurpleColor, icon: Icons.water_drop, ), ), const SizedBox(width: 12), Expanded( child: _buildSingleMetricCard( "Suhu Tubuh", "°C", 'temperature', blueColor, lightBlueColor, Icons.thermostat, ), ), ], ), ], ), ); } Widget _buildSingleMetricCard( String title, String unit, String type, Color color, Color bgColor, IconData icon, ) { return HealthMetricCard( title: title, unit: unit, spots: _getSpots(type), xLabels: _getXLabels(), color: color, bgColor: bgColor, icon: icon, ); } Widget _buildDetailedHistoryList() { final int startIndex = (currentPage - 1) * itemsPerPage; final int endIndex = startIndex + itemsPerPage; final sortedList = List.from(filteredDataList) ..sort( (a, b) => (_parseDateTime(b.createdAt) ?? DateTime(0)).compareTo( _parseDateTime(a.createdAt) ?? DateTime(0), ), ); final paginatedList = sortedList.length > startIndex ? sortedList.sublist( startIndex, endIndex > sortedList.length ? sortedList.length : endIndex, ) : []; final totalPages = (sortedList.length / itemsPerPage).ceil(); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( "Daftar Riwayat Rinci", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), const SizedBox(height: 12), if (paginatedList.isEmpty) const Padding( padding: EdgeInsets.symmetric(vertical: 40), child: Center( child: Text( "Belum ada data untuk periode ini.", style: TextStyle(color: Colors.black45), ), ), ) else ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: paginatedList.length, itemBuilder: (context, index) => DetailedHistoryListTile( data: paginatedList[index], parser: _parseDateTime, ), ), if (totalPages > 1) ...[ const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ IconButton( onPressed: currentPage > 1 ? () => setState(() => currentPage--) : null, icon: const Icon(Icons.arrow_back_ios, size: 16), ), Text("Halaman $currentPage dari $totalPages"), IconButton( onPressed: currentPage < totalPages ? () => setState(() => currentPage++) : null, icon: const Icon(Icons.arrow_forward_ios, size: 16), ), ], ), ], ], ), ); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFFAFAFA), body: SafeArea( child: RefreshIndicator( onRefresh: fetchHistory, child: isLoading ? const Center(child: CircularProgressIndicator()) : errorMessage.isNotEmpty ? _buildErrorWidget() : SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.symmetric(vertical: 20), child: Column( children: [ _buildTopHeader(), _buildMetricCards(), _buildDetailedHistoryList(), ], ), ), ), ), ); } Widget _buildErrorWidget() { return Center( child: Padding( padding: const EdgeInsets.all(20), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.error_outline, size: 70, color: redColor), const SizedBox(height: 16), Text( errorMessage, style: const TextStyle(color: redColor), textAlign: TextAlign.center, ), const SizedBox(height: 20), ElevatedButton.icon( onPressed: fetchHistory, style: ElevatedButton.styleFrom(backgroundColor: blueColor), icon: const Icon(Icons.refresh), label: const Text("Coba Lagi"), ), ], ), ), ); } } // ==================== HealthMetricCard ==================== class HealthMetricCard extends StatelessWidget { final String title; final String unit; final List spots; final List? spotsDiastolic; final List xLabels; final Color color; final Color bgColor; final IconData icon; const HealthMetricCard({ super.key, required this.title, required this.unit, required this.spots, this.spotsDiastolic, required this.xLabels, required this.color, required this.bgColor, required this.icon, }); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.06), blurRadius: 10, offset: const Offset(0, 4), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(icon, color: color, size: 20), const SizedBox(width: 8), Expanded( child: Text( title, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 15, ), ), ), ], ), const SizedBox(height: 20), SizedBox( height: 170, child: spots.isEmpty ? const Center( child: Text( "Tidak ada data", style: TextStyle(fontSize: 12, color: Colors.black38), ), ) : LineChart( LineChartData( gridData: FlGridData( show: true, drawVerticalLine: false, horizontalInterval: 10, ), titlesData: FlTitlesData( leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 35, getTitlesWidget: (value, meta) => value == meta.min || value == meta.max ? Text( value.toInt().toString(), style: const TextStyle(fontSize: 9), ) : const SizedBox(), ), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 28, getTitlesWidget: (value, meta) { int index = value.toInt(); if (index >= 0 && index < xLabels.length && xLabels[index].isNotEmpty) { return Text( xLabels[index], style: const TextStyle(fontSize: 9), ); } return const SizedBox(); }, ), ), ), borderData: FlBorderData( show: true, border: Border.all(color: Colors.grey.withOpacity(0.3)), ), lineBarsData: [ LineChartBarData( spots: spots, isCurved: true, color: color, barWidth: 2.5, dotData: const FlDotData(show: true), ), if (spotsDiastolic != null && spotsDiastolic!.isNotEmpty) LineChartBarData( spots: spotsDiastolic!, isCurved: true, color: color.withOpacity(0.6), barWidth: 2, ), ], minX: 0, maxX: (spots.length - 1).toDouble(), minY: _getMinY(), maxY: _getMaxY(), ), ), ), ], ), ); } double _getMinY() { if (spots.isEmpty) return 0; double minY = spots.map((s) => s.y).reduce((a, b) => a < b ? a : b); if (spotsDiastolic != null && spotsDiastolic!.isNotEmpty) { double minD = spotsDiastolic! .map((s) => s.y) .reduce((a, b) => a < b ? a : b); minY = minY < minD ? minY : minD; } return minY - 5; } double _getMaxY() { if (spots.isEmpty) return 0; double maxY = spots.map((s) => s.y).reduce((a, b) => a > b ? a : b); if (spotsDiastolic != null && spotsDiastolic!.isNotEmpty) { double maxD = spotsDiastolic! .map((s) => s.y) .reduce((a, b) => a > b ? a : b); maxY = maxY > maxD ? maxY : maxD; } return maxY + 5; } } class DetailedHistoryListTile extends StatelessWidget { final HistoryEntry data; final DateTime? Function(String) parser; const DetailedHistoryListTile({ super.key, required this.data, required this.parser, }); String _getFriendlyDateTime(String rawTime) { DateTime? date = parser(rawTime); if (date == null) return rawTime; return "${DateFormat('EEEE, d MMM yyyy', 'id_ID').format(date)}, ${DateFormat('HH:mm').format(date)} WIB"; } @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow(color: Colors.black.withOpacity(0.02), blurRadius: 6), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Text( _getFriendlyDateTime(data.createdAt), style: bodyTextStyle.copyWith( fontWeight: FontWeight.bold, fontSize: 14, ), ), ), Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6, ), decoration: BoxDecoration( color: data.statusColor, borderRadius: BorderRadius.circular(20), ), child: Text( data.status, style: bodyTextStyle.copyWith( color: data.statusTextColor, fontWeight: FontWeight.bold, fontSize: 12, ), ), ), ], ), const SizedBox(height: 12), Text.rich( TextSpan( children: [ TextSpan( text: "Detak Jantung: ", style: bodyTextStyle.copyWith( color: redColor, fontWeight: FontWeight.bold, ), ), TextSpan(text: "${data.bpm} BPM | "), TextSpan( text: "SpO2: ", style: bodyTextStyle.copyWith( color: greenColor, fontWeight: FontWeight.bold, ), ), TextSpan(text: "${data.spo2}% | "), TextSpan( text: "Suhu: ", style: bodyTextStyle.copyWith( color: blueColor, fontWeight: FontWeight.bold, ), ), TextSpan(text: "${data.temperature} °C | "), TextSpan( text: "TD: ", style: bodyTextStyle.copyWith( color: purpleColor, fontWeight: FontWeight.bold, ), ), TextSpan(text: "${data.systolic}/${data.diastolic} mmHg"), ], ), ), ], ), ); } }