Merge branch 'master' into haroon_dev

pull/359/head
haroon amjad 1 week ago
commit f20e6e8b16

@ -70,9 +70,7 @@ class ProfilePictureViewModel extends ChangeNotifier {
try {
_cachedImageBytes = base64Decode(imageData);
_cachedImageDataHash = '${imageData.length}_${imageData.hashCode}';
print('✅ Cached existing profile image');
} catch (e) {
print('❌ Error caching existing image: $e');
_cachedImageBytes = null;
_cachedImageDataHash = null;
}
@ -85,7 +83,6 @@ class ProfilePictureViewModel extends ChangeNotifier {
final currentPatientId = _appState.getAuthenticatedUser()?.patientId;
if (currentPatientId != null && currentPatientId != _currentPatientId) {
print('🔄 User switched detected: $_currentPatientId -> $currentPatientId');
_handleUserSwitch(currentPatientId);
return true;
}
@ -97,8 +94,6 @@ class ProfilePictureViewModel extends ChangeNotifier {
final oldPatientId = _currentPatientId;
_currentPatientId = newPatientId;
print('🧹 Clearing cache for old user: $oldPatientId');
// Clear AppState cache
_appState.clearProfileImageCache();
@ -113,17 +108,14 @@ class ProfilePictureViewModel extends ChangeNotifier {
notifyListeners();
// Load new user's profile image
print('📥 Loading profile image for new user: $newPatientId');
_profileSettingsViewModel.getProfileImage(
patientID: newPatientId,
forceRefresh: true,
onSuccess: (data) {
print('✅ Profile image loaded successfully for user: $newPatientId');
_tryCacheExistingImage();
notifyListeners();
},
onError: (error) {
print('❌ Error loading profile image: $error');
notifyListeners();
},
);
@ -133,22 +125,18 @@ class ProfilePictureViewModel extends ChangeNotifier {
void loadProfileImage({bool forceRefresh = false}) {
// Check if profile image is already loaded in AppState (skip if forcing refresh)
if (!forceRefresh && _appState.getProfileImageData != null && _appState.getProfileImageData!.isNotEmpty) {
print('✅ Profile image already cached in AppState');
return;
}
final patientID = _appState.getAuthenticatedUser()?.patientId;
if (patientID == null) {
print('⚠️ Cannot load profile image - no authenticated user');
return;
}
print('📥 Loading profile image for patient: $patientID (forceRefresh: $forceRefresh)');
_profileSettingsViewModel.getProfileImage(
patientID: patientID,
forceRefresh: forceRefresh,
onSuccess: (data) {
print('✅ Profile image loaded successfully');
_tryCacheExistingImage();
notifyListeners();
},
@ -188,10 +176,6 @@ class ProfilePictureViewModel extends ChangeNotifier {
false, // Don't show files option, only camera and gallery
(base64String, file) async {
try {
print('=== Starting image processing ===');
print('File path: ${file.path}');
print('File exists: ${await file.exists()}');
print('Original file size: ${await file.length() / 1024} KB');
// Compress and resize the image
print('Calling compressAndResizeImage...');
@ -266,14 +250,10 @@ class ProfilePictureViewModel extends ChangeNotifier {
onError('No authenticated user found');
return;
}
print('📤 Uploading profile image for patient: $patientID');
_profileSettingsViewModel.uploadProfileImage(
patientID: patientID,
imageData: base64String,
onSuccess: (data) async {
print('✅ Profile image uploaded successfully');
// Clear old cache first to ensure fresh data
_cachedImageBytes = null;
_cachedImageDataHash = null;
@ -287,9 +267,13 @@ class ProfilePictureViewModel extends ChangeNotifier {
// Increment version to trigger targeted rebuild (no full screen refresh)
_profileImageVersion.value++;
print('🔄 Profile image version updated to ${_profileImageVersion.value} (targeted rebuild)');
onSuccess(data);
final String successMessage = data is String
? data
: (data is Map && data['message'] != null
? data['message'].toString()
: data?.toString() ?? 'Success');
onSuccess(successMessage);
},
onError: (error) {
print('❌ Error uploading profile image: $error');
@ -308,7 +292,6 @@ class ProfilePictureViewModel extends ChangeNotifier {
try {
_cachedImageBytes = base64Decode(imageData!);
_cachedImageDataHash = currentHash;
print('🔄 Updated cached image bytes');
} catch (e) {
print('❌ Error decoding profile image: $e');
_cachedImageBytes = null;
@ -326,7 +309,6 @@ class ProfilePictureViewModel extends ChangeNotifier {
_cachedImageDataHash = null;
_selectedImage = null;
notifyListeners();
print('🧹 Cleared all profile picture cache');
}
/// Check if we should show shimmer loading

@ -120,13 +120,24 @@ class _LandingPageState extends State<LandingPage> {
authVM = context.read<AuthenticationViewModel>();
habibWalletVM = context.read<HabibWalletViewModel>();
appointmentRatingViewModel = context.read<AppointmentRatingViewModel>();
appState = getIt.get<AppState>();
authVM.savePushTokenToAppState();
if (mounted) {
final user = appState.getAuthenticatedUser();
final hasUserQuickLoginData =
user != null &&
(user.mobileNumber?.isNotEmpty ?? false) &&
(user.patientIdentificationNo?.isNotEmpty ?? false);
if (hasUserQuickLoginData) {
authVM.checkLastLoginStatus(() {
if (mounted) {
showQuickLogin(context);
}
});
}
}
_scrollController.addListener(() {
final scrollOffset = _scrollController.offset;
@ -172,12 +183,19 @@ class _LandingPageState extends State<LandingPage> {
appointmentNo,
projectID,
onSuccess: ((response) {
// Only open dialog if details are loaded AND widget is mounted
if (!mounted || appointmentRatingViewModel.appointmentDetails == null) return;
appointmentRatingViewModel.setClinicOrDoctor(false);
appointmentRatingViewModel.setTitle(LocaleKeys.rateDoctor.tr(context: context));
appointmentRatingViewModel.setSubTitle(LocaleKeys.howWasYourLastVisitWithDoctor.tr(context: context));
appointmentRatingViewModel.setTitle(LocaleKeys.rateDoctor.tr());
appointmentRatingViewModel.setSubTitle(LocaleKeys.howWasYourLastVisitWithDoctor.tr());
openLastRating();
appState.setRatedVisible(true);
}),
onError: (String errorMessage) {
// Silently mark as rated to prevent retry loops
if (mounted) appState.setRatedVisible(true);
},
);
}
},
@ -1458,6 +1476,7 @@ class _LandingPageState extends State<LandingPage> {
}
openLastRating() {
if (!mounted) return;
showCommonBottomSheetWithoutHeight(
context,
titleWidget: Selector<AppointmentRatingViewModel, String?>(

@ -29,11 +29,19 @@ class OrganSelectorPage extends StatefulWidget {
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 DialogService dialogService;
late final CacheService cacheService;
late final AnimationController _introZoomHintController;
late final Animation<double> _introZoomHintAnimation;
bool _showTutorial = false;
bool _hasPlayedIntroZoomHint = false;
@override
void initState() {
@ -41,10 +49,52 @@ class _OrganSelectorPageState extends State<OrganSelectorPage> {
_appState = getIt.get<AppState>();
dialogService = getIt<DialogService>();
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();
_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 {
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<OrganSelectorPage> {
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<OrganSelectorPage> {
),
),
// Tutorial overlay
if (_showTutorial)
if (_enableTutorialOverlay && _showTutorial)
PinchZoomTutorialOverlay(
onComplete: _onTutorialComplete,
),
@ -152,7 +206,13 @@ class _OrganSelectorPageState extends State<OrganSelectorPage> {
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<SymptomsCheckerViewModel>();
viewModel.clearAllSelections();
viewModel.setBottomSheetExpanded(false);
Navigator.pop(context);
},
highlightColor: Colors.transparent,
),
),
@ -175,7 +235,9 @@ class _OrganSelectorPageState extends State<OrganSelectorPage> {
Widget _buildBodyViewer(SymptomsCheckerViewModel viewModel) {
return GestureDetector(
onHorizontalDragEnd: (details) {
onHorizontalDragEnd: !_enableSwipeToFlip
? null
: (details) {
// Swipe left or right to toggle view
if (details.primaryVelocity != null) {
if (details.primaryVelocity! < -200 || details.primaryVelocity! > 200) {
@ -183,9 +245,15 @@ class _OrganSelectorPageState extends State<OrganSelectorPage> {
}
}
},
child: Padding(
padding: EdgeInsets.fromLTRB(16.h, 16.h, 16.h, 60.h),
child: AnimatedSwitcher(
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,
@ -193,15 +261,14 @@ class _OrganSelectorPageState extends State<OrganSelectorPage> {
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 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);
return InteractiveBodyWidget(
final body = InteractiveBodyWidget(
bodyImageAsset: bodyAsset,
organs: viewModel.currentOrgans,
selectedOrganIds: viewModel.selectedOrganIds,
@ -209,13 +276,43 @@ class _OrganSelectorPageState extends State<OrganSelectorPage> {
isBodyHidden: viewModel.isBodyHidden,
tooltipOrganId: viewModel.tooltipOrganId,
isArabic: _appState.isArabic(),
zoomScale: viewModel.currentZoomScale,
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),
),
),
),
);
},
),
);
}
Widget _build3DFlipTransition(Widget child, Animation<double> animation) {
@ -408,15 +505,22 @@ class _OrganSelectorPageState extends State<OrganSelectorPage> {
}
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,
child: Stack(
clipBehavior: Clip.none,
children: [
Align(
alignment: Alignment.center,
child: Container(
width: 48.w,
@ -441,6 +545,35 @@ class _OrganSelectorPageState extends State<OrganSelectorPage> {
),
),
),
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 '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<InteractiveBodyWidget> {
@override
void dispose() {
_transformationController.dispose();
currentZoom = 0.0;
super.dispose();
}
double currentZoom = 0.0;
Future<void> _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<InteractiveBodyWidget> {
_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<InteractiveBodyWidget> {
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<InteractiveBodyWidget> {
// 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);

@ -23,7 +23,8 @@ abstract class DialogService {
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});
@ -119,21 +120,15 @@ class DialogServiceImp implements DialogService {
}
@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;
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),

@ -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,

Loading…
Cancel
Save