You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
HMG_Patient_App_New/lib/presentation/symptoms_checker/triage_screen.dart

544 lines
20 KiB
Dart

import 'dart:developer';
1 month ago
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hmg_patient_app_new/core/app_assets.dart';
1 month ago
import 'package:hmg_patient_app_new/core/app_export.dart';
import 'package:hmg_patient_app_new/core/dependencies.dart';
1 month ago
import 'package:hmg_patient_app_new/core/utils/utils.dart';
import 'package:hmg_patient_app_new/extensions/route_extensions.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/symptoms_checker/models/resp_models/triage_response_model.dart';
import 'package:hmg_patient_app_new/features/symptoms_checker/symptoms_checker_view_model.dart';
1 month ago
import 'package:hmg_patient_app_new/generated/locale_keys.g.dart';
import 'package:hmg_patient_app_new/presentation/symptoms_checker/widgets/custom_progress_bar.dart';
import 'package:hmg_patient_app_new/services/dialog_service.dart';
1 month ago
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/buttons/custom_button.dart';
import 'package:hmg_patient_app_new/widgets/common_bottom_sheet.dart';
import 'package:lottie/lottie.dart';
import 'package:provider/provider.dart';
1 month ago
class TriageScreen extends StatefulWidget {
const TriageScreen({super.key});
1 month ago
@override
State<TriageScreen> createState() => _TriageScreenState();
1 month ago
}
class _TriageScreenState extends State<TriageScreen> {
late SymptomsCheckerViewModel viewModel;
late DialogService dialogService;
1 month ago
@override
void initState() {
super.initState();
viewModel = context.read<SymptomsCheckerViewModel>();
dialogService = getIt.get<DialogService>();
1 month ago
// Start triage process when screen loads
WidgetsBinding.instance.addPostFrameCallback((_) {
_startTriage();
1 month ago
});
}
void _startTriage() {
viewModel.startOrContinueTriage(
onSuccess: () {
_handleTriageResponse();
},
onError: (error) {
dialogService.showErrorBottomSheet(
message: error,
onOkPressed: () => context.pop(),
);
},
);
1 month ago
}
void _handleTriageResponse() {
// Case 1: Emergency evidence detected
if (viewModel.hasEmergencyEvidence) {
_showEmergencyDialog();
return;
}
// Get the highest probability condition
final conditions = viewModel.currentConditions ?? [];
double highestProbability = 0.0;
if (conditions.isNotEmpty) {
final sortedConditions = List<TriageCondition>.from(conditions);
sortedConditions.sort((a, b) => (b.probability ?? 0.0).compareTo(a.probability ?? 0.0));
highestProbability = (sortedConditions.first.probability ?? 0.0) * 100;
}
// Case 2: Should stop flag is true OR Case 3: Probability >= 70% OR Case 4: 7 or more questions answered
if (viewModel.shouldStopTriage || highestProbability >= 70.0 || viewModel.triageQuestionCount >= 7) {
// Navigate to results/possible conditions screen
context.navigateWithName(AppRoutes.possibleConditionsScreen);
return;
1 month ago
}
// Continue triage - question is loaded, reset selection for new question
viewModel.resetTriageChoice();
1 month ago
}
void _showEmergencyDialog() {
showCommonBottomSheetWithoutHeight(
1 month ago
context,
child: Container(
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(
color: AppColors.primaryRedColor,
borderRadius: 24.h,
),
child: Padding(
padding: EdgeInsets.all(24.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
"".toText14(),
Utils.buildSvgWithAssets(
icon: AppAssets.cancel_circle_icon,
iconColor: AppColors.whiteColor,
width: 24.h,
height: 24.h,
fit: BoxFit.contain,
).onPress(() {
Navigator.of(context).pop();
}),
],
),
Lottie.asset(AppAnimations.ambulanceAlert,
repeat: false, reverse: false, frameRate: FrameRate(60), width: 120.h, height: 120.h, fit: BoxFit.contain),
SizedBox(height: 8.h),
"Emergency".needTranslation.toText28(color: AppColors.whiteColor, isBold: true),
SizedBox(height: 8.h),
"Emergency evidence detected. Please seek medical attention."
.needTranslation
.toText14(color: AppColors.whiteColor, weight: FontWeight.w500),
SizedBox(height: 24.h),
CustomButton(
text: LocaleKeys.confirm.tr(context: context),
onPressed: () async => Navigator.of(context).pop(),
backgroundColor: AppColors.whiteColor,
borderColor: AppColors.whiteColor,
textColor: AppColors.primaryRedColor,
icon: AppAssets.checkmark_icon,
iconColor: AppColors.primaryRedColor,
),
SizedBox(height: 8.h),
],
),
),
1 month ago
),
isFullScreen: false,
isCloseButtonVisible: false,
hasBottomPadding: false,
backgroundColor: AppColors.primaryRedColor,
callBackFunc: () {},
1 month ago
);
}
bool get isFirstQuestion => viewModel.getTriageEvidence().isEmpty;
void _onOptionSelectedForItem(String itemId, int choiceIndex) {
viewModel.selectTriageChoiceForItem(itemId, choiceIndex);
}
void _onPreviousPressed() {
context.pop();
}
void _onNextPressed() {
final currentQuestion = viewModel.currentTriageQuestion;
if (currentQuestion?.items == null || currentQuestion!.items!.isEmpty) {
dialogService.showErrorBottomSheet(
message: 'No question items available'.needTranslation,
);
return;
}
// Check if all items have been answered
if (!viewModel.areAllTriageItemsAnswered) {
dialogService.showErrorBottomSheet(message: 'Please answer all questions before proceeding'.needTranslation);
return;
}
// Collect all evidence from all items
for (var item in currentQuestion.items!) {
final itemId = item.id ?? "";
if (itemId.isEmpty) continue;
final selectedChoiceIndex = viewModel.getTriageChoiceForItem(itemId);
if (selectedChoiceIndex == null) continue;
if (item.choices != null && selectedChoiceIndex < item.choices!.length) {
final selectedChoice = item.choices![selectedChoiceIndex];
final choiceId = selectedChoice.id ?? "";
if (choiceId.isNotEmpty) {
viewModel.addTriageEvidence(itemId, choiceId);
}
}
}
// Get all evidence: initial symptoms + risk factors + suggestions + triage evidence
List<String> initialEvidenceIds = viewModel.getAllEvidenceIds();
List<Map<String, String>> triageEvidence = viewModel.getTriageEvidence();
log("initialEvidenceIds: ${initialEvidenceIds.toString()}");
log("triageEvidence: ${triageEvidence.toString()}");
// Call API with updated evidence
viewModel.getDiagnosisForTriage(
age: viewModel.selectedAge!,
sex: viewModel.selectedGender!.toLowerCase(),
evidenceIds: initialEvidenceIds,
triageEvidence: triageEvidence,
language: viewModel.appState.isArabic() ? 'ar' : 'en',
onSuccess: (response) {
_handleTriageResponse();
},
onError: (error) {
dialogService.showErrorBottomSheet(message: error);
},
);
}
1 month ago
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.bgScaffoldColor,
body: Consumer<SymptomsCheckerViewModel>(
builder: (context, viewModel, child) {
// Show normal question UI
return Column(
children: [
Expanded(
child: CollapsingListView(
title: "Triage".needTranslation,
leadingCallback: () => _showConfirmationBeforeExit(context),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 16.h),
_buildQuestionCard(viewModel),
],
),
),
1 month ago
),
_buildStickyBottomCard(context, viewModel),
],
);
},
),
);
}
Widget _buildLoadingShimmer() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 16.h),
// Create 2-3 shimmer cards
...List.generate(1, (index) {
return Padding(
padding: EdgeInsets.only(bottom: 16.h),
child: _buildShimmerCard(),
);
}),
],
);
}
Widget _buildShimmerCard() {
return Container(
width: double.infinity,
margin: EdgeInsets.symmetric(horizontal: 24.w),
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.r),
padding: EdgeInsets.symmetric(vertical: 24.h, horizontal: 16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Shimmer title
Container(
height: 40.h,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24.r),
1 month ago
),
).toShimmer2(isShow: true, radius: 24.r),
SizedBox(height: 16.h),
// Shimmer chips
Wrap(
runSpacing: 12.h,
spacing: 8.w,
children: List.generate(4, (index) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h),
decoration: BoxDecoration(
color: AppColors.whiteColor,
borderRadius: BorderRadius.circular(24.r),
border: Border.all(color: AppColors.bottomNAVBorder, width: 1),
),
child: Text(
'Not Applicable Suggestion',
style: TextStyle(fontSize: 14.f, color: AppColors.textColor),
),
).toShimmer2(isShow: true, radius: 24.r);
}),
1 month ago
),
],
),
);
}
void _showConfirmationBeforeExit(BuildContext context) {
showCommonBottomSheetWithoutHeight(
title: LocaleKeys.notice.tr(context: context),
context,
child: Utils.getWarningWidget(
loadingText: "Are you sure you want to exit? Your progress will be lost.".needTranslation,
isShowActionButtons: true,
onCancelTap: () => Navigator.pop(context),
onConfirmTap: () {
Navigator.pop(context);
context.pop();
},
),
callBackFunc: () {},
isFullScreen: false,
isCloseButtonVisible: true,
);
}
Widget _buildQuestionCard(SymptomsCheckerViewModel viewModel) {
if (viewModel.isTriageDiagnosisLoading) {
return _buildLoadingShimmer();
}
if (viewModel.currentTriageQuestion == null) {
return Center(
child: "No question available".needTranslation.toText16(weight: FontWeight.w500),
);
}
final question = viewModel.currentTriageQuestion;
if (question == null || question.items == null || question.items!.isEmpty) {
return SizedBox.shrink();
}
1 month ago
return AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
transitionBuilder: (Widget child, Animation<double> animation) {
final offsetAnimation = Tween<Offset>(
begin: const Offset(1.0, 0.0),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: animation,
curve: Curves.easeInOut,
),
);
1 month ago
return SlideTransition(
position: offsetAnimation,
child: FadeTransition(
opacity: animation,
child: child,
),
);
},
child: Container(
key: ValueKey<String>(question.items!.first.id ?? viewModel.getTriageEvidence().length.toString()),
1 month ago
width: double.infinity,
margin: EdgeInsets.symmetric(horizontal: 24.w),
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.r),
padding: EdgeInsets.symmetric(vertical: 24.h, horizontal: 20.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Main question text
(question.text ?? "").toText16(weight: FontWeight.w600, color: AppColors.textColor),
1 month ago
SizedBox(height: 24.h),
// Show all items with dividers
...List.generate(question.items!.length, (itemIndex) {
final item = question.items![itemIndex];
final itemId = item.id ?? "";
final choices = item.choices ?? [];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Item name (sub-question)
(item.name ?? "").toText14(weight: FontWeight.w600, color: AppColors.textColor),
SizedBox(height: 8.h),
// Choices for this item
...List.generate(choices.length, (choiceIndex) {
bool selected = viewModel.getTriageChoiceForItem(itemId) == choiceIndex;
return _buildOptionItem(itemId, choiceIndex, selected, choices[choiceIndex].label ?? "");
}),
// Add divider between items (but not after the last one)
if (itemIndex < question.items!.length - 1) ...[
SizedBox(height: 8.h),
Divider(color: AppColors.bottomNAVBorder, thickness: 1),
SizedBox(height: 10.h),
],
],
);
1 month ago
}),
],
),
),
);
}
Widget _buildOptionItem(String itemId, int choiceIndex, bool selected, String optionText) {
1 month ago
return GestureDetector(
onTap: () => _onOptionSelectedForItem(itemId, choiceIndex),
1 month ago
child: Container(
margin: EdgeInsets.only(bottom: 12.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: 24.w,
height: 24.w,
decoration: BoxDecoration(
color: selected ? AppColors.primaryRedColor : Colors.transparent,
borderRadius: BorderRadius.circular(5.r),
border: Border.all(color: selected ? AppColors.primaryRedColor : AppColors.checkBoxBorderColor, width: 1.w),
1 month ago
),
child: selected ? Icon(Icons.check, size: 16.f, color: AppColors.whiteColor) : null,
),
SizedBox(width: 12.w),
Expanded(child: optionText.toText13(weight: FontWeight.w500)),
1 month ago
],
),
),
);
}
Widget _buildStickyBottomCard(BuildContext context, SymptomsCheckerViewModel viewModel) {
// Get the top condition with highest probability
final conditions = viewModel.currentConditions ?? [];
String suggestedCondition = "Analyzing...";
double probability = 0.0;
if (conditions.isNotEmpty) {
// Sort by probability descending
final sortedConditions = List<TriageCondition>.from(conditions);
sortedConditions.sort((a, b) => (b.probability ?? 0.0).compareTo(a.probability ?? 0.0));
final topCondition = sortedConditions.first;
suggestedCondition = topCondition.commonName ?? topCondition.name ?? "Unknown";
probability = (topCondition.probability ?? 0.0) * 100; // Convert to percentage
}
// final bool isHighConfidence = probability >= 70.0;
1 month ago
return Container(
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.r),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 16.h),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
text: "Possible symptom: ".needTranslation,
style: TextStyle(
color: AppColors.greyTextColor,
fontWeight: FontWeight.w600,
fontSize: 14.f,
),
children: [
TextSpan(
text: suggestedCondition,
style: TextStyle(
color: AppColors.textColor,
fontWeight: FontWeight.w600,
fontSize: 14.f,
),
),
],
),
),
SizedBox(height: 16.h),
CustomRoundedProgressBar(
percentage: probability.toInt(),
1 month ago
paddingBetween: 5.h,
color: AppColors.primaryRedColor,
backgroundColor: AppColors.primaryRedColor.withValues(alpha: 0.17),
height: 8.h,
titleWidget: RichText(
text: TextSpan(
text: "${probability.toStringAsFixed(1)}% ",
1 month ago
style: TextStyle(
color: AppColors.primaryRedColor,
fontWeight: FontWeight.w600,
fontSize: 14.f,
),
children: [
TextSpan(
text: "- Symptoms checker finding score".needTranslation,
style: TextStyle(
color: AppColors.textColor,
fontWeight: FontWeight.w500,
fontSize: 13.f,
),
),
],
),
),
),
// Show high confidence message
1 month ago
SizedBox(height: 12.h),
Row(
children: [
Expanded(
child: CustomButton(
text: "Previous".needTranslation,
onPressed: isFirstQuestion ? () {} : _onPreviousPressed,
isDisabled: isFirstQuestion || viewModel.isTriageDiagnosisLoading,
1 month ago
backgroundColor: AppColors.primaryRedColor.withValues(alpha: 0.11),
borderColor: Colors.transparent,
textColor: AppColors.primaryRedColor,
fontSize: 16.f,
),
),
SizedBox(width: 12.w),
Expanded(
child: CustomButton(
text: "Next".needTranslation,
isDisabled: viewModel.isTriageDiagnosisLoading,
1 month ago
onPressed: _onNextPressed,
backgroundColor: AppColors.primaryRedColor,
borderColor: AppColors.primaryRedColor,
textColor: AppColors.whiteColor,
fontSize: 16.f,
),
),
],
),
],
),
SizedBox(height: 24.h),
],
).paddingSymmetrical(24.w, 0),
);
}
}