805 lines
24 KiB
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"),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|