Complete USerInfo Selectoiun

pull/113/head
faizatflutter 3 months ago
parent c7c0a0ac09
commit cf26e71645

@ -39,10 +39,11 @@ class SymptomsCheckerViewModel extends ChangeNotifier {
// User Info Flow State
int _userInfoCurrentPage = 0;
String? _selectedGender;
DateTime? _dateOfBirth;
int? _selectedAge;
double? _selectedHeight;
double _selectedHeight = 170;
bool _isHeightCm = true;
double? _selectedWeight;
double _selectedWeight = 60;
bool _isWeightKg = true;
// Getters
@ -51,11 +52,19 @@ class SymptomsCheckerViewModel extends ChangeNotifier {
// User Info Getters
int get userInfoCurrentPage => _userInfoCurrentPage;
String? get selectedGender => _selectedGender;
DateTime? get dateOfBirth => _dateOfBirth;
int? get selectedAge => _selectedAge;
double? get selectedHeight => _selectedHeight;
bool get isHeightCm => _isHeightCm;
double? get selectedWeight => _selectedWeight;
bool get isWeightKg => _isWeightKg;
BodyView get currentView => _currentView;
@ -84,23 +93,22 @@ class SymptomsCheckerViewModel extends ChangeNotifier {
/// Get count of selected organs
int get selectedOrgansCount => _selectedOrganIds.length;
List<OrganSymptomResult> get organSymptomsResults {
List<OrganSymptomResult> get organSymptomsResults {
if (bodySymptomResponse?.dataDetails?.result == null) {
return [];
}
return bodySymptomResponse!.dataDetails!.result ?? [];
}
int get totalSelectedSymptomsCount {
int get totalSelectedSymptomsCount {
return _selectedSymptomsByOrgan.values.fold(0, (sum, symptomIds) => sum + symptomIds.length);
}
bool get hasSelectedSymptoms {
bool get hasSelectedSymptoms {
return _selectedSymptomsByOrgan.values.any((symptomIds) => symptomIds.isNotEmpty);
}
void toggleView() {
void toggleView() {
_currentView = _currentView == BodyView.front ? BodyView.back : BodyView.front;
notifyListeners();
}
@ -110,7 +118,7 @@ class SymptomsCheckerViewModel extends ChangeNotifier {
notifyListeners();
}
void toggleOrganSelection(String organId) {
void toggleOrganSelection(String organId) {
if (_selectedOrganIds.contains(organId)) {
_selectedOrganIds.remove(organId);
} else {
@ -123,10 +131,10 @@ class SymptomsCheckerViewModel extends ChangeNotifier {
notifyListeners();
}
void _showTooltip(String organId) {
_tooltipTimer?.cancel();
void _showTooltip(String organId) {
_tooltipTimer?.cancel();
_tooltipOrganId = organId;
_tooltipOrganId = organId;
notifyListeners();
// Hide tooltip after 2 seconds
@ -143,39 +151,39 @@ class SymptomsCheckerViewModel extends ChangeNotifier {
notifyListeners();
}
void removeOrgan(String organId) {
void removeOrgan(String organId) {
_selectedOrganIds.remove(organId);
notifyListeners();
}
void clearAllSelections() {
void clearAllSelections() {
_selectedOrganIds.clear();
notifyListeners();
}
void toggleBottomSheet() {
void toggleBottomSheet() {
_isBottomSheetExpanded = !_isBottomSheetExpanded;
notifyListeners();
}
void setBottomSheetExpanded(bool isExpanded) {
void setBottomSheetExpanded(bool isExpanded) {
_isBottomSheetExpanded = isExpanded;
notifyListeners();
}
bool validateSelection() {
bool validateSelection() {
return _selectedOrganIds.isNotEmpty;
}
List<String> getSelectedOrganIds() {
List<String> getSelectedOrganIds() {
return _selectedOrganIds.toList();
}
List<String> getSelectedOrganNames() {
List<String> getSelectedOrganNames() {
return selectedOrgans.map((organ) => organ.description).toList();
}
Future<void> initializeSymptomGroups({
Future<void> initializeSymptomGroups({
Function()? onSuccess,
Function(String)? onError,
}) async {
@ -186,12 +194,12 @@ class SymptomsCheckerViewModel extends ChangeNotifier {
return;
}
List<String> organNames = selectedOrgans.map((organ) => organ.name).toList();
List<String> organNames = selectedOrgans.map((organ) => organ.name).toList();
await getBodySymptomsByName(
await getBodySymptomsByName(
organNames: organNames,
onSuccess: (response) {
if (onSuccess != null) {
if (onSuccess != null) {
onSuccess();
}
},
@ -203,7 +211,7 @@ class SymptomsCheckerViewModel extends ChangeNotifier {
);
}
void toggleSymptomSelection(String organId, String symptomId) {
void toggleSymptomSelection(String organId, String symptomId) {
if (!_selectedSymptomsByOrgan.containsKey(organId)) {
_selectedSymptomsByOrgan[organId] = {};
}
@ -216,11 +224,11 @@ class SymptomsCheckerViewModel extends ChangeNotifier {
notifyListeners();
}
bool isSymptomSelected(String organId, String symptomId) {
bool isSymptomSelected(String organId, String symptomId) {
return _selectedSymptomsByOrgan[organId]?.contains(symptomId) ?? false;
}
List<BodySymptom> getAllSelectedSymptoms() {
List<BodySymptom> getAllSelectedSymptoms() {
List<BodySymptom> allSymptoms = [];
if (bodySymptomResponse?.dataDetails?.result == null) {
@ -228,7 +236,7 @@ class SymptomsCheckerViewModel extends ChangeNotifier {
}
for (var organResult in bodySymptomResponse!.dataDetails!.result!) {
String? matchingOrganId;
String? matchingOrganId;
for (var organ in selectedOrgans) {
if (organ.name == organResult.name) {
matchingOrganId = organ.id;
@ -252,12 +260,12 @@ class SymptomsCheckerViewModel extends ChangeNotifier {
return allSymptoms;
}
void clearAllSymptomSelections() {
void clearAllSymptomSelections() {
_selectedSymptomsByOrgan.clear();
notifyListeners();
}
void reset() {
void reset() {
_currentView = BodyView.front;
_selectedOrganIds.clear();
_selectedSymptomsByOrgan.clear();
@ -268,10 +276,11 @@ class SymptomsCheckerViewModel extends ChangeNotifier {
// Reset user info flow
_userInfoCurrentPage = 0;
_selectedGender = null;
_dateOfBirth = null;
_selectedAge = null;
_selectedHeight = null;
_selectedHeight = 170;
_isHeightCm = true;
_selectedWeight = null;
_selectedWeight = 60;
_isWeightKg = true;
notifyListeners();
}
@ -312,6 +321,19 @@ class SymptomsCheckerViewModel extends ChangeNotifier {
notifyListeners();
}
/// Set date of birth
void setDateOfBirth(DateTime dateOfBirth) {
_dateOfBirth = dateOfBirth;
// Calculate age from date of birth
final now = DateTime.now();
int age = now.year - dateOfBirth.year;
if (now.month < dateOfBirth.month || (now.month == dateOfBirth.month && now.day < dateOfBirth.day)) {
age--;
}
_selectedAge = age;
notifyListeners();
}
/// Set selected height
void setHeight(double height, bool isCm) {
_selectedHeight = height;
@ -329,10 +351,13 @@ class SymptomsCheckerViewModel extends ChangeNotifier {
/// Check if user info page is last
bool get isUserInfoLastPage => _userInfoCurrentPage == 3;
bool get isUserInfoFirstPage => _userInfoCurrentPage == 0;
/// Validate and submit user info
Map<String, dynamic> getUserInfoData() {
return {
'gender': _selectedGender,
'dateOfBirth': _dateOfBirth?.toIso8601String(),
'age': _selectedAge,
'height': _selectedHeight,
'heightUnit': _isHeightCm ? 'cm' : 'ft',
@ -341,19 +366,7 @@ class SymptomsCheckerViewModel extends ChangeNotifier {
};
}
/// Reset user info flow
void resetUserInfo() {
_userInfoCurrentPage = 0;
_selectedGender = null;
_selectedAge = null;
_selectedHeight = null;
_isHeightCm = true;
_selectedWeight = null;
_isWeightKg = true;
notifyListeners();
}
Future<void> getBodySymptomsByName({
Future<void> getBodySymptomsByName({
required List<String> organNames,
Function(BodySymptomResponseModel)? onSuccess,
Function(String)? onError,
@ -366,7 +379,7 @@ class SymptomsCheckerViewModel extends ChangeNotifier {
);
result.fold(
(failure) async {
(failure) async {
isBodySymptomsLoading = false;
notifyListeners();
await errorHandlerService.handleError(failure: failure);
@ -374,7 +387,7 @@ class SymptomsCheckerViewModel extends ChangeNotifier {
onError(failure.toString());
}
},
(apiResponse) {
(apiResponse) {
isBodySymptomsLoading = false;
if (apiResponse.messageStatus == 1 && apiResponse.data != null) {
bodySymptomResponse = apiResponse.data;

@ -7,13 +7,63 @@ 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/symptoms_checker_view_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/buttons/custom_button.dart';
import 'package:provider/provider.dart';
class UserInfoSelectionScreen extends StatelessWidget {
class UserInfoSelectionScreen extends StatefulWidget {
const UserInfoSelectionScreen({super.key});
@override
State<UserInfoSelectionScreen> createState() => _UserInfoSelectionScreenState();
}
class _UserInfoSelectionScreenState extends State<UserInfoSelectionScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_initializeUserInfo();
});
}
/// Initialize user info from appState if user is logged in
void _initializeUserInfo() {
final appState = getIt.get<AppState>();
final viewModel = context.read<SymptomsCheckerViewModel>();
if (appState.isAuthenticated) {
final user = appState.getAuthenticatedUser();
if (user == null) return;
// Populate gender (gender is int: 1=Male, 2=Female)
if (user.gender != null) {
String genderStr = user.gender == 1
? "Male"
: user.gender == 2
? "Female"
: "Other";
viewModel.setGender(genderStr);
}
if (user.dateofBirth != null && user.dateofBirth!.isNotEmpty) {
try {
DateTime dob = DateTime.parse(user.dateofBirth!);
viewModel.setDateOfBirth(dob);
} catch (e) {
// If date parsing fails, ignore and let user fill manually
}
}
// Note: AuthenticatedUser doesn't have height/weight fields
// User will need to fill these manually
}
// If not authenticated or fields are empty, user will fill them manually
}
_buildEditInfoTile({
required String leadingIcon,
required String title,
@ -69,79 +119,111 @@ class UserInfoSelectionScreen extends StatelessWidget {
} else {
name = "Guest";
}
return Scaffold(
backgroundColor: AppColors.bgScaffoldColor,
body: Column(
children: [
Expanded(
child: CollapsingListView(
title: "Symptoms Checker".needTranslation,
isLeading: true,
child: SingleChildScrollView(
child: Column(
children: [
Container(
width: double.infinity,
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.r),
padding: EdgeInsets.symmetric(vertical: 24.h, horizontal: 16.w),
child: Column(
children: [
"Hi $name, Is your information up to date?".needTranslation.toText18(
weight: FontWeight.w600,
color: AppColors.textColor,
body: Consumer<SymptomsCheckerViewModel>(
builder: (context, viewModel, child) {
// Check if any field is empty
bool hasEmptyFields = viewModel.selectedGender == null ||
viewModel.selectedAge == null ||
viewModel.selectedHeight == null ||
viewModel.selectedWeight == null;
// Get display values
String genderText = viewModel.selectedGender ?? "Not set";
// Show age calculated from DOB, not the DOB itself
String ageText = viewModel.selectedAge != null ? "${viewModel.selectedAge} Years" : "Not set";
String heightText =
viewModel.selectedHeight != null ? "${viewModel.selectedHeight!.round()} ${viewModel.isHeightCm ? 'cm' : 'ft'}" : "Not set";
String weightText =
viewModel.selectedWeight != null ? "${viewModel.selectedWeight!.round()} ${viewModel.isWeightKg ? 'kg' : 'lbs'}" : "Not set";
return Column(
children: [
Expanded(
child: CollapsingListView(
title: "Symptoms Checker".needTranslation,
isLeading: true,
child: SingleChildScrollView(
child: Column(
children: [
Container(
width: double.infinity,
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.r),
padding: EdgeInsets.symmetric(vertical: 24.h, horizontal: 16.w),
child: Column(
children: [
"Hello $name, Is your information up to date?".needTranslation.toText18(
weight: FontWeight.w600,
color: AppColors.textColor,
),
SizedBox(height: 24.h),
_buildEditInfoTile(
context: context,
leadingIcon: AppAssets.genderIcon,
title: "Gender".needTranslation,
subTitle: genderText,
onTap: () {
viewModel.setUserInfoPage(0);
context.navigateWithName(AppRoutes.userInfoFlowManager);
},
trailingIcon: AppAssets.edit_icon,
),
SizedBox(height: 24.h),
_buildEditInfoTile(
context: context,
leadingIcon: AppAssets.genderIcon,
title: "Gender".needTranslation,
subTitle: "Male".needTranslation,
onTap: () {},
trailingIcon: AppAssets.edit_icon,
),
_getDivider(),
_buildEditInfoTile(
context: context,
leadingIcon: AppAssets.calendar,
title: "Age".needTranslation,
subTitle: "32 Years",
iconColor: AppColors.greyTextColor,
onTap: () {},
trailingIcon: AppAssets.edit_icon,
),
_getDivider(),
_buildEditInfoTile(
context: context,
leadingIcon: AppAssets.rulerIcon,
title: "Height".needTranslation,
subTitle: "17 8cm",
onTap: () {},
trailingIcon: AppAssets.edit_icon,
),
_getDivider(),
_buildEditInfoTile(
context: context,
leadingIcon: AppAssets.weightScale,
title: "Weight".needTranslation,
subTitle: "88 kg",
onTap: () {},
trailingIcon: AppAssets.edit_icon,
_getDivider(),
_buildEditInfoTile(
context: context,
leadingIcon: AppAssets.calendarGrey,
title: "Age".needTranslation,
subTitle: ageText,
iconColor: AppColors.greyTextColor,
onTap: () {
viewModel.setUserInfoPage(1);
context.navigateWithName(AppRoutes.userInfoFlowManager);
},
trailingIcon: AppAssets.edit_icon,
),
_getDivider(),
_buildEditInfoTile(
context: context,
leadingIcon: AppAssets.rulerIcon,
title: "Height".needTranslation,
subTitle: heightText,
onTap: () {
viewModel.setUserInfoPage(2);
context.navigateWithName(AppRoutes.userInfoFlowManager);
},
trailingIcon: AppAssets.edit_icon,
),
_getDivider(),
_buildEditInfoTile(
context: context,
leadingIcon: AppAssets.weightScale,
title: "Weight".needTranslation,
subTitle: weightText,
onTap: () {
viewModel.setUserInfoPage(3);
context.navigateWithName(AppRoutes.userInfoFlowManager);
},
trailingIcon: AppAssets.edit_icon,
),
],
),
],
),
),
],
).paddingAll(24.w),
),
],
).paddingAll(24.w),
),
),
),
),
),
_buildBottomCard(context),
],
_buildBottomCard(context, hasEmptyFields),
],
);
},
),
);
}
Widget _buildBottomCard(BuildContext context) {
Widget _buildBottomCard(BuildContext context, bool hasEmptyFields) {
return Container(
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.r),
child: SafeArea(
@ -157,7 +239,10 @@ class UserInfoSelectionScreen extends StatelessWidget {
text: "No, Edit all".needTranslation,
icon: AppAssets.edit_icon,
iconColor: AppColors.primaryRedColor,
onPressed: () => context.navigateWithName(AppRoutes.userInfoFlowManager),
onPressed: () {
context.read<SymptomsCheckerViewModel>().setUserInfoPage(0);
context.navigateWithName(AppRoutes.userInfoFlowManager);
},
backgroundColor: AppColors.primaryRedColor.withValues(alpha: 0.11),
borderColor: Colors.transparent,
textColor: AppColors.primaryRedColor,
@ -170,10 +255,12 @@ class UserInfoSelectionScreen extends StatelessWidget {
text: "Yes, It is".needTranslation,
icon: AppAssets.tickIcon,
iconColor: AppColors.whiteColor,
onPressed: () => () {},
backgroundColor: AppColors.primaryRedColor,
borderColor: AppColors.primaryRedColor,
textColor: AppColors.whiteColor,
onPressed: hasEmptyFields
? () {} // Empty function for disabled state
: () => context.navigateWithName(AppRoutes.organSelectorPage),
backgroundColor: hasEmptyFields ? AppColors.greyLightColor : AppColors.primaryRedColor,
borderColor: hasEmptyFields ? AppColors.greyLightColor : AppColors.primaryRedColor,
textColor: hasEmptyFields ? AppColors.greyTextColor : AppColors.whiteColor,
fontSize: 16.f,
),
),

@ -1,7 +1,11 @@
import 'package:flutter/material.dart';
import 'dart:developer';
import 'package:flutter/cupertino.dart';
import 'package:hmg_patient_app_new/core/app_export.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/symptoms_checker_view_model.dart';
import 'package:hmg_patient_app_new/presentation/symptoms_checker/user_info_selection/widgets/custom_date_picker.dart';
import 'package:hmg_patient_app_new/theme/colors.dart';
import 'package:provider/provider.dart';
@ -19,14 +23,21 @@ class AgeSelectionPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: EdgeInsets.all(24.w),
child: Consumer<SymptomsCheckerViewModel>(
builder: (BuildContext context, symptomsViewModel, Widget? child) {
return Column(
children: [
"What is your gender?".needTranslation.toText18(weight: FontWeight.w600, color: AppColors.textColor),
SizedBox(height: 70.h),
"< Age Widget Here >".needTranslation.toText18(weight: FontWeight.w600, color: AppColors.greyTextColor),
"What is your Date of Birth?".needTranslation.toText18(weight: FontWeight.w600, color: AppColors.textColor).paddingAll(24.w),
SizedBox(height: 30.h),
ThreeColumnDatePicker(
enableHaptic: true,
enableSound: true,
initialDate: symptomsViewModel.dateOfBirth ?? DateTime(2000, 1, 1),
onDateChanged: (date) {
symptomsViewModel.setDateOfBirth(date);
log('DOB saved: $date, Age: ${symptomsViewModel.selectedAge}');
},
)
],
);
},

@ -1,36 +1,19 @@
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:hmg_patient_app_new/core/app_export.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/symptoms_checker_view_model.dart';
import 'package:hmg_patient_app_new/presentation/symptoms_checker/user_info_selection/widgets/height_scale.dart';
import 'package:hmg_patient_app_new/theme/colors.dart';
import 'package:provider/provider.dart';
/// Height selection page content
class HeightSelectionPage extends StatefulWidget {
final double height;
final bool isCm;
final Function(double, bool) onHeightChanged;
const HeightSelectionPage({
super.key,
required this.height,
required this.isCm,
required this.onHeightChanged,
});
class HeightSelectionPage extends StatelessWidget {
const HeightSelectionPage({super.key});
@override
State<HeightSelectionPage> createState() => _HeightSelectionPageState();
}
class _HeightSelectionPageState extends State<HeightSelectionPage> {
late double heightValue;
@override
void initState() {
super.initState();
heightValue = widget.height;
}
Widget _unitSelector() {
Widget _unitSelector(SymptomsCheckerViewModel viewModel) {
return Container(
height: 54.h,
padding: EdgeInsets.all(4.h),
@ -43,13 +26,12 @@ class _HeightSelectionPageState extends State<HeightSelectionPage> {
final tabWidth = (constraints.maxWidth - 8.w) / 2;
return Stack(
children: [
// Animated sliding indicator
AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
width: tabWidth,
height: constraints.maxHeight,
margin: EdgeInsets.only(left: widget.isCm ? 0 : tabWidth + 8.w),
margin: EdgeInsets.only(left: viewModel.isHeightCm ? 0 : tabWidth + 8.w),
decoration: BoxDecoration(
color: AppColors.bottomNAVBorder,
borderRadius: BorderRadius.circular(7.r),
@ -61,10 +43,9 @@ class _HeightSelectionPageState extends State<HeightSelectionPage> {
Expanded(
child: GestureDetector(
onTap: () {
if (!widget.isCm) {
// Convert from FT to CM (1 ft = 30.48 cm)
final convertedHeight = heightValue * 30.48;
widget.onHeightChanged(convertedHeight, true);
if (!viewModel.isHeightCm) {
final convertedHeight = viewModel.selectedHeight! * 30.48;
viewModel.setHeight(convertedHeight, true);
}
},
child: Container(
@ -75,7 +56,7 @@ class _HeightSelectionPageState extends State<HeightSelectionPage> {
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 14.f,
color: widget.isCm ? AppColors.textColor : AppColors.textColor.withValues(alpha: 0.6),
color: viewModel.isHeightCm ? AppColors.textColor : AppColors.textColor.withValues(alpha: 0.6),
),
),
),
@ -84,10 +65,9 @@ class _HeightSelectionPageState extends State<HeightSelectionPage> {
Expanded(
child: GestureDetector(
onTap: () {
if (widget.isCm) {
// Convert from CM to FT (1 cm = 0.0328084 ft)
final convertedHeight = heightValue / 30.48;
widget.onHeightChanged(convertedHeight, false);
if (viewModel.isHeightCm) {
final convertedHeight = viewModel.selectedHeight! / 30.48;
viewModel.setHeight(convertedHeight, false);
}
},
child: Container(
@ -98,7 +78,7 @@ class _HeightSelectionPageState extends State<HeightSelectionPage> {
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 14.f,
color: !widget.isCm ? AppColors.textColor : AppColors.textColor.withValues(alpha: 0.6),
color: !viewModel.isHeightCm ? AppColors.textColor : AppColors.textColor.withValues(alpha: 0.6),
),
),
),
@ -115,61 +95,80 @@ class _HeightSelectionPageState extends State<HeightSelectionPage> {
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 24.h),
Center(
child: Text(
'How tall are you?'.needTranslation,
style: TextStyle(fontSize: 18.f, fontWeight: FontWeight.w600, color: AppColors.textColor),
),
),
SizedBox(height: 24.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 24.w),
child: _unitSelector(),
),
SizedBox(height: 90.h),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.center,
return Consumer<SymptomsCheckerViewModel>(
builder: (context, viewModel, child) {
// Define min/max values based on unit
final minValue = viewModel.isHeightCm ? 50.0 : 1.6; // 50cm or 1.6ft (approx. 1'7")
final maxValue = viewModel.isHeightCm ? 280.0 : 9.2; // 280cm or 9.2ft (approx. 9'2")
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
heightValue.round().toString(),
style: TextStyle(fontSize: 100.f, color: AppColors.textColor, height: 1),
SizedBox(height: 24.h),
Center(
child: Text(
'How tall are you?'.needTranslation,
style: TextStyle(fontSize: 18.f, fontWeight: FontWeight.w600, color: AppColors.textColor),
),
),
SizedBox(height: 24.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 24.w),
child: _unitSelector(viewModel),
),
SizedBox(height: 20.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Spacer(flex: 1),
Expanded(
flex: 3,
child: Consumer<SymptomsCheckerViewModel>(
builder: (context, viewModel, child) {
return Text.rich(
TextSpan(
children: [
TextSpan(
text:
viewModel.isHeightCm ? viewModel.selectedHeight?.round().toString() : viewModel.selectedHeight?.toStringAsFixed(1),
style: TextStyle(
fontSize: 90.f,
color: AppColors.textColor,
height: 1,
),
),
TextSpan(
text: viewModel.isHeightCm ? 'cm' : 'ft',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 24.f,
),
),
],
),
).paddingOnly(bottom: 100.h, left: 20.w);
},
),
),
Expanded(
child: HeightScale(
enableHaptic: true,
enableSound: true,
minValue: minValue,
maxValue: maxValue,
initialHeight: viewModel.selectedHeight ?? 100,
isCm: viewModel.isHeightCm,
onHeightChanged: (newHeight) {
log("height: $newHeight");
viewModel.setHeight(newHeight, viewModel.isHeightCm);
},
),
),
],
),
SizedBox(width: 8.w),
Text(widget.isCm ? 'cm' : 'ft', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 24.f)).paddingOnly(bottom: 10.h, left: 8.w),
],
),
],
);
},
);
}
}
class _RulerPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paintTick = Paint()..color = const Color(0xFF222222);
final paintSmall = Paint()..color = const Color(0xFF222222).withValues(alpha: 0.6);
final width = size.width;
final start = 120;
final end = 210;
final steps = end - start;
for (int i = 0; i <= steps; i++) {
final x = (i / steps) * width;
if (i % 10 == 0) {
canvas.drawLine(Offset(x, size.height * 0.1), Offset(x, size.height * 0.6), paintTick);
} else if (i % 5 == 0) {
canvas.drawLine(Offset(x, size.height * 0.2), Offset(x, size.height * 0.5), paintSmall);
} else {
canvas.drawLine(Offset(x, size.height * 0.35), Offset(x, size.height * 0.5), paintSmall);
}
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

@ -1,36 +1,22 @@
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:hmg_patient_app_new/core/app_export.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/symptoms_checker_view_model.dart';
import 'package:hmg_patient_app_new/presentation/symptoms_checker/user_info_selection/widgets/weight_scale.dart';
import 'package:hmg_patient_app_new/theme/colors.dart';
import 'package:provider/provider.dart';
/// Weight selection page content
class WeightSelectionPage extends StatefulWidget {
final double weight;
final bool isKg;
class WeightSelectionPage extends StatelessWidget {
final Function(double, bool) onWeightChanged;
const WeightSelectionPage({
super.key,
required this.weight,
required this.isKg,
required this.onWeightChanged,
});
@override
State<WeightSelectionPage> createState() => _WeightSelectionPageState();
}
class _WeightSelectionPageState extends State<WeightSelectionPage> {
late double weightValue;
const WeightSelectionPage({super.key, required this.onWeightChanged});
@override
void initState() {
super.initState();
weightValue = widget.weight;
}
Widget _unitSelector() {
Widget _unitSelector(SymptomsCheckerViewModel viewModel) {
bool isKg = viewModel.isWeightKg;
return Container(
height: 54.h,
padding: EdgeInsets.all(4.h),
@ -49,7 +35,7 @@ class _WeightSelectionPageState extends State<WeightSelectionPage> {
curve: Curves.easeInOut,
width: tabWidth,
height: constraints.maxHeight,
margin: EdgeInsets.only(left: widget.isKg ? 0 : tabWidth + 8.w),
margin: EdgeInsets.only(left: isKg ? 0 : tabWidth + 8.w),
decoration: BoxDecoration(
color: AppColors.bottomNAVBorder,
borderRadius: BorderRadius.circular(7.r),
@ -61,10 +47,10 @@ class _WeightSelectionPageState extends State<WeightSelectionPage> {
Expanded(
child: GestureDetector(
onTap: () {
if (!widget.isKg) {
if (!isKg) {
// Convert from LBS to KG (1 lb = 0.453592 kg)
final convertedWeight = weightValue / 2.20462;
widget.onWeightChanged(convertedWeight, true);
final convertedWeight = viewModel.selectedWeight! / 2.20462;
Future.microtask(() => onWeightChanged(convertedWeight, true));
}
},
child: Container(
@ -75,7 +61,7 @@ class _WeightSelectionPageState extends State<WeightSelectionPage> {
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 14.f,
color: widget.isKg ? AppColors.textColor : AppColors.textColor.withValues(alpha: 0.6),
color: isKg ? AppColors.textColor : AppColors.textColor.withValues(alpha: 0.6),
),
),
),
@ -84,10 +70,10 @@ class _WeightSelectionPageState extends State<WeightSelectionPage> {
Expanded(
child: GestureDetector(
onTap: () {
if (widget.isKg) {
if (isKg) {
// Convert from KG to LBS (1 kg = 2.20462 lbs)
final convertedWeight = weightValue * 2.20462;
widget.onWeightChanged(convertedWeight, false);
final convertedWeight = viewModel.selectedWeight! * 2.20462;
Future.microtask(() => onWeightChanged(convertedWeight, false));
}
},
child: Container(
@ -98,7 +84,7 @@ class _WeightSelectionPageState extends State<WeightSelectionPage> {
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 14.f,
color: !widget.isKg ? AppColors.textColor : AppColors.textColor.withValues(alpha: 0.6),
color: !isKg ? AppColors.textColor : AppColors.textColor.withValues(alpha: 0.6),
),
),
),
@ -113,124 +99,64 @@ class _WeightSelectionPageState extends State<WeightSelectionPage> {
);
}
Widget _weightSlider() {
return Column(
children: [
SizedBox(height: 18.h),
SizedBox(height: 12.h),
SizedBox(
height: 80.h,
child: Row(
children: [
Expanded(child: Container()),
Expanded(
flex: 6,
child: Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: CustomPaint(
painter: _WeightRulerPainter(),
),
),
SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 6.h,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 0),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 0),
activeTrackColor: AppColors.primaryRedColor,
inactiveTrackColor: AppColors.lightRedButtonColor,
),
child: Slider(
min: 30,
max: 150,
value: weightValue,
onChanged: (v) {
setState(() => weightValue = v);
widget.onWeightChanged(v, widget.isKg);
},
),
),
Positioned(
right: 0,
child: Container(
width: 18.w,
height: 18.h,
decoration: BoxDecoration(
color: AppColors.primaryRedColor,
shape: BoxShape.rectangle,
borderRadius: BorderRadius.circular(4.r),
),
),
),
],
),
),
Expanded(child: Container()),
],
),
),
],
);
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 24.h),
Center(
child: Text(
'What is your weight?'.needTranslation,
style: TextStyle(fontSize: 18.f, fontWeight: FontWeight.w600, color: AppColors.textColor),
return Consumer<SymptomsCheckerViewModel>(builder: (context, viewModel, child) {
bool isKg = viewModel.isWeightKg;
// Define min/max values based on unit
final minValue = isKg ? 10.0 : 22.0; // 10kg or 22lbs
final maxValue = isKg ? 200.0 : 440.0; // 200kg or 440lbs
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 24.h),
Center(
child: Text(
'What is your weight?'.needTranslation,
style: TextStyle(fontSize: 18.f, fontWeight: FontWeight.w600, color: AppColors.textColor),
),
),
SizedBox(height: 24.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 24.w),
child: _unitSelector(viewModel),
),
),
SizedBox(height: 20.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 24.w),
child: _unitSelector(),
),
SizedBox(height: 90.h),
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
weightValue.round().toString(),
style: TextStyle(fontSize: 100.f, color: AppColors.textColor, height: 1),
SizedBox(height: 60.h),
// Weight display centered
Center(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
viewModel.selectedWeight!.round().toString(),
style: TextStyle(fontSize: 100.f, color: AppColors.textColor, height: 1),
),
SizedBox(width: 8.w),
Text(
isKg ? 'kg' : 'lbs',
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 24.f),
).paddingOnly(bottom: 10.h),
],
),
SizedBox(width: 8.w),
Text(widget.isKg ? 'kg' : 'lbs', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 24.f)).paddingOnly(bottom: 10.h, left: 8.w),
],
),
],
);
}
}
class _WeightRulerPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paintTick = Paint()..color = const Color(0xFF222222);
final paintSmall = Paint()..color = const Color(0xFF222222).withValues(alpha: 0.6);
final width = size.width;
final start = 30;
final end = 150;
final steps = end - start;
for (int i = 0; i <= steps; i++) {
final x = (i / steps) * width;
if (i % 10 == 0) {
canvas.drawLine(Offset(x, size.height * 0.1), Offset(x, size.height * 0.6), paintTick);
} else if (i % 5 == 0) {
canvas.drawLine(Offset(x, size.height * 0.2), Offset(x, size.height * 0.5), paintSmall);
} else {
canvas.drawLine(Offset(x, size.height * 0.35), Offset(x, size.height * 0.5), paintSmall);
}
}
),
SizedBox(height: 40.h),
// Horizontal weight picker
WeightScale(
enableHaptic: true,
enableSound: true,
minValue: minValue,
maxValue: maxValue,
initialWeight: viewModel.selectedWeight!,
isKg: isKg,
onWeightChanged: (newWeight) {
log("weight: $newWeight");
Future.microtask(() => onWeightChanged(newWeight, isKg));
},
),
],
);
});
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

@ -39,7 +39,6 @@ class _UserInfoFlowManagerState extends State<UserInfoFlowManager> {
void initState() {
super.initState();
_viewModel = context.read<SymptomsCheckerViewModel>();
// _viewModel.resetUserInfo();
}
@override
@ -57,7 +56,6 @@ class _UserInfoFlowManagerState extends State<UserInfoFlowManager> {
curve: Curves.easeInOut,
);
} else {
// Submit and navigate to next screen
_submitUserInfo();
}
}
@ -91,119 +89,165 @@ class _UserInfoFlowManagerState extends State<UserInfoFlowManager> {
context.pop();
}
Widget _buildProgressBar(int currentPage) {
return Row(
children: List.generate(4, (index) {
final isActive = index <= currentPage;
return Expanded(
child: Container(
height: 4.h,
margin: EdgeInsets.symmetric(horizontal: 6.w),
decoration: BoxDecoration(
color: isActive ? AppColors.primaryRedColor : AppColors.greyLightColor,
borderRadius: BorderRadius.circular(8.r),
),
),
Widget _buildProgressBar() {
return Consumer<SymptomsCheckerViewModel>(
builder: (BuildContext context, viewModel, child) {
return Row(
children: List.generate(4, (index) {
final isCompleted = index < viewModel.userInfoCurrentPage;
final isCurrentStep = index == viewModel.userInfoCurrentPage;
return Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 6.w),
child: ClipRRect(
borderRadius: BorderRadius.circular(8.r),
child: Stack(
children: [
// Background (grey)
Container(
height: 4.h,
decoration: BoxDecoration(
color: AppColors.greyLightColor,
borderRadius: BorderRadius.circular(8.r),
),
),
// Animated red fill from left to right
TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
tween: Tween<double>(
begin: 0.0,
end: isCompleted
? 1.0
: isCurrentStep
? 1.0
: 0.0,
),
builder: (context, value, child) {
return FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: value,
child: Container(
height: 4.h,
decoration: BoxDecoration(
color: AppColors.primaryRedColor,
borderRadius: BorderRadius.circular(8.r),
boxShadow: isCurrentStep && value > 0
? [
BoxShadow(
color: AppColors.primaryRedColor.withValues(alpha: 0.4),
blurRadius: 8.r,
spreadRadius: 1.r,
),
]
: null,
),
),
);
},
),
],
),
),
),
);
}),
);
}),
},
);
}
Widget _buildStickyBottomCard(bool isLastPage) {
return Container(
decoration: BoxDecoration(
color: AppColors.whiteColor,
borderRadius: BorderRadius.vertical(top: Radius.circular(24.r)),
),
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 16.h),
child: SafeArea(
top: false,
child: Row(
children: [
Expanded(
child: CustomButton(
text: "Previous".needTranslation,
onPressed: _onPrevious,
backgroundColor: AppColors.primaryRedColor.withValues(alpha: 0.11),
borderColor: Colors.transparent,
textColor: AppColors.primaryRedColor,
fontSize: 16.f,
),
),
SizedBox(width: 12.w),
Expanded(
child: CustomButton(
text: isLastPage ? "Submit".needTranslation : "Next".needTranslation,
onPressed: _onNext,
backgroundColor: AppColors.primaryRedColor,
borderColor: AppColors.primaryRedColor,
textColor: AppColors.whiteColor,
fontSize: 16.f,
Widget _buildStickyBottomCard() {
return Consumer<SymptomsCheckerViewModel>(builder: (BuildContext context, viewModel, child) {
bool isLastPage = viewModel.isUserInfoLastPage;
bool isFirstPage = viewModel.isUserInfoFirstPage;
return Container(
decoration: BoxDecoration(
color: AppColors.whiteColor,
borderRadius: BorderRadius.vertical(top: Radius.circular(24.r)),
),
padding: EdgeInsets.only(left: 24.w, right: 24.w, top: 16.h),
child: SafeArea(
top: false,
child: Row(
children: [
if (!isFirstPage) ...[
Expanded(
child: CustomButton(
text: "Previous".needTranslation,
onPressed: _onPrevious,
backgroundColor: AppColors.primaryRedColor.withValues(alpha: 0.11),
borderColor: Colors.transparent,
textColor: AppColors.primaryRedColor,
fontSize: 16.f,
),
),
SizedBox(width: 12.w),
],
Expanded(
child: CustomButton(
text: isLastPage ? "Submit".needTranslation : "Next".needTranslation,
onPressed: _onNext,
backgroundColor: AppColors.primaryRedColor,
borderColor: AppColors.primaryRedColor,
textColor: AppColors.whiteColor,
fontSize: 16.f,
),
),
),
],
],
),
),
),
);
);
});
}
@override
Widget build(BuildContext context) {
return Consumer<SymptomsCheckerViewModel>(
builder: (context, viewModel, child) {
return Scaffold(
backgroundColor: AppColors.bgScaffoldColor,
body: Column(
children: [
Expanded(
child: CollapsingListView(
title: _pageTitles[viewModel.userInfoCurrentPage].needTranslation,
isLeading: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 24.h),
_buildProgressBar(viewModel.userInfoCurrentPage),
SizedBox(height: 24.h),
SizedBox(
height: 600.h,
child: PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(), // Disable swipe
onPageChanged: (index) {
viewModel.setUserInfoPage(index);
},
children: [
GenderSelectionPage(
selectedGender: viewModel.selectedGender,
onGenderSelected: viewModel.setGender,
),
AgeSelectionPage(
selectedAge: viewModel.selectedAge,
onAgeSelected: viewModel.setAge,
),
HeightSelectionPage(
height: viewModel.selectedHeight ?? 178,
isCm: viewModel.isHeightCm,
onHeightChanged: viewModel.setHeight,
),
WeightSelectionPage(
weight: viewModel.selectedWeight ?? 70,
isKg: viewModel.isWeightKg,
onWeightChanged: viewModel.setWeight,
),
],
return Scaffold(
backgroundColor: AppColors.bgScaffoldColor,
body: Column(
children: [
Expanded(
child: CollapsingListView(
physics: NeverScrollableScrollPhysics(),
title: _pageTitles[_viewModel.userInfoCurrentPage].needTranslation,
isLeading: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 24.h),
_buildProgressBar(),
SizedBox(height: 24.h),
SizedBox(
height: 600.h,
child: PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(), // Disable swipe
onPageChanged: (index) {
_viewModel.setUserInfoPage(index);
},
children: [
GenderSelectionPage(
selectedGender: _viewModel.selectedGender,
onGenderSelected: _viewModel.setGender,
),
),
],
AgeSelectionPage(
selectedAge: _viewModel.selectedAge,
onAgeSelected: _viewModel.setAge,
),
HeightSelectionPage(),
WeightSelectionPage(onWeightChanged: _viewModel.setWeight),
],
),
),
),
],
),
_buildStickyBottomCard(viewModel.isUserInfoLastPage),
],
),
),
);
},
_buildStickyBottomCard(),
],
),
);
}
}

@ -0,0 +1,235 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hmg_patient_app_new/core/app_export.dart';
import 'package:hmg_patient_app_new/presentation/symptoms_checker/user_info_selection/widgets/triangle_indicator.dart';
import 'package:hmg_patient_app_new/theme/colors.dart';
class ThreeColumnDatePicker extends StatefulWidget {
final DateTime initialDate;
final ValueChanged<DateTime>? onDateChanged;
// Feedback config
final bool enableHaptic;
final bool enableSound;
final Duration feedbackDebounce;
const ThreeColumnDatePicker({
super.key,
required this.initialDate,
this.onDateChanged,
this.enableHaptic = true,
this.enableSound = true,
this.feedbackDebounce = const Duration(milliseconds: 80),
});
@override
State<ThreeColumnDatePicker> createState() => _ThreeColumnDatePickerState();
}
class _ThreeColumnDatePickerState extends State<ThreeColumnDatePicker> {
static const int yearRange = 100;
static const double _defaultItemExtent = 48.0; // will be scaled with .h
late final List<String> _days;
late final List<String> _months;
late final List<int> _years;
late FixedExtentScrollController _dayController;
late FixedExtentScrollController _monthController;
late FixedExtentScrollController _yearController;
int _selectedDay = 0;
int _selectedMonth = 0;
int _selectedYearIndex = 0;
// Debounce timer used for playing feedback only after small pause
Timer? _feedbackTimer;
double get _itemExtent => _defaultItemExtent.h;
@override
void initState() {
super.initState();
_days = List.generate(31, (i) => (i + 1).toString().padLeft(2, '0'));
_months = const ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
final currentYear = DateTime.now().year;
_years = List.generate(yearRange, (i) => currentYear - i);
_selectedDay = (widget.initialDate.day - 1).clamp(0, _days.length - 1);
_selectedMonth = (widget.initialDate.month - 1).clamp(0, _months.length - 1);
_selectedYearIndex = _years.indexOf(widget.initialDate.year);
if (_selectedYearIndex == -1) _selectedYearIndex = 0;
_dayController = FixedExtentScrollController(initialItem: _selectedDay);
_monthController = FixedExtentScrollController(initialItem: _selectedMonth);
_yearController = FixedExtentScrollController(initialItem: _selectedYearIndex);
}
@override
void dispose() {
_feedbackTimer?.cancel();
_dayController.dispose();
_monthController.dispose();
_yearController.dispose();
super.dispose();
}
void _emitDate() {
final day = int.parse(_days[_selectedDay]);
final month = _selectedMonth + 1;
final year = _years[_selectedYearIndex];
final date = DateTime(year, month, day);
widget.onDateChanged?.call(date);
}
// Schedule haptic + sound feedback with debounce (prevents spamming during fling)
void _scheduleFeedback() {
if (!(widget.enableHaptic || widget.enableSound)) return;
_feedbackTimer?.cancel();
_feedbackTimer = Timer(widget.feedbackDebounce, () {
// Haptic
if (widget.enableHaptic) {
// selection click is lightweight and appropriate for wheel ticks
HapticFeedback.selectionClick();
}
// Sound
if (widget.enableSound) {
// simple system click - note: may be muted by device settings
SystemSound.play(SystemSoundType.click);
}
});
}
Widget _wheel({
required FixedExtentScrollController controller,
required int itemCount,
required Widget Function(int index, bool selected) itemBuilder,
required ValueChanged<int> onSelectedItemChanged,
required int currentlySelectedIndex,
}) {
return Expanded(
child: SizedBox(
height: _itemExtent * 5, // show ~5 rows
child: ListWheelScrollView.useDelegate(
controller: controller,
itemExtent: _itemExtent,
physics: const BouncingScrollPhysics(),
diameterRatio: 2.2,
squeeze: 1.2,
perspective: 0.004,
// overAndUnderCenterOpacity: 0.6,
onSelectedItemChanged: (i) {
// update selected index, emit date and schedule feedback
onSelectedItemChanged(i);
_scheduleFeedback();
},
childDelegate: ListWheelChildBuilderDelegate(
builder: (context, index) {
if (index < 0 || index >= itemCount) return null;
final bool selected = index == currentlySelectedIndex;
return Center(child: itemBuilder(index, selected));
},
childCount: itemCount,
),
),
),
);
}
Widget _styledText(String text, bool selected) {
return Text(
text,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: selected ? 22.f : 20.f,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected ? AppColors.textColor : AppColors.greyTextColor.withValues(alpha: 0.9),
height: 1.0,
letterSpacing: selected ? -0.02 * 30 : -0.02 * 18,
),
);
}
@override
Widget build(BuildContext context) {
final pickerHeight = _itemExtent * 5;
final pointerSize = 20.w;
final pointerTop = (pickerHeight / 2) - (pointerSize / 2);
return LayoutBuilder(builder: (context, constraints) {
return SizedBox(
height: pickerHeight,
child: Stack(
children: [
Row(
children: [
// Day wheel
_wheel(
controller: _dayController,
itemCount: _days.length,
currentlySelectedIndex: _selectedDay,
onSelectedItemChanged: (i) {
setState(() => _selectedDay = i);
_emitDate();
},
itemBuilder: (index, selected) => _styledText(_days[index], selected),
),
// Month wheel
_wheel(
controller: _monthController,
itemCount: _months.length,
currentlySelectedIndex: _selectedMonth,
onSelectedItemChanged: (i) {
setState(() => _selectedMonth = i);
_emitDate();
},
itemBuilder: (index, selected) => _styledText(_months[index], selected),
),
// Year wheel
_wheel(
controller: _yearController,
itemCount: _years.length,
currentlySelectedIndex: _selectedYearIndex,
onSelectedItemChanged: (i) {
setState(() => _selectedYearIndex = i);
_emitDate();
},
itemBuilder: (index, selected) => _styledText(_years[index].toString(), selected),
),
],
),
// subtle center overlay (optional keeps layout consistent & highlights center row)
Positioned.fill(
child: IgnorePointer(
child: Center(
child: SizedBox(
height: _itemExtent,
),
),
),
),
// left red triangular pointer aligned to center row
Positioned(
left: 0.w,
top: pointerTop,
child: TriangleIndicator(
pointerSize: pointerSize,
// your TriangleIndicator supports direction param; use left as in the original
// if your TriangleIndicator doesn't accept direction, remove the param
direction: TriangleDirection.left,
),
),
],
),
);
});
}
}

@ -0,0 +1,169 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hmg_patient_app_new/core/app_export.dart';
import 'package:hmg_patient_app_new/presentation/symptoms_checker/user_info_selection/widgets/triangle_indicator.dart';
import 'package:hmg_patient_app_new/theme/colors.dart';
class HeightScale extends StatefulWidget {
final double minValue;
final double maxValue;
final double initialHeight;
final bool isCm;
final ValueChanged<double>? onHeightChanged;
// Feedback config
final bool enableHaptic;
final bool enableSound;
final Duration feedbackDebounce;
const HeightScale({
super.key,
required this.minValue,
required this.maxValue,
required this.initialHeight,
required this.isCm,
this.onHeightChanged,
this.enableHaptic = true,
this.enableSound = true,
this.feedbackDebounce = const Duration(milliseconds: 80),
});
@override
State<HeightScale> createState() => _HeightScaleState();
}
class _HeightScaleState extends State<HeightScale> {
late FixedExtentScrollController _scrollController;
// Debounce timer used for playing feedback only after small pause
Timer? _feedbackTimer;
// Get increment based on unit (CM = 1.0, FT = 0.1)
double get _increment => widget.isCm ? 1.0 : 0.1;
@override
void initState() {
super.initState();
int initialIndex = ((widget.initialHeight - widget.minValue) / _increment).round();
_scrollController = FixedExtentScrollController(initialItem: initialIndex);
}
// Schedule haptic + sound feedback with debounce (prevents spamming during fling)
void _scheduleFeedback() {
if (!(widget.enableHaptic || widget.enableSound)) return;
_feedbackTimer?.cancel();
_feedbackTimer = Timer(widget.feedbackDebounce, () {
// Haptic
if (widget.enableHaptic) {
// selection click is lightweight and appropriate for wheel ticks
HapticFeedback.selectionClick();
}
// Sound
if (widget.enableSound) {
// simple system click - note: may be muted by device settings
SystemSound.play(SystemSoundType.click);
}
});
}
@override
void dispose() {
_feedbackTimer?.cancel();
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pointerSize = 20.w;
final pickerHeight = 300.h;
final pointerTop = (pickerHeight / 2) - (pointerSize / 2);
return SizedBox(
height: pickerHeight,
child: Stack(
children: [
// Scrollable wheel picker
ListWheelScrollView.useDelegate(
controller: _scrollController,
itemExtent: 10.h,
diameterRatio: 2.0,
squeeze: 1.2,
perspective: 0.001,
physics: const BouncingScrollPhysics(),
onSelectedItemChanged: (index) {
final selectedValue = widget.minValue + (index * _increment);
widget.onHeightChanged?.call(selectedValue);
_scheduleFeedback();
},
childDelegate: ListWheelChildBuilderDelegate(
childCount: ((widget.maxValue - widget.minValue) / _increment).round() + 1,
builder: (context, index) {
final height = widget.minValue + (index * _increment);
// For CM: main mark every 10, mid mark every 5
// For FT: main mark every 1.0 (10 ticks), mid mark every 0.5 (5 ticks)
final isMainMark = widget.isCm ? height % 10 == 0 : (height * 10).round() % 10 == 0;
final isMidMark = widget.isCm ? height % 5 == 0 : (height * 10).round() % 5 == 0;
return SizedBox(
width: 100.w,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Number label for main marks
if (isMainMark)
SizedBox(
width: 30.w,
child: Text(
widget.isCm ? height.round().toString() : height.toStringAsFixed(1),
style: TextStyle(
fontSize: 11.f,
color: AppColors.greyTextColor,
fontWeight: FontWeight.w500,
height: 1,
),
textAlign: TextAlign.right,
),
)
else
SizedBox(width: 30.w),
SizedBox(width: 4.w),
// Ruler mark
Container(
width: isMainMark
? 40.w
: isMidMark
? 30.w
: 25.w,
height: isMainMark || isMidMark ? 2.5.h : 1.5.h,
decoration: BoxDecoration(
color: isMainMark
? AppColors.textColor
: isMidMark
? AppColors.textColorLight
: AppColors.textColorLight.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(2.r),
),
),
],
),
);
},
),
),
// Triangle indicator pointing to selected value
Positioned(
right: 0,
top: pointerTop,
child: TriangleIndicator(pointerSize: pointerSize),
),
],
),
);
}
}

@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:hmg_patient_app_new/theme/colors.dart';
enum TriangleDirection { left, right, up, down }
class TriangleIndicator extends StatelessWidget {
final double pointerSize;
final Color? color;
final TriangleDirection direction;
const TriangleIndicator({
super.key,
required this.pointerSize,
this.color,
this.direction = TriangleDirection.right,
});
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size(pointerSize, pointerSize),
painter: _TrianglePainter(
color: color ?? AppColors.primaryRedColor,
direction: direction,
),
);
}
}
class _TrianglePainter extends CustomPainter {
final Color color;
final TriangleDirection direction;
_TrianglePainter({required this.color, required this.direction});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.fill
..isAntiAlias = true;
final path = Path();
final w = size.width;
final h = size.height;
switch (direction) {
case TriangleDirection.right:
// apex on the right, base on the left
path.moveTo(0, h / 2);
path.lineTo(w, 0);
path.lineTo(w, h);
path.close();
break;
case TriangleDirection.left:
// apex on the left, base on the right
path.moveTo(w, h / 2);
path.lineTo(0, 0);
path.lineTo(0, h);
path.close();
break;
case TriangleDirection.up:
// apex on top, base on bottom
path.moveTo(w / 2, 0);
path.lineTo(0, h);
path.lineTo(w, h);
path.close();
break;
case TriangleDirection.down:
// apex on bottom, base on top
path.moveTo(w / 2, h);
path.lineTo(0, 0);
path.lineTo(w, 0);
path.close();
break;
}
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant _TrianglePainter oldDelegate) {
return oldDelegate.color != color || oldDelegate.direction != direction;
}
}

@ -1,36 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hmg_patient_app_new/core/app_export.dart';
import 'package:hmg_patient_app_new/theme/colors.dart';
/// Progress bar widget showing the current step in user info selection flow
/// Total steps: 4 (Gender -> Age -> Height -> Weight)
class UserInfoProgressBar extends StatelessWidget {
final int currentStep;
final int totalSteps;
const UserInfoProgressBar({
super.key,
required this.currentStep,
this.totalSteps = 4,
});
@override
Widget build(BuildContext context) {
return Row(
children: List.generate(totalSteps, (index) {
final isActive = index < currentStep;
return Expanded(
child: Container(
height: 4.h,
margin: EdgeInsets.symmetric(horizontal: 6.w),
decoration: BoxDecoration(
color: isActive ? AppColors.primaryRedColor : AppColors.greyLightColor,
borderRadius: BorderRadius.circular(8.r),
),
),
);
}),
);
}
}

@ -1,67 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hmg_patient_app_new/core/app_export.dart';
import 'package:hmg_patient_app_new/presentation/symptoms_checker/user_info_selection/widgets/user_info_progress_bar.dart';
import 'package:hmg_patient_app_new/presentation/symptoms_checker/user_info_selection/widgets/user_info_sticky_bottom_card.dart';
import 'package:hmg_patient_app_new/theme/colors.dart';
import 'package:hmg_patient_app_new/widgets/appbar/collapsing_list_view.dart';
/// Base scaffold for user info selection flow pages
/// Provides consistent layout with progress bar and sticky bottom card
class UserInfoSelectionScaffold extends StatelessWidget {
final String title;
final int currentStep;
final Widget child;
final VoidCallback? onPrevious;
final VoidCallback? onNext;
final bool showPrevious;
final String? nextButtonText;
final bool isScrollable;
const UserInfoSelectionScaffold({
super.key,
required this.title,
required this.currentStep,
required this.child,
this.onPrevious,
this.onNext,
this.showPrevious = true,
this.nextButtonText,
this.isScrollable = true,
});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.bgScaffoldColor,
body: Column(
children: [
Expanded(
child: CollapsingListView(
title: title,
isLeading: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 24.h),
UserInfoProgressBar(currentStep: currentStep),
SizedBox(height: 24.h),
isScrollable
? SingleChildScrollView(
child: child,
)
: child,
],
),
),
),
UserInfoStickyBottomCard(
onPrevious: onPrevious,
onNext: onNext,
showPrevious: showPrevious,
nextButtonText: nextButtonText,
),
],
),
);
}
}

@ -1,68 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hmg_patient_app_new/core/app_export.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/theme/colors.dart';
import 'package:hmg_patient_app_new/widgets/buttons/custom_button.dart';
/// Sticky bottom card with Previous/Next navigation buttons
class UserInfoStickyBottomCard extends StatelessWidget {
final VoidCallback? onPrevious;
final VoidCallback? onNext;
final bool showPrevious;
final String? nextButtonText;
const UserInfoStickyBottomCard({
super.key,
this.onPrevious,
this.onNext,
this.showPrevious = true,
this.nextButtonText,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(
color: AppColors.whiteColor,
borderRadius: 24.r,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 16.h),
Row(
children: [
if (showPrevious) ...[
Expanded(
child: CustomButton(
text: "Previous".needTranslation,
onPressed: onPrevious ?? () => context.pop(),
backgroundColor: AppColors.primaryRedColor.withValues(alpha: 0.11),
borderColor: Colors.transparent,
textColor: AppColors.primaryRedColor,
fontSize: 16.f,
),
),
SizedBox(width: 12.w),
],
Expanded(
child: CustomButton(
text: nextButtonText ?? "Next".needTranslation,
onPressed: onNext ?? () {},
backgroundColor: AppColors.primaryRedColor,
borderColor: AppColors.primaryRedColor,
textColor: AppColors.whiteColor,
fontSize: 16.f,
),
),
],
),
SizedBox(height: 24.h),
],
).paddingSymmetrical(24.w, 0),
);
}
}

@ -0,0 +1,180 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hmg_patient_app_new/core/app_export.dart';
import 'package:hmg_patient_app_new/presentation/symptoms_checker/user_info_selection/widgets/triangle_indicator.dart';
import 'package:hmg_patient_app_new/theme/colors.dart';
class WeightScale extends StatefulWidget {
final double minValue;
final double maxValue;
final double initialWeight;
final bool isKg;
final ValueChanged<double>? onWeightChanged;
// Feedback config
final bool enableHaptic;
final bool enableSound;
final Duration feedbackDebounce;
const WeightScale({
super.key,
required this.minValue,
required this.maxValue,
required this.initialWeight,
required this.isKg,
this.onWeightChanged,
this.enableHaptic = true,
this.enableSound = true,
this.feedbackDebounce = const Duration(milliseconds: 80),
});
@override
State<WeightScale> createState() => _WeightScaleState();
}
class _WeightScaleState extends State<WeightScale> {
late ScrollController _scrollController;
// Debounce timer used for playing feedback only after small pause
Timer? _feedbackTimer;
int? _lastReportedIndex;
final double _itemWidth = 8.0; // Width per weight unit
@override
void initState() {
super.initState();
int initialIndex = (widget.initialWeight - widget.minValue).round();
final initialOffset = initialIndex * _itemWidth;
_scrollController = ScrollController(initialScrollOffset: initialOffset);
_scrollController.addListener(_onScroll);
}
void _onScroll() {
if (!_scrollController.hasClients) return;
final offset = _scrollController.offset;
final index = (offset / _itemWidth).round();
final maxIndex = (widget.maxValue - widget.minValue).round();
if (index != _lastReportedIndex && index >= 0 && index <= maxIndex) {
_lastReportedIndex = index;
final selectedValue = widget.minValue + index;
widget.onWeightChanged?.call(selectedValue);
_scheduleFeedback();
}
}
// Schedule haptic + sound feedback with debounce (prevents spamming during fling)
void _scheduleFeedback() {
if (!(widget.enableHaptic || widget.enableSound)) return;
_feedbackTimer?.cancel();
_feedbackTimer = Timer(widget.feedbackDebounce, () {
// Haptic
if (widget.enableHaptic) {
// selection click is lightweight and appropriate for wheel ticks
HapticFeedback.selectionClick();
}
// Sound
if (widget.enableSound) {
// simple system click - note: may be muted by device settings
SystemSound.play(SystemSoundType.click);
}
});
}
@override
void dispose() {
_feedbackTimer?.cancel();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pointerSize = 20.h;
final pickerHeight = 100.h;
final itemCount = (widget.maxValue - widget.minValue).round() + 1;
return SizedBox(
height: pickerHeight,
child: Stack(
alignment: Alignment.center,
children: [
// Horizontal scrollable ruler with gradient fade
ListView.builder(
controller: _scrollController,
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: MediaQuery.of(context).size.width / 2),
itemCount: itemCount,
itemBuilder: (context, index) {
final weight = (widget.minValue + index).round();
final isMainMark = weight % 10 == 0;
final isMidMark = weight % 5 == 0;
return SizedBox(
width: _itemWidth,
child: Stack(
alignment: Alignment.bottomCenter,
clipBehavior: Clip.none,
children: [
// Ruler mark (vertical line)
Positioned(
bottom: 0,
child: Container(
width: isMainMark || isMidMark ? 2.5.w : 1.5.w,
height: isMainMark
? 40.h
: isMidMark
? 30.h
: 25.h,
decoration: BoxDecoration(
color: isMainMark
? AppColors.textColor
: isMidMark
? AppColors.textColorLight
: AppColors.textColorLight.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(2.r),
),
),
),
// Number label for main marks
if (isMainMark)
Positioned(
bottom: 45.h,
child: Text(
weight.toString(),
style: TextStyle(
fontSize: 11.f,
color: AppColors.greyTextColor,
fontWeight: FontWeight.w500,
height: 1,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.visible,
),
),
],
),
);
},
),
// Triangle indicator pointing to selected value
Positioned(
bottom: 0,
child: TriangleIndicator(
pointerSize: pointerSize,
direction: TriangleDirection.up,
),
),
],
),
);
}
}

@ -27,6 +27,7 @@ class CollapsingListView extends StatelessWidget {
bool isClose;
bool isLeading;
VoidCallback? leadingCallback;
ScrollPhysics? physics;
CollapsingListView({
super.key,
@ -43,6 +44,7 @@ class CollapsingListView extends StatelessWidget {
this.isLeading = true,
this.trailing,
this.leadingCallback,
this.physics,
});
@override
@ -53,6 +55,7 @@ class CollapsingListView extends StatelessWidget {
body: Column(
children: [
CustomScrollView(
physics: physics,
slivers: [
SliverAppBar(
automaticallyImplyLeading: false,
@ -119,11 +122,18 @@ class CollapsingListView extends StatelessWidget {
color: AppColors.blackColor,
letterSpacing: -0.5),
).expanded,
if (logout != null) actionButton(context, t, title: "Logout".needTranslation, icon: AppAssets.logout).onPress(logout!),
if (report != null) actionButton(context, t, title: "Feedback".needTranslation, icon: AppAssets.report_icon).onPress(report!),
if (history != null) actionButton(context, t, title: "History".needTranslation, icon: AppAssets.insurance_history_icon).onPress(history!),
if (instructions != null) actionButton(context, t, title: "Instructions".needTranslation, icon: AppAssets.requests).onPress(instructions!),
if (requests != null) actionButton(context, t, title: "Requests".needTranslation, icon: AppAssets.insurance_history_icon).onPress(requests!),
if (logout != null)
actionButton(context, t, title: "Logout".needTranslation, icon: AppAssets.logout).onPress(logout!),
if (report != null)
actionButton(context, t, title: "Feedback".needTranslation, icon: AppAssets.report_icon).onPress(report!),
if (history != null)
actionButton(context, t, title: "History".needTranslation, icon: AppAssets.insurance_history_icon)
.onPress(history!),
if (instructions != null)
actionButton(context, t, title: "Instructions".needTranslation, icon: AppAssets.requests).onPress(instructions!),
if (requests != null)
actionButton(context, t, title: "Requests".needTranslation, icon: AppAssets.insurance_history_icon)
.onPress(requests!),
if (search != null) Utils.buildSvgWithAssets(icon: AppAssets.search_icon).onPress(search!).paddingOnly(right: 24),
if (trailing != null) trailing!,
],

Loading…
Cancel
Save