fixed the symptoms zoom issue and minor text suggestions (faiz_dev) #357

Merged
Haroon6138 merged 1 commits from faiz_dev_symptoms_checker into master 1 week ago

@ -29,11 +29,19 @@ class OrganSelectorPage extends StatefulWidget {
State<OrganSelectorPage> createState() => _OrganSelectorPageState(); State<OrganSelectorPage> createState() => _OrganSelectorPageState();
} }
class _OrganSelectorPageState extends State<OrganSelectorPage> { class _OrganSelectorPageState extends State<OrganSelectorPage> with SingleTickerProviderStateMixin {
static const double _expandedBodyZoomFactor = 1.08;
static const double _expandedSheetHeightFactor = 0.3;
static const bool _enableTutorialOverlay = false;
static const bool _enableSwipeToFlip = false;
late final AppState _appState; late final AppState _appState;
late final DialogService dialogService; late final DialogService dialogService;
late final CacheService cacheService; late final CacheService cacheService;
late final AnimationController _introZoomHintController;
late final Animation<double> _introZoomHintAnimation;
bool _showTutorial = false; bool _showTutorial = false;
bool _hasPlayedIntroZoomHint = false;
@override @override
void initState() { void initState() {
@ -41,10 +49,52 @@ class _OrganSelectorPageState extends State<OrganSelectorPage> {
_appState = getIt.get<AppState>(); _appState = getIt.get<AppState>();
dialogService = getIt<DialogService>(); dialogService = getIt<DialogService>();
cacheService = getIt<CacheService>(); cacheService = getIt<CacheService>();
_introZoomHintController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
)..addListener(() {
if (mounted) setState(() {});
});
_introZoomHintAnimation = TweenSequence<double>([
TweenSequenceItem(
tween: Tween<double>(begin: 1.0, end: 1.25),
weight: 45,
),
TweenSequenceItem(
tween: Tween<double>(begin: 1.25, end: 1.0),
weight: 55,
),
]).animate(CurvedAnimation(
parent: _introZoomHintController,
curve: Curves.easeInOut,
));
_checkAndShowTutorial(); _checkAndShowTutorial();
_playIntroZoomHint();
}
@override
void dispose() {
_introZoomHintController.dispose();
super.dispose();
}
Future<void> _playIntroZoomHint() async {
if (_hasPlayedIntroZoomHint) return;
_hasPlayedIntroZoomHint = true;
await Future.delayed(const Duration(milliseconds: 700));
if (!mounted) return;
_introZoomHintController.forward(from: 0);
} }
Future<void> _checkAndShowTutorial() async { Future<void> _checkAndShowTutorial() async {
if (!_enableTutorialOverlay) {
_showTutorial = false;
return;
}
// final hasSeenTutorial = cacheService.getBool(key: CacheConst.organSelectorTutorialShown) ?? false; // final hasSeenTutorial = cacheService.getBool(key: CacheConst.organSelectorTutorialShown) ?? false;
// if (!hasSeenTutorial) { // if (!hasSeenTutorial) {
// Show tutorial after a short delay to ensure the screen is fully built // Show tutorial after a short delay to ensure the screen is fully built
@ -118,7 +168,11 @@ class _OrganSelectorPageState extends State<OrganSelectorPage> {
Expanded( Expanded(
child: Stack( child: Stack(
children: [ children: [
_buildBodyViewer(viewModel), SafeArea(
top: false,
bottom: false,
child: _buildBodyViewer(viewModel),
),
_buildViewToggleButtons(viewModel), _buildViewToggleButtons(viewModel),
// _buildViewZoomButtons(viewModel), // _buildViewZoomButtons(viewModel),
_buildBottomSheet(viewModel), _buildBottomSheet(viewModel),
@ -131,7 +185,7 @@ class _OrganSelectorPageState extends State<OrganSelectorPage> {
), ),
), ),
// Tutorial overlay // Tutorial overlay
if (_showTutorial) if (_enableTutorialOverlay && _showTutorial)
PinchZoomTutorialOverlay( PinchZoomTutorialOverlay(
onComplete: _onTutorialComplete, onComplete: _onTutorialComplete,
), ),
@ -152,7 +206,13 @@ class _OrganSelectorPageState extends State<OrganSelectorPage> {
height: 24.h, height: 24.h,
), ),
padding: EdgeInsetsDirectional.only(start: 12, end: 12), padding: EdgeInsetsDirectional.only(start: 12, end: 12),
onPressed: () => Navigator.pop(context), onPressed: () {
// Clear selected organs and sheet status when going back
final viewModel = context.read<SymptomsCheckerViewModel>();
viewModel.clearAllSelections();
viewModel.setBottomSheetExpanded(false);
Navigator.pop(context);
},
highlightColor: Colors.transparent, highlightColor: Colors.transparent,
), ),
), ),
@ -175,45 +235,82 @@ class _OrganSelectorPageState extends State<OrganSelectorPage> {
Widget _buildBodyViewer(SymptomsCheckerViewModel viewModel) { Widget _buildBodyViewer(SymptomsCheckerViewModel viewModel) {
return GestureDetector( return GestureDetector(
onHorizontalDragEnd: (details) { onHorizontalDragEnd: !_enableSwipeToFlip
// Swipe left or right to toggle view ? null
if (details.primaryVelocity != null) { : (details) {
if (details.primaryVelocity! < -200 || details.primaryVelocity! > 200) { // Swipe left or right to toggle view
viewModel.toggleView(); if (details.primaryVelocity != null) {
} if (details.primaryVelocity! < -200 || details.primaryVelocity! > 200) {
} viewModel.toggleView();
}, }
child: Padding( }
padding: EdgeInsets.fromLTRB(16.h, 16.h, 16.h, 60.h),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 600),
transitionBuilder: (child, animation) => _build3DFlipTransition(child, animation),
switchInCurve: Curves.easeInOut,
switchOutCurve: Curves.easeInOut,
child: Builder(
key: ValueKey<BodyView>(viewModel.currentView),
builder: (context) {
// Detect female gender from viewModel; allow Arabic value as fallback
final bool isFemale =
(viewModel.selectedGender != null && (viewModel.selectedGender!.toLowerCase() == 'female' || viewModel.selectedGender == 'أنثى'));
final String bodyAsset = viewModel.currentView == BodyView.front
? (isFemale ? AppAssets.fullBodyFrontFemale : AppAssets.fullBodyFrontMale)
: (isFemale ? AppAssets.fullBodyBackFemale : AppAssets.fullBodyBackMale);
return InteractiveBodyWidget(
bodyImageAsset: bodyAsset,
organs: viewModel.currentOrgans,
selectedOrganIds: viewModel.selectedOrganIds,
onOrganTap: viewModel.toggleOrganSelection,
isBodyHidden: viewModel.isBodyHidden,
tooltipOrganId: viewModel.tooltipOrganId,
isArabic: _appState.isArabic(),
zoomScale: viewModel.currentZoomScale,
);
}, },
), child: LayoutBuilder(
), builder: (context, constraints) {
final bool isExpanded = viewModel.isBottomSheetExpanded;
final double screenHeight = MediaQuery.of(context).size.height;
final double extraBottomInset = isExpanded ? screenHeight * _expandedSheetHeightFactor : 0;
final bool isUserZoomedIn = viewModel.currentZoomScale > 1.01;
Widget buildInteractiveBody({required bool useExpandedZoom}) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 600),
transitionBuilder: (child, animation) => _build3DFlipTransition(child, animation),
switchInCurve: Curves.easeInOut,
switchOutCurve: Curves.easeInOut,
child: Builder(
key: ValueKey<BodyView>(viewModel.currentView),
builder: (context) {
final bool isFemale = (viewModel.selectedGender != null && (viewModel.selectedGender!.toLowerCase() == 'female' || viewModel.selectedGender == 'أنثى'));
final double effectiveZoomScale = viewModel.currentZoomScale * _introZoomHintAnimation.value;
final String bodyAsset = viewModel.currentView == BodyView.front
? (isFemale ? AppAssets.fullBodyFrontFemale : AppAssets.fullBodyFrontMale)
: (isFemale ? AppAssets.fullBodyBackFemale : AppAssets.fullBodyBackMale);
final body = InteractiveBodyWidget(
bodyImageAsset: bodyAsset,
organs: viewModel.currentOrgans,
selectedOrganIds: viewModel.selectedOrganIds,
onOrganTap: viewModel.toggleOrganSelection,
isBodyHidden: viewModel.isBodyHidden,
tooltipOrganId: viewModel.tooltipOrganId,
isArabic: _appState.isArabic(),
zoomScale: effectiveZoomScale,
);
return body;
},
),
);
}
if (!isExpanded) {
return Padding(
padding: EdgeInsets.fromLTRB(16.h, 16.h, 16.h, 60.h),
child: buildInteractiveBody(useExpandedZoom: false),
);
}
return SingleChildScrollView(
physics: isExpanded && !isUserZoomedIn ? const ClampingScrollPhysics() : const NeverScrollableScrollPhysics(),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight + extraBottomInset,
),
child: Padding(
padding: EdgeInsets.only(
bottom: extraBottomInset,
top: 24.h,
),
child: Padding(
padding: EdgeInsets.fromLTRB(16.h, 16.h, 16.h, 60.h),
child: buildInteractiveBody(useExpandedZoom: true),
),
),
),
);
},
), ),
); );
} }
@ -408,37 +505,73 @@ class _OrganSelectorPageState extends State<OrganSelectorPage> {
} }
Widget _buildExpandCollapseButton(SymptomsCheckerViewModel viewModel) { Widget _buildExpandCollapseButton(SymptomsCheckerViewModel viewModel) {
final organCount = viewModel.selectedOrgans.length;
final showBadge = !viewModel.isBottomSheetExpanded && organCount > 0;
return PositionedDirectional( return PositionedDirectional(
end: 24.w, end: 24.w,
top: -24.h, top: -24.h,
child: GestureDetector( child: GestureDetector(
onTap: viewModel.toggleBottomSheet, onTap: viewModel.toggleBottomSheet,
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: Container( child: SizedBox(
width: 70.w, width: 70.w,
height: 70.h, height: 70.h,
alignment: Alignment.center, child: Stack(
child: Container( clipBehavior: Clip.none,
width: 48.w, children: [
height: 48.h, Align(
decoration: RoundedRectangleBorder().toSmoothCornerDecoration( alignment: Alignment.center,
color: AppColors.whiteColor, child: Container(
borderRadius: 11.r, width: 48.w,
), height: 48.h,
child: Center( decoration: RoundedRectangleBorder().toSmoothCornerDecoration(
child: Transform.flip( color: AppColors.whiteColor,
flipX: _appState.isArabic(), borderRadius: 11.r,
child: AnimatedRotation( ),
duration: const Duration(milliseconds: 300), child: Center(
turns: viewModel.isBottomSheetExpanded ? 0.25 : -0.25, child: Transform.flip(
child: Utils.buildSvgWithAssets( flipX: _appState.isArabic(),
icon: AppAssets.arrowRight, child: AnimatedRotation(
width: 25.w, duration: const Duration(milliseconds: 300),
height: 25.h, turns: viewModel.isBottomSheetExpanded ? 0.25 : -0.25,
child: Utils.buildSvgWithAssets(
icon: AppAssets.arrowRight,
width: 25.w,
height: 25.h,
),
),
),
), ),
), ),
), ),
), if (showBadge)
PositionedDirectional(
top: 8.h,
end: 8.w,
child: Container(
width: 22.w,
height: 22.h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.primaryRedColor,
shape: BoxShape.circle,
border: Border.all(
color: AppColors.whiteColor,
width: 1.5,
),
),
child: Text(
'$organCount',
style: TextStyle(
color: AppColors.whiteColor,
fontSize: 10.f,
fontWeight: FontWeight.w700,
),
),
),
),
],
), ),
), ),
), ),

@ -1,3 +1,4 @@
import 'dart:developer';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -7,7 +8,6 @@ import 'package:hmg_patient_app_new/core/utils/utils.dart';
import 'package:hmg_patient_app_new/features/symptoms_checker/models/organ_model.dart'; import 'package:hmg_patient_app_new/features/symptoms_checker/models/organ_model.dart';
import 'package:hmg_patient_app_new/presentation/symptoms_checker/widgets/organ_dot_widget.dart'; import 'package:hmg_patient_app_new/presentation/symptoms_checker/widgets/organ_dot_widget.dart';
import 'package:hmg_patient_app_new/presentation/symptoms_checker/widgets/organ_tooltip_widget.dart'; import 'package:hmg_patient_app_new/presentation/symptoms_checker/widgets/organ_tooltip_widget.dart';
import 'package:vector_math/vector_math_64.dart' show Vector3;
class InteractiveBodyWidget extends StatefulWidget { class InteractiveBodyWidget extends StatefulWidget {
final String bodyImageAsset; final String bodyImageAsset;
@ -63,9 +63,12 @@ class _InteractiveBodyWidgetState extends State<InteractiveBodyWidget> {
@override @override
void dispose() { void dispose() {
_transformationController.dispose(); _transformationController.dispose();
currentZoom = 0.0;
super.dispose(); super.dispose();
} }
double currentZoom = 0.0;
Future<void> _loadImageAspectRatio() async { Future<void> _loadImageAspectRatio() async {
final ByteData data = await rootBundle.load(widget.bodyImageAsset); final ByteData data = await rootBundle.load(widget.bodyImageAsset);
final ui.Codec codec = await ui.instantiateImageCodec(data.buffer.asUint8List()); final ui.Codec codec = await ui.instantiateImageCodec(data.buffer.asUint8List());
@ -77,17 +80,16 @@ class _InteractiveBodyWidgetState extends State<InteractiveBodyWidget> {
_imageAspectRatio = image.width / image.height; _imageAspectRatio = image.width / image.height;
}); });
} }
_transformationController.addListener(() {
currentZoom = _transformationController.value.getMaxScaleOnAxis();
setState(() {});
});
} }
void _updateZoom(double scale) { void _updateZoom(double scale) {
// Get current translation // Keep programmatic zoom centered to avoid drifting the body off-screen.
final currentTransform = _transformationController.value; final newTransform = Matrix4.identity()..scaleByDouble(scale, scale, 1.0, 1.0);
final currentTranslation = currentTransform.getTranslation();
// Create new transformation with updated scale while preserving translation
final newTransform = Matrix4.identity()
..setTranslation(currentTranslation)
..scaleByVector3(Vector3(scale, scale, 1.0));
_transformationController.value = newTransform; _transformationController.value = newTransform;
} }
@ -103,8 +105,12 @@ class _InteractiveBodyWidgetState extends State<InteractiveBodyWidget> {
return Center( return Center(
child: InteractiveViewer( child: InteractiveViewer(
transformationController: _transformationController, transformationController: _transformationController,
minScale: 0.5, alignment: Alignment.center,
maxScale: 4.0, panEnabled: true,
scaleEnabled: true,
boundaryMargin: currentZoom > 1.2 ? EdgeInsets.all(200.h) : EdgeInsets.zero,
minScale: 1.0,
maxScale: 9.0,
clipBehavior: Clip.none, clipBehavior: Clip.none,
child: AspectRatio( child: AspectRatio(
aspectRatio: _imageAspectRatio!, aspectRatio: _imageAspectRatio!,
@ -135,7 +141,7 @@ class _InteractiveBodyWidgetState extends State<InteractiveBodyWidget> {
// Organ dots // Organ dots
...widget.organs.map((organ) { ...widget.organs.map((organ) {
final isSelected = widget.selectedOrganIds.contains(organ.id); final isSelected = widget.selectedOrganIds.contains(organ.id);
final dotSize = 16.0; final dotSize = 18.0;
final leftPos = (organ.position.x * imageConstraints.maxWidth) - (dotSize / 2); final leftPos = (organ.position.x * imageConstraints.maxWidth) - (dotSize / 2);
final topPos = (organ.position.y * imageConstraints.maxHeight) - (dotSize / 2); final topPos = (organ.position.y * imageConstraints.maxHeight) - (dotSize / 2);

@ -23,7 +23,8 @@ abstract class DialogService {
Future<void> showExceptionBottomSheet({required String message, required Function() onOkPressed, Function()? onCancelPressed}); Future<void> showExceptionBottomSheet({required String message, required Function() onOkPressed, Function()? onCancelPressed});
Future<void> showCommonBottomSheetWithoutH({String? label, required String message, String? okLabel, String? cancelLabel, bool isConfirmButton = false, required Function() onOkPressed, Function()? onCancelPressed}); Future<void> showCommonBottomSheetWithoutH(
{String? label, required String message, String? okLabel, String? cancelLabel, bool isConfirmButton = false, required Function() onOkPressed, Function()? onCancelPressed});
Future<void> showSuccessBottomSheetWithoutH({String? label, required String message, required Function() onOkPressed, Function()? onCancelPressed}); Future<void> showSuccessBottomSheetWithoutH({String? label, required String message, required Function() onOkPressed, Function()? onCancelPressed});
@ -119,21 +120,15 @@ class DialogServiceImp implements DialogService {
} }
@override @override
Future<void> showCommonBottomSheetWithoutH({String? label, required String message, String? okLabel, String? cancelLabel, bool isConfirmButton = false, required Function() onOkPressed, Function()? onCancelPressed}) async { Future<void> showCommonBottomSheetWithoutH(
{String? label, required String message, String? okLabel, String? cancelLabel, bool isConfirmButton = false, required Function() onOkPressed, Function()? onCancelPressed}) async {
final context = navigationService.navigatorKey.currentContext; final context = navigationService.navigatorKey.currentContext;
if (context == null) return; if (context == null) return;
showCommonBottomSheetWithoutHeight( showCommonBottomSheetWithoutHeight(
context, context,
title: label ?? "", title: label ?? "",
child: exceptionBottomSheetWidget( child: exceptionBottomSheetWidget(
context: context, context: context, message: message, okLabel: okLabel, cancelLabel: cancelLabel, onOkPressed: onOkPressed, onCancelPressed: onCancelPressed, isConfirmButton: isConfirmButton),
message: message,
okLabel: okLabel,
cancelLabel: cancelLabel,
onOkPressed: onOkPressed,
onCancelPressed: onCancelPressed,
isConfirmButton: isConfirmButton
),
callBackFunc: () {}, callBackFunc: () {},
); );
} }
@ -240,7 +235,8 @@ class DialogServiceImp implements DialogService {
} }
} }
Widget exceptionBottomSheetWidget({required BuildContext context, required String message, String? okLabel, String? cancelLabel, bool isConfirmButton = false, required Function() onOkPressed, Function()? onCancelPressed}) { Widget exceptionBottomSheetWidget(
{required BuildContext context, required String message, String? okLabel, String? cancelLabel, bool isConfirmButton = false, required Function() onOkPressed, Function()? onCancelPressed}) {
return Column( return Column(
children: [ children: [
(message).toText16(isBold: false, color: AppColors.textColor), (message).toText16(isBold: false, color: AppColors.textColor),

@ -1,3 +1,4 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hmg_patient_app_new/theme/colors.dart'; import 'package:hmg_patient_app_new/theme/colors.dart';
@ -15,10 +16,7 @@ class AppTheme {
visualDensity: VisualDensity.adaptivePlatformDensity, visualDensity: VisualDensity.adaptivePlatformDensity,
brightness: Brightness.light, brightness: Brightness.light,
pageTransitionsTheme: const PageTransitionsTheme( pageTransitionsTheme: const PageTransitionsTheme(
builders: { builders: {TargetPlatform.android: ZoomPageTransitionsBuilder(), TargetPlatform.iOS: CupertinoPageTransitionsBuilder()},
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder()
},
), ),
hintColor: Colors.grey[400], hintColor: Colors.grey[400],
disabledColor: Colors.grey[300], disabledColor: Colors.grey[300],
@ -51,10 +49,7 @@ class AppTheme {
visualDensity: VisualDensity.adaptivePlatformDensity, visualDensity: VisualDensity.adaptivePlatformDensity,
brightness: Brightness.dark, brightness: Brightness.dark,
pageTransitionsTheme: const PageTransitionsTheme( pageTransitionsTheme: const PageTransitionsTheme(
builders: { builders: {TargetPlatform.android: ZoomPageTransitionsBuilder(), TargetPlatform.iOS: CupertinoPageTransitionsBuilder()},
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder()
},
), ),
hintColor: Colors.grey[600], hintColor: Colors.grey[600],
disabledColor: Colors.grey[700], disabledColor: Colors.grey[700],
@ -63,8 +58,7 @@ class AppTheme {
scaffoldBackgroundColor: AppColors.dark.scaffoldBgColor, scaffoldBackgroundColor: AppColors.dark.scaffoldBgColor,
highlightColor: Colors.grey[800]!.withOpacity(0.4), highlightColor: Colors.grey[800]!.withOpacity(0.4),
splashColor: Colors.transparent, splashColor: Colors.transparent,
bottomSheetTheme: BottomSheetThemeData( bottomSheetTheme: BottomSheetThemeData(backgroundColor: Colors.black.withOpacity(0)),
backgroundColor: Colors.black.withOpacity(0)),
floatingActionButtonTheme: const FloatingActionButtonThemeData(highlightElevation: 2, disabledElevation: 0, elevation: 2), floatingActionButtonTheme: const FloatingActionButtonThemeData(highlightElevation: 2, disabledElevation: 0, elevation: 2),
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
color: AppColors.dark.scaffoldBgColor, color: AppColors.dark.scaffoldBgColor,

Loading…
Cancel
Save