diff --git a/lib/presentation/symptoms_checker/organ_selector_screen.dart b/lib/presentation/symptoms_checker/organ_selector_screen.dart index 2244ecbd..438d9728 100644 --- a/lib/presentation/symptoms_checker/organ_selector_screen.dart +++ b/lib/presentation/symptoms_checker/organ_selector_screen.dart @@ -29,11 +29,19 @@ class OrganSelectorPage extends StatefulWidget { State createState() => _OrganSelectorPageState(); } -class _OrganSelectorPageState extends State { +class _OrganSelectorPageState extends State 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 DialogService dialogService; late final CacheService cacheService; + late final AnimationController _introZoomHintController; + late final Animation _introZoomHintAnimation; bool _showTutorial = false; + bool _hasPlayedIntroZoomHint = false; @override void initState() { @@ -41,10 +49,52 @@ class _OrganSelectorPageState extends State { _appState = getIt.get(); dialogService = getIt(); cacheService = getIt(); + + _introZoomHintController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2000), + )..addListener(() { + if (mounted) setState(() {}); + }); + _introZoomHintAnimation = TweenSequence([ + TweenSequenceItem( + tween: Tween(begin: 1.0, end: 1.25), + weight: 45, + ), + TweenSequenceItem( + tween: Tween(begin: 1.25, end: 1.0), + weight: 55, + ), + ]).animate(CurvedAnimation( + parent: _introZoomHintController, + curve: Curves.easeInOut, + )); + _checkAndShowTutorial(); + _playIntroZoomHint(); + } + + @override + void dispose() { + _introZoomHintController.dispose(); + super.dispose(); + } + + Future _playIntroZoomHint() async { + if (_hasPlayedIntroZoomHint) return; + _hasPlayedIntroZoomHint = true; + + await Future.delayed(const Duration(milliseconds: 700)); + if (!mounted) return; + _introZoomHintController.forward(from: 0); } Future _checkAndShowTutorial() async { + if (!_enableTutorialOverlay) { + _showTutorial = false; + return; + } + // final hasSeenTutorial = cacheService.getBool(key: CacheConst.organSelectorTutorialShown) ?? false; // if (!hasSeenTutorial) { // Show tutorial after a short delay to ensure the screen is fully built @@ -118,7 +168,11 @@ class _OrganSelectorPageState extends State { Expanded( child: Stack( children: [ - _buildBodyViewer(viewModel), + SafeArea( + top: false, + bottom: false, + child: _buildBodyViewer(viewModel), + ), _buildViewToggleButtons(viewModel), // _buildViewZoomButtons(viewModel), _buildBottomSheet(viewModel), @@ -131,7 +185,7 @@ class _OrganSelectorPageState extends State { ), ), // Tutorial overlay - if (_showTutorial) + if (_enableTutorialOverlay && _showTutorial) PinchZoomTutorialOverlay( onComplete: _onTutorialComplete, ), @@ -152,7 +206,13 @@ class _OrganSelectorPageState extends State { height: 24.h, ), 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(); + viewModel.clearAllSelections(); + viewModel.setBottomSheetExpanded(false); + Navigator.pop(context); + }, highlightColor: Colors.transparent, ), ), @@ -175,45 +235,82 @@ class _OrganSelectorPageState extends State { Widget _buildBodyViewer(SymptomsCheckerViewModel viewModel) { return GestureDetector( - onHorizontalDragEnd: (details) { - // Swipe left or right to toggle view - 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(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, - ); + onHorizontalDragEnd: !_enableSwipeToFlip + ? null + : (details) { + // Swipe left or right to toggle view + if (details.primaryVelocity != null) { + if (details.primaryVelocity! < -200 || details.primaryVelocity! > 200) { + viewModel.toggleView(); + } + } }, - ), - ), + 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(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 { } Widget _buildExpandCollapseButton(SymptomsCheckerViewModel viewModel) { + final organCount = viewModel.selectedOrgans.length; + final showBadge = !viewModel.isBottomSheetExpanded && organCount > 0; + return PositionedDirectional( end: 24.w, top: -24.h, child: GestureDetector( onTap: viewModel.toggleBottomSheet, behavior: HitTestBehavior.opaque, - child: Container( + child: SizedBox( width: 70.w, height: 70.h, - alignment: Alignment.center, - child: Container( - width: 48.w, - height: 48.h, - decoration: RoundedRectangleBorder().toSmoothCornerDecoration( - color: AppColors.whiteColor, - borderRadius: 11.r, - ), - child: Center( - child: Transform.flip( - flipX: _appState.isArabic(), - child: AnimatedRotation( - duration: const Duration(milliseconds: 300), - turns: viewModel.isBottomSheetExpanded ? 0.25 : -0.25, - child: Utils.buildSvgWithAssets( - icon: AppAssets.arrowRight, - width: 25.w, - height: 25.h, + child: Stack( + clipBehavior: Clip.none, + children: [ + Align( + alignment: Alignment.center, + child: Container( + width: 48.w, + height: 48.h, + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: AppColors.whiteColor, + borderRadius: 11.r, + ), + child: Center( + child: Transform.flip( + flipX: _appState.isArabic(), + child: AnimatedRotation( + duration: const Duration(milliseconds: 300), + 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, + ), + ), + ), + ), + ], ), ), ), diff --git a/lib/presentation/symptoms_checker/widgets/interactive_body_widget.dart b/lib/presentation/symptoms_checker/widgets/interactive_body_widget.dart index a32709f9..813ff7e3 100644 --- a/lib/presentation/symptoms_checker/widgets/interactive_body_widget.dart +++ b/lib/presentation/symptoms_checker/widgets/interactive_body_widget.dart @@ -1,3 +1,4 @@ +import 'dart:developer'; import 'dart:ui' as ui; 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/presentation/symptoms_checker/widgets/organ_dot_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 { final String bodyImageAsset; @@ -63,9 +63,12 @@ class _InteractiveBodyWidgetState extends State { @override void dispose() { _transformationController.dispose(); + currentZoom = 0.0; super.dispose(); } + double currentZoom = 0.0; + Future _loadImageAspectRatio() async { final ByteData data = await rootBundle.load(widget.bodyImageAsset); final ui.Codec codec = await ui.instantiateImageCodec(data.buffer.asUint8List()); @@ -77,17 +80,16 @@ class _InteractiveBodyWidgetState extends State { _imageAspectRatio = image.width / image.height; }); } + + _transformationController.addListener(() { + currentZoom = _transformationController.value.getMaxScaleOnAxis(); + setState(() {}); + }); } void _updateZoom(double scale) { - // Get current translation - final currentTransform = _transformationController.value; - 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)); + // Keep programmatic zoom centered to avoid drifting the body off-screen. + final newTransform = Matrix4.identity()..scaleByDouble(scale, scale, 1.0, 1.0); _transformationController.value = newTransform; } @@ -103,8 +105,12 @@ class _InteractiveBodyWidgetState extends State { return Center( child: InteractiveViewer( transformationController: _transformationController, - minScale: 0.5, - maxScale: 4.0, + alignment: Alignment.center, + panEnabled: true, + scaleEnabled: true, + boundaryMargin: currentZoom > 1.2 ? EdgeInsets.all(200.h) : EdgeInsets.zero, + minScale: 1.0, + maxScale: 9.0, clipBehavior: Clip.none, child: AspectRatio( aspectRatio: _imageAspectRatio!, @@ -135,7 +141,7 @@ class _InteractiveBodyWidgetState extends State { // Organ dots ...widget.organs.map((organ) { 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 topPos = (organ.position.y * imageConstraints.maxHeight) - (dotSize / 2); diff --git a/lib/services/dialog_service.dart b/lib/services/dialog_service.dart index ac7acf9e..be33fe5d 100644 --- a/lib/services/dialog_service.dart +++ b/lib/services/dialog_service.dart @@ -23,7 +23,8 @@ abstract class DialogService { Future showExceptionBottomSheet({required String message, required Function() onOkPressed, Function()? onCancelPressed}); - Future showCommonBottomSheetWithoutH({String? label, required String message, String? okLabel, String? cancelLabel, bool isConfirmButton = false, required Function() onOkPressed, Function()? onCancelPressed}); + Future showCommonBottomSheetWithoutH( + {String? label, required String message, String? okLabel, String? cancelLabel, bool isConfirmButton = false, required Function() onOkPressed, Function()? onCancelPressed}); Future showSuccessBottomSheetWithoutH({String? label, required String message, required Function() onOkPressed, Function()? onCancelPressed}); @@ -119,21 +120,15 @@ class DialogServiceImp implements DialogService { } @override - Future showCommonBottomSheetWithoutH({String? label, required String message, String? okLabel, String? cancelLabel, bool isConfirmButton = false, required Function() onOkPressed, Function()? onCancelPressed}) async { + Future showCommonBottomSheetWithoutH( + {String? label, required String message, String? okLabel, String? cancelLabel, bool isConfirmButton = false, required Function() onOkPressed, Function()? onCancelPressed}) async { final context = navigationService.navigatorKey.currentContext; if (context == null) return; showCommonBottomSheetWithoutHeight( context, title: label ?? "", child: exceptionBottomSheetWidget( - context: context, - message: message, - okLabel: okLabel, - cancelLabel: cancelLabel, - onOkPressed: onOkPressed, - onCancelPressed: onCancelPressed, - isConfirmButton: isConfirmButton - ), + context: context, message: message, okLabel: okLabel, cancelLabel: cancelLabel, onOkPressed: onOkPressed, onCancelPressed: onCancelPressed, isConfirmButton: isConfirmButton), 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( children: [ (message).toText16(isBold: false, color: AppColors.textColor), diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index c676819f..f4465f7f 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -1,3 +1,4 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hmg_patient_app_new/theme/colors.dart'; @@ -15,10 +16,7 @@ class AppTheme { visualDensity: VisualDensity.adaptivePlatformDensity, brightness: Brightness.light, pageTransitionsTheme: const PageTransitionsTheme( - builders: { - TargetPlatform.android: ZoomPageTransitionsBuilder(), - TargetPlatform.iOS: CupertinoPageTransitionsBuilder() - }, + builders: {TargetPlatform.android: ZoomPageTransitionsBuilder(), TargetPlatform.iOS: CupertinoPageTransitionsBuilder()}, ), hintColor: Colors.grey[400], disabledColor: Colors.grey[300], @@ -51,10 +49,7 @@ class AppTheme { visualDensity: VisualDensity.adaptivePlatformDensity, brightness: Brightness.dark, pageTransitionsTheme: const PageTransitionsTheme( - builders: { - TargetPlatform.android: ZoomPageTransitionsBuilder(), - TargetPlatform.iOS: CupertinoPageTransitionsBuilder() - }, + builders: {TargetPlatform.android: ZoomPageTransitionsBuilder(), TargetPlatform.iOS: CupertinoPageTransitionsBuilder()}, ), hintColor: Colors.grey[600], disabledColor: Colors.grey[700], @@ -63,8 +58,7 @@ class AppTheme { scaffoldBackgroundColor: AppColors.dark.scaffoldBgColor, highlightColor: Colors.grey[800]!.withOpacity(0.4), splashColor: Colors.transparent, - bottomSheetTheme: BottomSheetThemeData( - backgroundColor: Colors.black.withOpacity(0)), + bottomSheetTheme: BottomSheetThemeData(backgroundColor: Colors.black.withOpacity(0)), floatingActionButtonTheme: const FloatingActionButtonThemeData(highlightElevation: 2, disabledElevation: 0, elevation: 2), appBarTheme: AppBarTheme( color: AppColors.dark.scaffoldBgColor,