|
|
|
@ -0,0 +1,620 @@
|
|
|
|
|
|
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
|
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
|
|
|
import 'package:hmg_patient_app_new/core/app_assets.dart';
|
|
|
|
|
|
|
|
import 'package:hmg_patient_app_new/core/common_models/data_points.dart';
|
|
|
|
|
|
|
|
import 'package:hmg_patient_app_new/core/utils/size_utils.dart';
|
|
|
|
|
|
|
|
import 'package:hmg_patient_app_new/core/utils/utils.dart';
|
|
|
|
|
|
|
|
import 'package:hmg_patient_app_new/extensions/string_extensions.dart';
|
|
|
|
|
|
|
|
import 'package:hmg_patient_app_new/extensions/widget_extensions.dart';
|
|
|
|
|
|
|
|
import 'package:hmg_patient_app_new/features/hmg_services/hmg_services_view_model.dart';
|
|
|
|
|
|
|
|
import 'package:hmg_patient_app_new/features/hmg_services/models/resq_models/vital_sign_respo_model.dart';
|
|
|
|
|
|
|
|
import 'package:hmg_patient_app_new/features/hmg_services/models/ui_models/vital_sign_ui_model.dart';
|
|
|
|
|
|
|
|
import 'package:hmg_patient_app_new/theme/colors.dart';
|
|
|
|
|
|
|
|
import 'package:hmg_patient_app_new/widgets/appbar/collapsing_list_view.dart';
|
|
|
|
|
|
|
|
import 'package:hmg_patient_app_new/widgets/graph/custom_graph.dart';
|
|
|
|
|
|
|
|
import 'package:provider/provider.dart';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Which vital sign is being shown in the details screen.
|
|
|
|
|
|
|
|
enum VitalSignMetric {
|
|
|
|
|
|
|
|
bmi,
|
|
|
|
|
|
|
|
height,
|
|
|
|
|
|
|
|
weight,
|
|
|
|
|
|
|
|
bloodPressure,
|
|
|
|
|
|
|
|
temperature,
|
|
|
|
|
|
|
|
heartRate,
|
|
|
|
|
|
|
|
respiratoryRate,
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class VitalSignDetailsArgs {
|
|
|
|
|
|
|
|
final VitalSignMetric metric;
|
|
|
|
|
|
|
|
final String title;
|
|
|
|
|
|
|
|
final String icon;
|
|
|
|
|
|
|
|
final String unit;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Optional bounds used for graph shading and labels.
|
|
|
|
|
|
|
|
final double? low;
|
|
|
|
|
|
|
|
final double? high;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const VitalSignDetailsArgs({
|
|
|
|
|
|
|
|
required this.metric,
|
|
|
|
|
|
|
|
required this.title,
|
|
|
|
|
|
|
|
required this.icon,
|
|
|
|
|
|
|
|
required this.unit,
|
|
|
|
|
|
|
|
this.low,
|
|
|
|
|
|
|
|
this.high,
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class VitalSignDetailsPage extends StatefulWidget {
|
|
|
|
|
|
|
|
final VitalSignDetailsArgs args;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const VitalSignDetailsPage({super.key, required this.args});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
|
|
State<VitalSignDetailsPage> createState() => _VitalSignDetailsPageState();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _VitalSignDetailsPageState extends State<VitalSignDetailsPage> {
|
|
|
|
|
|
|
|
bool _isGraphVisible = true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
VitalSignDetailsArgs get args => widget.args;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
|
|
return CollapsingListView(
|
|
|
|
|
|
|
|
title: 'Vital Sign Details'.needTranslation,
|
|
|
|
|
|
|
|
child: Consumer<HmgServicesViewModel>(
|
|
|
|
|
|
|
|
builder: (context, viewModel, child) {
|
|
|
|
|
|
|
|
final latest = viewModel.vitalSignList.isNotEmpty ? viewModel.vitalSignList.first : null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
final history = _buildSeries(viewModel.vitalSignList, args);
|
|
|
|
|
|
|
|
final latestValueText = _latestValueText(latest);
|
|
|
|
|
|
|
|
final status = _statusForLatest(latest);
|
|
|
|
|
|
|
|
final scheme = VitalSignUiModel.scheme(status: status, label: args.title);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return SingleChildScrollView(
|
|
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
|
|
children: [
|
|
|
|
|
|
|
|
_headerCard(
|
|
|
|
|
|
|
|
context,
|
|
|
|
|
|
|
|
title: args.title,
|
|
|
|
|
|
|
|
icon: args.icon,
|
|
|
|
|
|
|
|
valueText: latestValueText,
|
|
|
|
|
|
|
|
status: status,
|
|
|
|
|
|
|
|
scheme: scheme,
|
|
|
|
|
|
|
|
latestDate: latest?.vitalSignDate,
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
SizedBox(height: 16.h),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_whatIsThisResultCard(context),
|
|
|
|
|
|
|
|
SizedBox(height: 16.h),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_historyCard(context, history: history),
|
|
|
|
|
|
|
|
SizedBox(height: 16.h),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_nextStepsCard(context),
|
|
|
|
|
|
|
|
SizedBox(height: 32.h),
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
).paddingAll(24.h),
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Widget _headerCard(
|
|
|
|
|
|
|
|
BuildContext context, {
|
|
|
|
|
|
|
|
required String title,
|
|
|
|
|
|
|
|
required String icon,
|
|
|
|
|
|
|
|
required String valueText,
|
|
|
|
|
|
|
|
required String? status,
|
|
|
|
|
|
|
|
required VitalSignUiModel scheme,
|
|
|
|
|
|
|
|
required DateTime? latestDate,
|
|
|
|
|
|
|
|
}) {
|
|
|
|
|
|
|
|
return Container(
|
|
|
|
|
|
|
|
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(
|
|
|
|
|
|
|
|
color: AppColors.whiteColor,
|
|
|
|
|
|
|
|
borderRadius: 24.h,
|
|
|
|
|
|
|
|
hasShadow: true,
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
padding: EdgeInsets.all(16.h),
|
|
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
|
|
children: [
|
|
|
|
|
|
|
|
Row(
|
|
|
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
|
|
|
|
|
children: [
|
|
|
|
|
|
|
|
Row(
|
|
|
|
|
|
|
|
children: [
|
|
|
|
|
|
|
|
Container(
|
|
|
|
|
|
|
|
padding: EdgeInsets.all(10.h),
|
|
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
|
|
color: scheme.iconBg,
|
|
|
|
|
|
|
|
borderRadius: BorderRadius.circular(12.r),
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
child: Utils.buildSvgWithAssets(
|
|
|
|
|
|
|
|
icon: icon,
|
|
|
|
|
|
|
|
width: 20.w,
|
|
|
|
|
|
|
|
height: 20.h,
|
|
|
|
|
|
|
|
iconColor: scheme.iconFg,
|
|
|
|
|
|
|
|
fit: BoxFit.contain,
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
SizedBox(width: 10.w),
|
|
|
|
|
|
|
|
title.toText18(isBold: true, weight: FontWeight.w600),
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
if (status != null)
|
|
|
|
|
|
|
|
Container(
|
|
|
|
|
|
|
|
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 6.h),
|
|
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
|
|
color: scheme.chipBg,
|
|
|
|
|
|
|
|
borderRadius: BorderRadius.circular(100.r),
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
child: status.toText11(
|
|
|
|
|
|
|
|
color: scheme.chipFg,
|
|
|
|
|
|
|
|
weight: FontWeight.w500,
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
SizedBox(height: 10.h),
|
|
|
|
|
|
|
|
(
|
|
|
|
|
|
|
|
latestDate != null
|
|
|
|
|
|
|
|
? ('Result of ${latestDate.toString().split(' ').first}'.needTranslation)
|
|
|
|
|
|
|
|
: ('Result of --'.needTranslation)
|
|
|
|
|
|
|
|
).toText11(weight: FontWeight.w500, color: AppColors.greyTextColor),
|
|
|
|
|
|
|
|
SizedBox(height: 12.h),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
valueText.toText28(isBold: true, color: AppColors.textColor, letterSpacing: -2),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (args.low != null || args.high != null) ...[
|
|
|
|
|
|
|
|
SizedBox(height: 8.h),
|
|
|
|
|
|
|
|
Text(
|
|
|
|
|
|
|
|
_referenceText(context),
|
|
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
|
|
fontSize: 12.f,
|
|
|
|
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
|
|
|
|
color: AppColors.greyTextColor,
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
String _referenceText(BuildContext context) {
|
|
|
|
|
|
|
|
if (args.low != null && args.high != null) {
|
|
|
|
|
|
|
|
return 'Reference range: ${args.low} – ${args.high} ${args.unit}'.needTranslation;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (args.low != null) {
|
|
|
|
|
|
|
|
return 'Reference range: ≥ ${args.low} ${args.unit}'.needTranslation;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (args.high != null) {
|
|
|
|
|
|
|
|
return 'Reference range: ≤ ${args.high} ${args.unit}'.needTranslation;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return '';
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Widget _whatIsThisResultCard(BuildContext context) {
|
|
|
|
|
|
|
|
return Container(
|
|
|
|
|
|
|
|
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(
|
|
|
|
|
|
|
|
color: AppColors.whiteColor,
|
|
|
|
|
|
|
|
borderRadius: 24.h,
|
|
|
|
|
|
|
|
hasShadow: true,
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
padding: EdgeInsets.all(16.h),
|
|
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
|
|
children: [
|
|
|
|
|
|
|
|
'What is this result?'.needTranslation.toText16(weight: FontWeight.w600),
|
|
|
|
|
|
|
|
SizedBox(height: 8.h),
|
|
|
|
|
|
|
|
_descriptionText(context).toText12(color: AppColors.greyTextColor, fontWeight: FontWeight.w500, maxLine: 10),
|
|
|
|
|
|
|
|
SizedBox(height: 12.h),
|
|
|
|
|
|
|
|
Row(
|
|
|
|
|
|
|
|
children: [
|
|
|
|
|
|
|
|
Utils.buildSvgWithAssets(icon: AppAssets.bulb, width: 16.w, height: 16.h, iconColor: AppColors.greyTextColor),
|
|
|
|
|
|
|
|
SizedBox(width: 6.w),
|
|
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
|
|
child: 'This information is for monitoring and not a diagnosis.'.needTranslation
|
|
|
|
|
|
|
|
.toText11(color: AppColors.greyTextColor, weight: FontWeight.w500, maxLine: 3),
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Widget _historyCard(BuildContext context, {required List<DataPoint> history}) {
|
|
|
|
|
|
|
|
return Container(
|
|
|
|
|
|
|
|
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(
|
|
|
|
|
|
|
|
color: AppColors.whiteColor,
|
|
|
|
|
|
|
|
borderRadius: 24.h,
|
|
|
|
|
|
|
|
hasShadow: true,
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
padding: EdgeInsets.all(16.h),
|
|
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
|
|
children: [
|
|
|
|
|
|
|
|
Row(
|
|
|
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
|
|
|
|
|
children: [
|
|
|
|
|
|
|
|
'History flowchart'.needTranslation.toText16(weight: FontWeight.w600),
|
|
|
|
|
|
|
|
Row(
|
|
|
|
|
|
|
|
children: [
|
|
|
|
|
|
|
|
// toggle graph/list similar to lab result details
|
|
|
|
|
|
|
|
Utils.buildSvgWithAssets(
|
|
|
|
|
|
|
|
icon: _isGraphVisible ? AppAssets.graphIcon : AppAssets.listIcon,
|
|
|
|
|
|
|
|
width: 18.w,
|
|
|
|
|
|
|
|
height: 18.h,
|
|
|
|
|
|
|
|
iconColor: AppColors.greyTextColor,
|
|
|
|
|
|
|
|
).onPress(() {
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
|
|
_isGraphVisible = !_isGraphVisible;
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
SizedBox(width: 10.w),
|
|
|
|
|
|
|
|
Utils.buildSvgWithAssets(icon: AppAssets.calendarGrey, width: 18.w, height: 18.h, iconColor: AppColors.greyTextColor),
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
SizedBox(height: 12.h),
|
|
|
|
|
|
|
|
if (history.isEmpty)
|
|
|
|
|
|
|
|
Utils.getNoDataWidget(context, noDataText: 'No history available'.needTranslation, isSmallWidget: true)
|
|
|
|
|
|
|
|
else if (_isGraphVisible)
|
|
|
|
|
|
|
|
_buildHistoryGraph(history)
|
|
|
|
|
|
|
|
else
|
|
|
|
|
|
|
|
_buildHistoryList(context, history),
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Widget _buildHistoryGraph(List<DataPoint> history) {
|
|
|
|
|
|
|
|
final minY = _minY(history);
|
|
|
|
|
|
|
|
final maxY = _maxY(history);
|
|
|
|
|
|
|
|
return CustomGraph(
|
|
|
|
|
|
|
|
dataPoints: history,
|
|
|
|
|
|
|
|
makeGraphBasedOnActualValue: true,
|
|
|
|
|
|
|
|
leftLabelReservedSize: 40,
|
|
|
|
|
|
|
|
showGridLines: true,
|
|
|
|
|
|
|
|
leftLabelInterval: _leftInterval(history),
|
|
|
|
|
|
|
|
maxY: maxY,
|
|
|
|
|
|
|
|
minY: minY,
|
|
|
|
|
|
|
|
maxX: history.length.toDouble() - .75,
|
|
|
|
|
|
|
|
horizontalInterval: .1,
|
|
|
|
|
|
|
|
leftLabelFormatter: (value) {
|
|
|
|
|
|
|
|
// Match the lab screen behavior: only show High/Low labels.
|
|
|
|
|
|
|
|
final v = double.parse(value.toStringAsFixed(1));
|
|
|
|
|
|
|
|
if (args.high != null && v == args.high) {
|
|
|
|
|
|
|
|
return _axisLabel('High'.needTranslation);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (args.low != null && v == args.low) {
|
|
|
|
|
|
|
|
return _axisLabel('Low'.needTranslation);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return const SizedBox.shrink();
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
getDrawingHorizontalLine: (value) {
|
|
|
|
|
|
|
|
value = double.parse(value.toStringAsFixed(1));
|
|
|
|
|
|
|
|
if ((args.high != null && value == args.high) || (args.low != null && value == args.low)) {
|
|
|
|
|
|
|
|
return FlLine(
|
|
|
|
|
|
|
|
color: AppColors.bgGreenColor.withValues(alpha: 0.6),
|
|
|
|
|
|
|
|
strokeWidth: 1,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return const FlLine(color: Colors.transparent, strokeWidth: 1);
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
graphColor: AppColors.blackColor,
|
|
|
|
|
|
|
|
graphShadowColor: Colors.transparent,
|
|
|
|
|
|
|
|
graphGridColor: AppColors.graphGridColor.withValues(alpha: .4),
|
|
|
|
|
|
|
|
bottomLabelFormatter: (value, data) {
|
|
|
|
|
|
|
|
if (data.isEmpty) return const SizedBox.shrink();
|
|
|
|
|
|
|
|
if (value == 0) return _bottomLabel(data[value.toInt()].label);
|
|
|
|
|
|
|
|
if (value == data.length - 1) return _bottomLabel(data[value.toInt()].label);
|
|
|
|
|
|
|
|
if (value == ((data.length - 1) / 2)) return _bottomLabel(data[value.toInt()].label);
|
|
|
|
|
|
|
|
return const SizedBox.shrink();
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
rangeAnnotations: _rangeAnnotations(history),
|
|
|
|
|
|
|
|
minX: (history.length == 1) ? null : -.2,
|
|
|
|
|
|
|
|
scrollDirection: Axis.horizontal,
|
|
|
|
|
|
|
|
height: 180.h,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Widget _buildHistoryList(BuildContext context, List<DataPoint> history) {
|
|
|
|
|
|
|
|
final items = history.reversed.toList();
|
|
|
|
|
|
|
|
final height = items.length < 3 ? items.length * 64.0 : 180.h;
|
|
|
|
|
|
|
|
return SizedBox(
|
|
|
|
|
|
|
|
height: height,
|
|
|
|
|
|
|
|
child: ListView.separated(
|
|
|
|
|
|
|
|
padding: EdgeInsets.zero,
|
|
|
|
|
|
|
|
itemCount: items.length,
|
|
|
|
|
|
|
|
separatorBuilder: (_, __) => Divider(
|
|
|
|
|
|
|
|
color: AppColors.borderOnlyColor.withValues(alpha: 0.1),
|
|
|
|
|
|
|
|
height: 1,
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
itemBuilder: (context, index) {
|
|
|
|
|
|
|
|
final dp = items[index];
|
|
|
|
|
|
|
|
return Padding(
|
|
|
|
|
|
|
|
padding: EdgeInsets.symmetric(vertical: 12.h),
|
|
|
|
|
|
|
|
child: Row(
|
|
|
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
|
|
|
|
|
children: [
|
|
|
|
|
|
|
|
dp.displayTime.toText12(color: AppColors.greyTextColor, fontWeight: FontWeight.w500),
|
|
|
|
|
|
|
|
('${dp.actualValue} ${dp.unitOfMeasurement ?? ''}').toText12(
|
|
|
|
|
|
|
|
color: AppColors.textColor,
|
|
|
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
double _minY(List<DataPoint> points) {
|
|
|
|
|
|
|
|
// IMPORTANT: y-axis uses actual numeric values (from actualValue).
|
|
|
|
|
|
|
|
final values = points.map((e) => double.tryParse(e.actualValue) ?? 0).toList();
|
|
|
|
|
|
|
|
final min = values.reduce((a, b) => a < b ? a : b);
|
|
|
|
|
|
|
|
final double boundLow = args.low ?? min;
|
|
|
|
|
|
|
|
return (min < boundLow ? min : boundLow) - 1;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
double _maxY(List<DataPoint> points) {
|
|
|
|
|
|
|
|
// IMPORTANT: y-axis uses actual numeric values (from actualValue).
|
|
|
|
|
|
|
|
final values = points.map((e) => double.tryParse(e.actualValue) ?? 0).toList();
|
|
|
|
|
|
|
|
final max = values.reduce((a, b) => a > b ? a : b);
|
|
|
|
|
|
|
|
final double boundHigh = args.high ?? max;
|
|
|
|
|
|
|
|
return (max > boundHigh ? max : boundHigh) + 1;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
double _leftInterval(List<DataPoint> points) {
|
|
|
|
|
|
|
|
// Keep it stable; graph will mostly show just two labels.
|
|
|
|
|
|
|
|
final range = (_maxY(points) - _minY(points)).abs();
|
|
|
|
|
|
|
|
if (range <= 0) return 1;
|
|
|
|
|
|
|
|
return (range / 4).clamp(1, 20);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
RangeAnnotations? _rangeAnnotations(List<DataPoint> points) {
|
|
|
|
|
|
|
|
if (args.low == null && args.high == null) return null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
final minY = _minY(points);
|
|
|
|
|
|
|
|
final maxY = _maxY(points);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
final List<HorizontalRangeAnnotation> ranges = [];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (args.low != null) {
|
|
|
|
|
|
|
|
ranges.add(
|
|
|
|
|
|
|
|
HorizontalRangeAnnotation(
|
|
|
|
|
|
|
|
y1: minY,
|
|
|
|
|
|
|
|
y2: args.low!,
|
|
|
|
|
|
|
|
color: AppColors.highAndLow.withValues(alpha: 0.05),
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (args.low != null && args.high != null) {
|
|
|
|
|
|
|
|
ranges.add(
|
|
|
|
|
|
|
|
HorizontalRangeAnnotation(
|
|
|
|
|
|
|
|
y1: args.low!,
|
|
|
|
|
|
|
|
y2: args.high!,
|
|
|
|
|
|
|
|
color: AppColors.bgGreenColor.withValues(alpha: 0.05),
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (args.high != null) {
|
|
|
|
|
|
|
|
ranges.add(
|
|
|
|
|
|
|
|
HorizontalRangeAnnotation(
|
|
|
|
|
|
|
|
y1: args.high!,
|
|
|
|
|
|
|
|
y2: maxY,
|
|
|
|
|
|
|
|
color: AppColors.criticalLowAndHigh.withValues(alpha: 0.05),
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return RangeAnnotations(horizontalRangeAnnotations: ranges);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
List<DataPoint> _buildSeries(List<VitalSignResModel> vitals, VitalSignDetailsArgs args) {
|
|
|
|
|
|
|
|
final List<DataPoint> points = [];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Build a chronological series (oldest -> newest), skipping null/zero values.
|
|
|
|
|
|
|
|
final sorted = List<VitalSignResModel>.from(vitals);
|
|
|
|
|
|
|
|
sorted.sort((a, b) {
|
|
|
|
|
|
|
|
final ad = a.vitalSignDate ?? DateTime.fromMillisecondsSinceEpoch(0);
|
|
|
|
|
|
|
|
final bd = b.vitalSignDate ?? DateTime.fromMillisecondsSinceEpoch(0);
|
|
|
|
|
|
|
|
return ad.compareTo(bd);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
double? metricValue(VitalSignResModel v) {
|
|
|
|
|
|
|
|
switch (args.metric) {
|
|
|
|
|
|
|
|
case VitalSignMetric.bmi:
|
|
|
|
|
|
|
|
return _toDouble(v.bodyMassIndex);
|
|
|
|
|
|
|
|
case VitalSignMetric.height:
|
|
|
|
|
|
|
|
return _toDouble(v.heightCm);
|
|
|
|
|
|
|
|
case VitalSignMetric.weight:
|
|
|
|
|
|
|
|
return _toDouble(v.weightKg);
|
|
|
|
|
|
|
|
case VitalSignMetric.temperature:
|
|
|
|
|
|
|
|
return _toDouble(v.temperatureCelcius);
|
|
|
|
|
|
|
|
case VitalSignMetric.heartRate:
|
|
|
|
|
|
|
|
return _toDouble(v.heartRate ?? v.pulseBeatPerMinute);
|
|
|
|
|
|
|
|
case VitalSignMetric.respiratoryRate:
|
|
|
|
|
|
|
|
return _toDouble(v.respirationBeatPerMinute);
|
|
|
|
|
|
|
|
case VitalSignMetric.bloodPressure:
|
|
|
|
|
|
|
|
// Graph only systolic for now (simple single-series).
|
|
|
|
|
|
|
|
return _toDouble(v.bloodPressureHigher);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
double index = 0;
|
|
|
|
|
|
|
|
for (final v in sorted) {
|
|
|
|
|
|
|
|
final mv = metricValue(v);
|
|
|
|
|
|
|
|
if (mv == null) continue;
|
|
|
|
|
|
|
|
if (mv == 0) continue;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
final dt = v.vitalSignDate ?? DateTime.now();
|
|
|
|
|
|
|
|
final label = '${dt.day}/${dt.month}';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
points.add(
|
|
|
|
|
|
|
|
DataPoint(
|
|
|
|
|
|
|
|
value: index,
|
|
|
|
|
|
|
|
label: label,
|
|
|
|
|
|
|
|
actualValue: mv.toStringAsFixed(0),
|
|
|
|
|
|
|
|
time: dt,
|
|
|
|
|
|
|
|
displayTime: '${dt.day}/${dt.month}/${dt.year}',
|
|
|
|
|
|
|
|
unitOfMeasurement: args.unit,
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
index += 1;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return points;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
double? _toDouble(dynamic v) {
|
|
|
|
|
|
|
|
if (v == null) return null;
|
|
|
|
|
|
|
|
if (v is num) return v.toDouble();
|
|
|
|
|
|
|
|
return double.tryParse(v.toString());
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
String _latestValueText(VitalSignResModel? latest) {
|
|
|
|
|
|
|
|
if (latest == null) return '--';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
switch (args.metric) {
|
|
|
|
|
|
|
|
case VitalSignMetric.bmi:
|
|
|
|
|
|
|
|
final v = _toDouble(latest.bodyMassIndex);
|
|
|
|
|
|
|
|
return v == null ? '--' : v.toStringAsFixed(0);
|
|
|
|
|
|
|
|
case VitalSignMetric.height:
|
|
|
|
|
|
|
|
final v = _toDouble(latest.heightCm);
|
|
|
|
|
|
|
|
return v == null ? '--' : '${v.toStringAsFixed(0)} ${args.unit}';
|
|
|
|
|
|
|
|
case VitalSignMetric.weight:
|
|
|
|
|
|
|
|
final v = _toDouble(latest.weightKg);
|
|
|
|
|
|
|
|
return v == null ? '--' : '${v.toStringAsFixed(0)} ${args.unit}';
|
|
|
|
|
|
|
|
case VitalSignMetric.temperature:
|
|
|
|
|
|
|
|
final v = _toDouble(latest.temperatureCelcius);
|
|
|
|
|
|
|
|
return v == null ? '--' : '${v.toStringAsFixed(0)} ${args.unit}';
|
|
|
|
|
|
|
|
case VitalSignMetric.heartRate:
|
|
|
|
|
|
|
|
final v = _toDouble(latest.heartRate ?? latest.pulseBeatPerMinute);
|
|
|
|
|
|
|
|
return v == null ? '--' : '${v.toStringAsFixed(0)} ${args.unit}';
|
|
|
|
|
|
|
|
case VitalSignMetric.respiratoryRate:
|
|
|
|
|
|
|
|
final v = _toDouble(latest.respirationBeatPerMinute);
|
|
|
|
|
|
|
|
return v == null ? '--' : '${v.toStringAsFixed(0)} ${args.unit}';
|
|
|
|
|
|
|
|
case VitalSignMetric.bloodPressure:
|
|
|
|
|
|
|
|
final s = _toDouble(latest.bloodPressureHigher);
|
|
|
|
|
|
|
|
final d = _toDouble(latest.bloodPressureLower);
|
|
|
|
|
|
|
|
if (s == null || d == null) return '--';
|
|
|
|
|
|
|
|
return '${s.toStringAsFixed(0)}/${d.toStringAsFixed(0)}';
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
String? _statusForLatest(VitalSignResModel? latest) {
|
|
|
|
|
|
|
|
if (latest == null) return null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
switch (args.metric) {
|
|
|
|
|
|
|
|
case VitalSignMetric.bmi:
|
|
|
|
|
|
|
|
return VitalSignUiModel.bmiStatus(latest.bodyMassIndex);
|
|
|
|
|
|
|
|
case VitalSignMetric.bloodPressure:
|
|
|
|
|
|
|
|
return VitalSignUiModel.bloodPressureStatus(systolic: latest.bloodPressureHigher, diastolic: latest.bloodPressureLower);
|
|
|
|
|
|
|
|
case VitalSignMetric.height:
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
case VitalSignMetric.weight:
|
|
|
|
|
|
|
|
return latest.weightKg != null ? 'Normal' : null;
|
|
|
|
|
|
|
|
case VitalSignMetric.temperature:
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
case VitalSignMetric.heartRate:
|
|
|
|
|
|
|
|
return (latest.heartRate ?? latest.pulseBeatPerMinute) != null ? 'Normal' : null;
|
|
|
|
|
|
|
|
case VitalSignMetric.respiratoryRate:
|
|
|
|
|
|
|
|
return latest.respirationBeatPerMinute != null ? 'Normal' : null;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
String _descriptionText(BuildContext context) {
|
|
|
|
|
|
|
|
switch (args.metric) {
|
|
|
|
|
|
|
|
case VitalSignMetric.bmi:
|
|
|
|
|
|
|
|
return 'BMI is a measurement based on height and weight that estimates body fat.'.needTranslation;
|
|
|
|
|
|
|
|
case VitalSignMetric.height:
|
|
|
|
|
|
|
|
return 'Height is measured in centimeters and is used to calculate BMI and dosage recommendations.'.needTranslation;
|
|
|
|
|
|
|
|
case VitalSignMetric.weight:
|
|
|
|
|
|
|
|
return 'Weight helps track overall health, nutrition, and changes over time.'.needTranslation;
|
|
|
|
|
|
|
|
case VitalSignMetric.bloodPressure:
|
|
|
|
|
|
|
|
return 'Blood pressure reflects the force of blood against artery walls. It is shown as systolic/diastolic.'.needTranslation;
|
|
|
|
|
|
|
|
case VitalSignMetric.temperature:
|
|
|
|
|
|
|
|
return 'Body temperature reflects how hot your body is and may change with infection or inflammation.'.needTranslation;
|
|
|
|
|
|
|
|
case VitalSignMetric.heartRate:
|
|
|
|
|
|
|
|
return 'Heart rate refers to the number of heart beats per minute.'.needTranslation;
|
|
|
|
|
|
|
|
case VitalSignMetric.respiratoryRate:
|
|
|
|
|
|
|
|
return 'Respiratory rate is the number of breaths taken per minute.'.needTranslation;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
String _nextStepsText(BuildContext context) {
|
|
|
|
|
|
|
|
switch (args.metric) {
|
|
|
|
|
|
|
|
case VitalSignMetric.bmi:
|
|
|
|
|
|
|
|
return 'Maintain a balanced diet and regular activity. If your BMI is high or low, consider consulting your doctor.'.needTranslation;
|
|
|
|
|
|
|
|
case VitalSignMetric.height:
|
|
|
|
|
|
|
|
return 'No action is needed unless your measurement looks incorrect. Update it during your next visit.'.needTranslation;
|
|
|
|
|
|
|
|
case VitalSignMetric.weight:
|
|
|
|
|
|
|
|
return 'Monitor weight changes. Sudden gain or loss may require medical advice.'.needTranslation;
|
|
|
|
|
|
|
|
case VitalSignMetric.bloodPressure:
|
|
|
|
|
|
|
|
return 'Keep tracking your blood pressure. High or low readings should be discussed with your doctor.'.needTranslation;
|
|
|
|
|
|
|
|
case VitalSignMetric.temperature:
|
|
|
|
|
|
|
|
return 'If you have a persistent fever or symptoms, contact your healthcare provider.'.needTranslation;
|
|
|
|
|
|
|
|
case VitalSignMetric.heartRate:
|
|
|
|
|
|
|
|
return 'Track your heart rate trends. If you feel dizziness or chest pain, seek medical care.'.needTranslation;
|
|
|
|
|
|
|
|
case VitalSignMetric.respiratoryRate:
|
|
|
|
|
|
|
|
return 'If you notice shortness of breath or abnormal breathing, seek medical advice.'.needTranslation;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Widget _nextStepsCard(BuildContext context) {
|
|
|
|
|
|
|
|
return Container(
|
|
|
|
|
|
|
|
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(
|
|
|
|
|
|
|
|
color: AppColors.whiteColor,
|
|
|
|
|
|
|
|
borderRadius: 24.h,
|
|
|
|
|
|
|
|
hasShadow: true,
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
padding: EdgeInsets.all(16.h),
|
|
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
|
|
children: [
|
|
|
|
|
|
|
|
'What should I do next?'.needTranslation.toText16(weight: FontWeight.w600),
|
|
|
|
|
|
|
|
SizedBox(height: 8.h),
|
|
|
|
|
|
|
|
_nextStepsText(context).toText12(color: AppColors.greyTextColor, fontWeight: FontWeight.w500, maxLine: 10),
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Widget _axisLabel(String value) {
|
|
|
|
|
|
|
|
return Text(
|
|
|
|
|
|
|
|
value,
|
|
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
|
|
|
fontFamily: 'Poppins',
|
|
|
|
|
|
|
|
fontSize: 8.f,
|
|
|
|
|
|
|
|
color: AppColors.textColor,
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Widget _bottomLabel(String label) {
|
|
|
|
|
|
|
|
return Padding(
|
|
|
|
|
|
|
|
padding: const EdgeInsets.only(top: 8.0),
|
|
|
|
|
|
|
|
child: Text(
|
|
|
|
|
|
|
|
label,
|
|
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
|
|
fontSize: 8.f,
|
|
|
|
|
|
|
|
fontFamily: 'Poppins',
|
|
|
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
|
|
|
color: AppColors.labelTextColor,
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|