Complete USerInfo Selectoiun
parent
c7c0a0ac09
commit
cf26e71645
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue