TKK__E32232246/lib/riwayat_screen.dart

805 lines
24 KiB
Dart

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<String, dynamic> 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<RiwayatScreen> createState() => _RiwayatScreenState();
}
class _RiwayatScreenState extends State<RiwayatScreen> {
List<HistoryEntry> allHistoryData = [];
List<HistoryEntry> 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<String> parts = rawTime.split(' ');
if (parts.length == 2) {
List<String> dateParts = parts[0].split('-');
List<String> 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<void> 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<HistoryEntry> 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<FlSpot> _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<String> _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<HistoryEntry>.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<FlSpot> spots;
final List<FlSpot>? spotsDiastolic;
final List<String> 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"),
],
),
),
],
),
);
}
}