Merge branch 'master' into faiz_dev

# Conflicts:
#	lib/core/dependencies.dart
#	lib/main.dart
#	lib/presentation/authentication/register_step2.dart
pull/322/head
faizatflutter 4 weeks ago
commit 6efbe257d1

File diff suppressed because it is too large Load Diff

@ -0,0 +1,444 @@
# Theme Implementation Guide - HMG Patient App
## Overview
This Flutter application implements a comprehensive **dual-theme system** supporting both **Light Mode** and **Dark Mode**. The theme system is built with a combination of Flutter's native theming and a custom color management system.
---
## Architecture
### 1. **Core Components**
#### A. AppTheme Class (`lib/theme/app_theme.dart`)
- **Purpose**: Defines the Material theme configurations for both light and dark modes
- **Key Methods**:
- `getTheme(isArabic)` - Returns light theme configuration
- `getDarkTheme(isArabic)` - Returns dark theme configuration
**Features**:
- Dynamic font family selection based on locale (Arabic: `CairoArabic`, English: `Poppins`)
- Platform-specific page transitions (Android: Zoom, iOS: Cupertino)
- Consistent styling for AppBar, BottomSheet, FloatingActionButton
- Transparent splash colors for better UX
- System overlay styles (Dark for light theme, Light for dark theme)
#### B. AppColors Class (`lib/theme/colors.dart`)
- **Purpose**: Centralized color management with dual-palette support
- **Architecture**: Three-tier color system
**Three Access Patterns**:
1. **Static Getters (Global State-Based)**
```dart
AppColors.primaryRedColor // Uses AppColors.isDarkMode flag
```
- Checks global `AppColors.isDarkMode` boolean
- Returns dark or light palette value accordingly
2. **AppColorsDark Class (Dark Palette)**
```dart
AppColors.dark.primaryRedColor // Always dark variant
```
- Constant dark mode color values
- Used as source for dark theme colors
3. **BuildContext Extension (Theme-Aware)**
```dart
context.primaryRedColor // Uses Theme brightness
```
- Reads `Theme.of(context).brightness`
- Automatically adapts to MaterialApp's themeMode
---
## 2. **Theme State Management**
### ProfileSettingsViewModel
Located in `lib/features/profile_settings/profile_settings_view_model.dart`
**Responsibilities**:
- Manages dark mode state (`_isDarkMode` boolean)
- Persists preference to local storage (CacheService)
- Triggers UI rebuilds on theme changes
**Key Methods**:
```dart
// Called at app startup (before first frame)
void loadDarkMode() {
final saved = _cacheService.getBool(key: _darkModeKey);
_isDarkMode = saved ?? false;
AppColors.isDarkMode = _isDarkMode;
// No notifyListeners() - called before build
}
// Toggle theme and persist
void toggleDarkMode(bool value) {
_isDarkMode = value;
AppColors.isDarkMode = value;
_cacheService.saveBool(key: _darkModeKey, value: value);
notifyListeners(); // Triggers rebuild
}
```
---
## 3. **Application Lifecycle**
### Initialization Flow (`main.dart`)
```dart
Future<void> callInitializations() async {
// ... Firebase, dependencies setup ...
// Restore dark mode BEFORE first frame
getIt.get<ProfileSettingsViewModel>().loadDarkMode();
}
```
### Theme Application in Widget Tree
```dart
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<ProfileSettingsViewModel>(
builder: (context, profileVm, _) {
final isArabic = EasyLocalization.of(context)?.locale.languageCode == "ar";
return MaterialApp(
key: ValueKey('app_theme_${profileVm.isDarkMode}'), // Force rebuild on theme change
theme: AppTheme.getTheme(isArabic),
darkTheme: AppTheme.getDarkTheme(isArabic),
themeMode: profileVm.isDarkMode ? ThemeMode.dark : ThemeMode.light,
// ... other properties
);
},
);
}
}
```
**Key Mechanism**:
- `Consumer<ProfileSettingsViewModel>` listens for theme changes
- When `toggleDarkMode()` is called → `notifyListeners()` → Widget rebuilds
- `themeMode` property switches between `ThemeMode.dark` and `ThemeMode.light`
- `ValueKey` forces MaterialApp to rebuild entirely on theme change
---
## 4. **Color System Design**
### Dual-Palette Architecture
```dart
class AppColors {
static bool isDarkMode = false; // Global flag
// Pattern: Conditional getter
static Color get primaryRedColor =>
isDarkMode ? dark.primaryRedColor : const Color(0xFFED1C2B);
// Dark palette instance
static const AppColorsDark dark = AppColorsDark();
}
class AppColorsDark {
const AppColorsDark();
// Dark mode variants
Color get primaryRedColor => const Color(0xFFDE5C5D);
Color get scaffoldBgColor => const Color(0xFF191919);
Color get textColor => const Color(0xFFFFFFFF);
// ... 100+ color definitions
}
```
### BuildContext Extension for Theme-Aware Colors
```dart
extension AppColorsContext on BuildContext {
bool get _isDark => Theme.of(this).brightness == Brightness.dark;
Color get scaffoldBgColor =>
_isDark ? AppColors.dark.scaffoldBgColor : const Color(0xFFF8F8F8);
Color get textColor =>
_isDark ? AppColors.dark.textColor : const Color(0xFF2E3039);
}
```
**Usage in Widgets**:
```dart
// Option 1: Static (uses global flag)
color: AppColors.primaryRedColor
// Option 2: Extension (uses Theme.of(context))
color: context.primaryRedColor
// Option 3: Direct dark access
color: AppColors.dark.primaryRedColor
```
---
## 5. **User Interface Implementation**
### Theme Toggle Control (`profile_settings.dart`)
```dart
Consumer<ProfileSettingsViewModel>(
builder: (context, profileVm, _) {
return actionItem(
AppAssets.darkModeIcon,
LocaleKeys.darkMode.tr(context: context),
() {
profileVm.toggleDarkMode(!profileVm.isDarkMode);
},
switchValue: profileVm.isDarkMode,
onSwitchChanged: (value) {
profileVm.toggleDarkMode(value);
},
);
},
)
```
---
## 6. **Color Categories**
### Organized Color Groups
| Category | Light Example | Dark Example |
|----------|---------------|--------------|
| **Scaffold/Background** | `0xFFF8F8F8` | `0xFF191919` |
| **Primary Brand** | `0xFFED1C2B` (Red) | `0xFFDE5C5D` (Softer Red) |
| **Text** | `0xFF2E3039` (Dark) | `0xFFFFFFFF` (White) |
| **Success** | `0xFF18C273` (Green) | `0xFF18C273` (Same) |
| **Error** | `0xFFED1C2B` | `0xFFD63D48` |
| **Card Surface** | `0xFFFFFFFF` | `0xFF1E1E1E` |
| **Borders** | `0x332E3039` | `0x55ECECEC` |
| **Shimmer Base** | `0xFFE0E0E0` | `0xFF2C2C2C` |
### Special Purpose Colors
- **Rating Stars**: Constant across themes (`0xFFFFA726`)
- **Health Calculators**: Feature-specific colors maintained in both themes
- **Status Colors**: Pending, Processing, Completed, Rejected
- **Info Banners**: Warning backgrounds with adjusted opacity for dark mode
---
## 7. **Best Practices for Developers**
### Adding New Colors
1. **Define in Light Theme**:
```dart
static Color get newFeatureColor =>
isDarkMode ? dark.newFeatureColor : const Color(0xFFXXXXXX);
```
2. **Add Dark Variant**:
```dart
class AppColorsDark {
Color get newFeatureColor => const Color(0xFFYYYYYY);
}
```
3. **Optional: Add to BuildContext Extension**:
```dart
extension AppColorsContext on BuildContext {
Color get newFeatureColor =>
_isDark ? AppColors.dark.newFeatureColor : const Color(0xFFXXXXXX);
}
```
### Using Colors in Widgets
**Recommended Approach**:
```dart
// For colors that should adapt to theme
Container(
color: AppColors.scaffoldBgColor, // Uses global isDarkMode
)
// Or using context extension
Container(
color: context.scaffoldBgColor, // Uses Theme.brightness
)
```
**Avoid**:
```dart
// Don't hardcode colors
Container(
color: Color(0xFFED1C2B), // ❌ Won't adapt to dark mode
)
```
### Testing Theme Changes
```dart
// Toggle theme programmatically
context.read<ProfileSettingsViewModel>().toggleDarkMode(true);
// Check current state
final isDark = context.read<ProfileSettingsViewModel>().isDarkMode;
```
---
## 8. **Persistence Layer**
### CacheService Integration
- Uses shared_preferences or similar local storage
- Key: `_darkModeKey` (defined in ProfileSettingsViewModel)
- Automatically loads on app startup
- Saves immediately on toggle
**Flow**:
1. App launches → `loadDarkMode()` reads from cache
2. User toggles → `toggleDarkMode(value)` saves to cache
3. App restart → Previous preference restored
---
## 9. **Font Handling**
### Locale-Specific Fonts
```dart
// In AppTheme
fontFamily: isArabic ? 'CairoArabic' : 'Poppins'
textTheme: const TextTheme(
displayLarge: TextStyle(fontFamily: 'CairoArabic'),
bodyLarge: TextStyle(fontFamily: 'CairoArabic'),
)
```
**Rationale**: Arabic text requires specialized fonts for proper rendering
---
## 10. **System Integration**
### AppBar System Overlay
```dart
// Light Theme
systemOverlayStyle: SystemUiOverlayStyle.dark // Dark status bar icons
// Dark Theme
systemOverlayStyle: SystemUiOverlayStyle.light // Light status bar icons
```
### Platform-Specific Adjustments
```dart
// SafeArea handling
SafeArea(
top: false,
bottom: Platform.isIOS ? false : true,
)
```
---
## 11. **Advanced Features**
### Gradient Support
```dart
static const LinearGradient aiLinearGradient = LinearGradient(
colors: [Color(0xFF8A38F5), Color(0xFFE20BBB)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
```
*Note: Gradients remain constant across themes for brand consistency*
### Transparency Management
```dart
static const transparent = Colors.transparent;
// Used for splash colors, bottomSheet backgrounds
```
---
## 12. **Common Pitfalls & Solutions**
### Issue 1: Colors Not Updating
**Problem**: Widget doesn't reflect theme change
**Solution**: Ensure widget rebuilds on theme change
```dart
// Wrap with Consumer or use context.watch
Consumer<ProfileSettingsViewModel>(
builder: (context, vm, _) => YourWidget(),
)
```
### Issue 2: Inconsistent Colors
**Problem**: Some UI elements use wrong theme
**Solution**: Always use `AppColors.*` or `context.*`, never hardcode
### Issue 3: Theme Not Persisting
**Problem**: Theme resets on app restart
**Solution**: Verify `loadDarkMode()` is called in `callInitializations()`
---
## 13. **File Structure**
```
lib/
├── theme/
│ ├── app_theme.dart # Theme configurations
│ └── colors.dart # Color definitions & dark palette
├── features/
│ └── profile_settings/
│ └── profile_settings_view_model.dart # Theme state management
├── presentation/
│ └── profile_settings/
│ └── profile_settings.dart # UI with theme toggle
└── main.dart # Theme initialization & application
```
---
## 14. **Summary**
### Workflow
1. **Initialization**: Load saved theme preference → Set `AppColors.isDarkMode`
2. **User Action**: Toggle switch in Settings → Call `toggleDarkMode()`
3. **State Update**: Update flag → Save to cache → Notify listeners
4. **UI Rebuild**: Consumer rebuilds → MaterialApp switches `themeMode`
5. **Color Resolution**: All `AppColors.*` getters return appropriate palette
### Key Advantages
**Centralized**: Single source of truth for colors
**Type-Safe**: Compile-time color checking
**Persistent**: Survives app restarts
**Flexible**: Multiple access patterns for different use cases
**Maintainable**: Easy to add/modify colors
**Performance**: No runtime color calculations, just conditional returns
---
## 15. **Migration Checklist for New Features**
When adding a new screen/feature:
- [ ] Use `AppColors.*` for all color references
- [ ] Test in both light and dark modes
- [ ] Ensure text contrast meets accessibility standards
- [ ] Add dark variants for any new colors
- [ ] Verify with Arabic locale (font rendering)
- [ ] Check shimmer/loading states in both themes
- [ ] Test on both iOS and Android
---
**Last Updated**: January 2024
**Maintained By**: HMG Development Team

@ -41,10 +41,6 @@
android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"
tools:node="remove" />
<!-- Gallery/Photo permissions for Android 13+ (API 33+) -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove" />
<!-- <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />-->
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove" />
<!-- Gallery/Photo permissions for Android 12 and below -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.9995 12C20.9995 12 20.9995 12 20.9995 12C20.9995 11.5568 20.8047 11.162 20.6411 10.8906C20.461 10.5918 20.2225 10.2892 19.9677 10.0008C19.4559 9.4216 18.7926 8.80434 18.1551 8.25084C17.5127 7.69303 16.8731 7.18002 16.396 6.80767C16.1593 6.62291 15.7406 6.30672 15.5962 6.19767L15.5925 6.19487C15.1478 5.86736 14.5218 5.96236 14.1943 6.40706C13.8668 6.85173 13.9618 7.47768 14.4064 7.80521C14.5396 7.90576 14.9359 8.20515 15.1656 8.38437C15.6259 8.74365 16.2364 9.23357 16.8439 9.76105C17.3225 10.1765 17.7853 10.6034 18.1679 11L3.99951 11C3.44723 11 2.99951 11.4477 2.99951 12C2.99951 12.5523 3.44723 13 3.99951 13L18.1679 13C17.7853 13.3966 17.3225 13.8235 16.8439 14.2389C16.2364 14.7664 15.6259 15.2564 15.1656 15.6156C14.9359 15.7948 14.5396 16.0942 14.4064 16.1948C13.9618 16.5223 13.8668 17.1483 14.1943 17.5929C14.5218 18.0376 15.1478 18.1326 15.5925 17.8051L15.5965 17.8021C15.7409 17.6931 16.1593 17.3771 16.396 17.1923C16.8731 16.82 17.5127 16.307 18.1551 15.7492C18.7926 15.1957 19.4559 14.5784 19.9677 13.9992C20.2225 13.7108 20.461 13.4082 20.6411 13.1094C20.8047 12.8381 20.9995 12.4431 20.9995 12Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.7163 5.99998C10.8032 6 10.8902 6.00001 10.9771 6.00001L10.9771 14C10.9771 14.5523 11.4351 15 12.0001 15C12.5651 15 13.0231 14.5523 13.0231 14L13.0231 6.00001C13.11 6.00001 13.1969 6 13.2837 5.99998C13.4694 5.99994 13.655 5.9999 13.8407 6.00004C14.073 6.00021 14.3505 6.00041 14.5774 5.96723C14.8128 5.93278 15.3669 5.81137 15.6344 5.24525C15.9005 4.68223 15.641 4.19357 15.5138 3.99322C15.3933 3.80322 15.2141 3.59781 15.0652 3.42709C15.0546 3.41493 15.0442 3.40295 15.0339 3.39116C14.481 2.75629 13.6973 1.89011 13.1169 1.41106C12.8307 1.17478 12.47 1.01767 12.0681 1.0014C11.6161 0.983094 11.2039 1.14606 10.883 1.41077C10.3023 1.88992 9.51848 2.75645 8.96575 3.39141C8.9555 3.40319 8.94507 3.41515 8.93448 3.4273C8.78565 3.59799 8.60654 3.80342 8.48601 3.99344C8.35889 4.19385 8.0995 4.68246 8.3656 5.24535C8.63317 5.81135 9.18709 5.93277 9.42259 5.96723C9.6494 6.00041 9.92688 6.00021 10.1591 6.00004C10.3449 5.9999 10.5306 5.99994 10.7163 5.99998Z" fill="white"/>
<path d="M6.20302 7.97913C6.7438 7.86701 7.0913 7.33772 6.97918 6.79694C6.86705 6.25615 6.33777 5.90866 5.79698 6.02078C4.89779 6.20722 4.12408 6.54004 3.48187 7.15175C2.67092 7.92418 2.31966 8.90199 2.15626 10.0596C1.99994 11.167 1.99997 12.5725 2 14.3041V14.4558C1.99997 16.1873 1.99994 17.5928 2.15626 18.7003C2.31966 19.8579 2.67092 20.8357 3.48187 21.6081C4.28597 22.374 5.29233 22.6998 6.48389 22.8524C7.63674 23 9.1038 23 10.9298 23H13.0702C14.8962 23 16.3633 23 17.5161 22.8524C18.7077 22.6998 19.714 22.374 20.5181 21.6081C21.3291 20.8357 21.6803 19.8579 21.8437 18.7003C22.0001 17.5928 22 16.1873 22 14.4558V14.3041C22 12.5725 22.0001 11.167 21.8437 10.0596C21.6803 8.90199 21.3291 7.92418 20.5181 7.15175C19.8759 6.54004 19.1022 6.20722 18.203 6.02078C17.6622 5.90866 17.133 6.25615 17.0208 6.79694C16.9087 7.33772 17.2562 7.86701 17.797 7.97913C18.4468 8.11386 18.8417 8.31705 19.1387 8.59994C19.4993 8.94343 19.7339 9.42162 19.8634 10.3391C19.9977 11.2905 20 12.5542 20 14.3799C20 16.2056 19.9977 17.4694 19.8634 18.4208C19.7339 19.3383 19.4993 19.8164 19.1387 20.1599C18.7713 20.51 18.249 20.7422 17.2621 20.8686C16.2511 20.998 14.9126 21 13 21H11C9.08742 21 7.74887 20.998 6.73794 20.8686C5.75099 20.7422 5.22875 20.51 4.86128 20.1599C4.50066 19.8164 4.26614 19.3383 4.13663 18.4208C4.00234 17.4694 4 16.2056 4 14.3799C4 12.5542 4.00234 11.2905 4.13663 10.3391C4.26614 9.42162 4.50066 8.94343 4.86128 8.59994C5.15828 8.31705 5.55322 8.11386 6.20302 7.97913Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@ -0,0 +1,3 @@
<svg width="21" height="22" viewBox="0 0 21 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 1C0 0.447715 0.447715 0 1 0L1.93845 0C3.31505 0 4.515 0.936891 4.84887 2.27239L4.85349 2.29084L7.31185 14H7.38C7.39152 14 7.40299 14.0002 7.41441 14.0006L13.7632 14.0006C14.7838 14.0006 15.515 13.8399 16.0562 13.5888C16.5861 13.3429 16.9837 12.9875 17.3096 12.5118C18.0068 11.4943 18.3556 9.98374 18.7807 7.8723C18.9056 7.25167 18.9778 6.88274 18.9961 6.61741C19.002 6.53142 18.983 6.52996 18.9117 6.52452L18.884 6.52225C18.7781 6.51262 18.6477 6.5068 18.4816 6.50374C17.9294 6.49357 17.49 6.03769 17.5002 5.4855C17.5103 4.93331 17.9662 4.49391 18.5184 4.50408C18.8759 4.51066 19.258 4.52934 19.6007 4.61974C19.9829 4.72056 20.3604 4.92167 20.641 5.30777C20.9858 5.78217 21.0219 6.31071 20.9914 6.75472C20.963 7.16653 20.8624 7.66613 20.753 8.20908L20.7414 8.26701C20.3387 10.2673 19.9324 12.2223 18.9595 13.6422C18.4504 14.3853 17.7842 14.9917 16.8981 15.4029C16.0234 15.8088 14.9865 16.0006 13.7632 16.0006L6.92903 16.0006C6.34562 16.0177 5.81269 16.4121 5.59895 17L16 17L15.9984 17.0026C15.9982 17.003 15.9979 17.0035 15.9976 17.0039C15.9984 17.0039 15.9992 17.0039 16 17.0039C17.2426 17.0039 18.25 18.0113 18.25 19.2539C18.25 20.4965 17.2426 21.5039 16 21.5039C14.7574 21.5039 13.75 20.4965 13.75 19.2539C13.75 19.1681 13.7548 19.0833 13.7642 19H13.764H11.2358C11.2452 19.0833 11.25 19.1681 11.25 19.2539C11.25 20.4965 10.2426 21.5039 9 21.5039C7.75736 21.5039 6.75 20.4965 6.75 19.2539C6.75 19.1681 6.75481 19.0833 6.76417 19H4.91143C4.09384 19 3.5 18.3215 3.5 17.5714C3.5 16.2195 4.24057 15.0121 5.35392 14.4081L2.90464 2.74218C2.78811 2.30517 2.39205 2 1.93845 2L1 2C0.447715 2 0 1.55228 0 1ZM12.5 1.5C12.5 0.947715 12.0523 0.5 11.5 0.5C10.9477 0.5 10.5 0.947715 10.5 1.5L10.5 4.5L7.5 4.5C6.94772 4.5 6.5 4.94772 6.5 5.5C6.5 6.05228 6.94772 6.5 7.5 6.5L10.5 6.5V9.5C10.5 10.0523 10.9477 10.5 11.5 10.5C12.0523 10.5 12.5 10.0523 12.5 9.5V6.5L15.5 6.5C16.0523 6.5 16.5 6.05228 16.5 5.5C16.5 4.94772 16.0523 4.5 15.5 4.5L12.5 4.5L12.5 1.5ZM8.25 19.2539C8.25 18.8397 8.58579 18.5039 9 18.5039C9.41421 18.5039 9.75 18.8397 9.75 19.2539C9.75 19.6681 9.41421 20.0039 9 20.0039C8.58579 20.0039 8.25 19.6681 8.25 19.2539ZM16 18.5039C15.5858 18.5039 15.25 18.8397 15.25 19.2539C15.25 19.6681 15.5858 20.0039 16 20.0039C16.4142 20.0039 16.75 19.6681 16.75 19.2539C16.75 18.8397 16.4142 18.5039 16 18.5039Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@ -557,7 +557,7 @@
"remeberthat": "Remember that",
"loginToUseService": "You need to login to use this service",
"offersAndPromotions": "OFFERS & SPECIAL PROMOTIONS",
"offers": "OFFERS",
"offers": "Offers",
"myPrescriptions": "MY PRESCRIPTIONS",
"searchAndScanMedication": "SEARCH & SCAN FOR MEDICATION",
"shopByBrands": "Shop by Brands",

@ -4,7 +4,7 @@ import 'package:hmg_patient_app_new/core/enums.dart';
class ApiConsts {
static const maxSmallScreen = 660;
static AppEnvironmentTypeEnum appEnvironmentType = AppEnvironmentTypeEnum.uat;
static AppEnvironmentTypeEnum appEnvironmentType = AppEnvironmentTypeEnum.prod;
// static String baseUrl = 'https://uat.hmgwebservices.com/'; // HIS API URL UAT
@ -689,6 +689,11 @@ var GET_PATIENT_SICK_LEAVE_STATUS = 'Services/Patients.svc/REST/GetPatientSickLe
var GET_SERVICES_PRICE_LIST = 'Services/OUTPs.svc/REST/GetServicesPriceList';
// Offers and Discounts
//TODO: Need to Be Changes Once Apis Provided By Vendor ---- Aamir
var GET_OFFERS_AND_DISCOUNTS = 'Services/Patients.svc/REST/GetOffersAndDiscounts';
var GET_OFFERS_AND_DISCOUNTS_HISTORY = 'Services/Patients.svc/REST/GetOffersAndDiscountsHistory';
var SendSickLeaveEmail = 'Services/Notifications.svc/REST/SendSickLeaveEmail';
var GET_PATIENT_AdVANCE_BALANCE_AMOUNT = 'Services/Patients.svc/REST/GetPatientAdvanceBalanceAmount';

@ -236,6 +236,9 @@ class AppAssets {
static const String h_calc_selected = '$svgBasePath/h_calc_selected.svg';
static const String weatherBottom = '$svgBasePath/weather_bottom.svg';
static const String weatherBottomFill = '$svgBasePath/weather_bottom_fill.svg';
static const String shoppingCart = '$svgBasePath/shoppingcart.svg';
static const String share = '$svgBasePath/share.svg';
static const String nextSwiper = '$svgBasePath/arrow-right-02.svg';
static const String height = '$svgBasePath/height.svg';
static const String weight = '$svgBasePath/weight.svg';

@ -25,6 +25,8 @@ class AppState {
_loadProfileImageFromCache();
}
bool isEnabledOffersAndDiscountsCarousel = false;
double userLat = 0.0;
set setUserLat(v) => userLat = v;

@ -76,6 +76,7 @@ class CacheConst {
static const String hasEnabledQuickLogin = 'has-enabled-quick-login';
static const String quickLoginEnabled = 'quick-login-enabled';
static const String isMonthlyReportEnabled = 'is-monthly-report-enabled';
static const String isShowSymptomCheckerBottomSheet = 'is-show-symptom-checker-bottom-sheet';
static const String zoomRoomID = 'zoom-room-id';
static const String callTypeID = 'call-type-id';

@ -46,11 +46,14 @@ import 'package:hmg_patient_app_new/features/my_invoices/my_invoices_repo.dart';
import 'package:hmg_patient_app_new/features/my_invoices/my_invoices_view_model.dart';
import 'package:hmg_patient_app_new/features/notifications/notifications_repo.dart';
import 'package:hmg_patient_app_new/features/notifications/notifications_view_model.dart';
import 'package:hmg_patient_app_new/features/offers_and_discounts/offers_and_discounts_repo.dart';
import 'package:hmg_patient_app_new/features/offers_and_discounts/offers_and_discounts_view_model.dart';
import 'package:hmg_patient_app_new/features/payfort/payfort_repo.dart';
import 'package:hmg_patient_app_new/features/payfort/payfort_view_model.dart';
import 'package:hmg_patient_app_new/features/paytabs/paytabs_view_model.dart';
import 'package:hmg_patient_app_new/features/prescriptions/prescriptions_repo.dart';
import 'package:hmg_patient_app_new/features/prescriptions/prescriptions_view_model.dart';
import 'package:hmg_patient_app_new/features/profile_picture/profile_picture_view_model.dart';
import 'package:hmg_patient_app_new/features/profile_settings/profile_settings_repo.dart';
import 'package:hmg_patient_app_new/features/profile_settings/profile_settings_view_model.dart';
import 'package:hmg_patient_app_new/features/qr_parking/qr_parking_repo.dart';
@ -79,6 +82,7 @@ import 'package:hmg_patient_app_new/services/localauth_service.dart';
import 'package:hmg_patient_app_new/services/logger_service.dart';
import 'package:hmg_patient_app_new/services/navigation_service.dart';
import 'package:hmg_patient_app_new/services/notification_service.dart';
import 'package:hmg_patient_app_new/services/permission_service.dart';
import 'package:hmg_patient_app_new/widgets/date_range_selector/viewmodel/date_range_calendar_model.dart';
import 'package:hmg_patient_app_new/widgets/date_range_selector/viewmodel/date_range_view_model.dart';
import 'package:local_auth/local_auth.dart';
@ -149,6 +153,8 @@ class AppDependencies {
() => LocalAuthService(loggerService: getIt<LoggerService>(), localAuth: getIt<LocalAuthentication>()),
);
getIt.registerLazySingleton<PermissionService>(() => PermissionService());
// Repositories
getIt.registerLazySingleton<CommonRepo>(() => CommonRepoImp(loggerService: getIt()));
getIt.registerLazySingleton<AuthenticationRepo>(() => AuthenticationRepoImp(loggerService: getIt<LoggerService>(), apiClient: getIt()));
@ -184,6 +190,7 @@ class AppDependencies {
getIt.registerLazySingleton<ServicesPriceListRepo>(() => ServicesPriceListRepoImp(loggerService: getIt<LoggerService>(), apiClient: getIt()));
getIt.registerLazySingleton<ProfileSettingsRepo>(() => ProfileSettingsRepoImp(loggerService: getIt<LoggerService>(), apiClient: getIt()));
getIt.registerLazySingleton<RefundRequestRepo>(() => RefundRequestRepoImp(apiClient: getIt(), loggerService: getIt()));
getIt.registerLazySingleton<OffersAndDiscountsRepo>(() => OffersAndDiscountsRepoImp(loggerService: getIt<LoggerService>(), apiClient: getIt()));
// ViewModels
// Global/shared VMs LazySingleton
@ -256,6 +263,11 @@ class AppDependencies {
errorHandlerService: getIt<ErrorHandlerService>(),
));
getIt.registerLazySingleton<ProfilePictureViewModel>(() => ProfilePictureViewModel(
appState: getIt<AppState>(),
profileSettingsViewModel: getIt<ProfileSettingsViewModel>(),
));
getIt.registerLazySingleton<DateRangeSelectorRangeViewModel>(() => DateRangeSelectorRangeViewModel());
getIt.registerLazySingleton<DoctorFilterViewModel>(() => DoctorFilterViewModel());
@ -339,6 +351,11 @@ class AppDependencies {
getIt.registerLazySingleton<AskDoctorViewModel>(() => AskDoctorViewModel(askDoctorRepo: getIt(), errorHandlerService: getIt()));
getIt.registerLazySingleton<ServicesPriceListViewModel>(() => ServicesPriceListViewModel(servicesPriceListRepo: getIt(), errorHandlerService: getIt()));
getIt.registerLazySingleton<OffersAndDiscountsViewModel>(() => OffersAndDiscountsViewModel(offersAndDiscountsRepo: getIt(), errorHandlerService: getIt()));
getIt.registerLazySingleton<DateRangCalenderModel>(() => DateRangCalenderModel(appState: getIt(), navigationService: getIt(), dialogService: getIt()));
getIt.registerLazySingleton<RefundRequestViewModel>(() => RefundRequestViewModel(
appState: getIt(),
refundRequestRepo: getIt(),

@ -99,29 +99,32 @@ class AuthenticationViewModel extends ChangeNotifier {
// Login screen errors
String? _nationalIdError;
String? get nationalIdError => _nationalIdError;
// Phone number errors (used in multiple screens)
String? _phoneNumberError;
String? get phoneNumberError => _phoneNumberError;
// Registration screen errors
String? _nameError;
String? get nameError => _nameError;
String? _emailError;
String? get emailError => _emailError;
String? _dobError;
String? get dobError => _dobError;
// Check if registration form has any errors (for container border)
bool get hasRegistrationFormError =>
_nationalIdError != null || _dobError != null;
bool get hasRegistrationFormError => _nationalIdError != null || _dobError != null;
// Check if ID and phone have errors (for family file container)
bool get hasIdAndPhoneError =>
_nationalIdError != null || _phoneNumberError != null;
bool get hasIdAndPhoneError => _nationalIdError != null || _phoneNumberError != null;
// Additional field errors for UAE registration step 2
String? _genderError;
@ -130,15 +133,13 @@ class AuthenticationViewModel extends ChangeNotifier {
// Getters for step 2 field errors (nameError and emailError already exist above)
String? get genderError => _genderError;
String? get maritalStatusError => _maritalStatusError;
String? get countryError => _countryError;
// Check if registration step 2 form has any errors (for container border)
bool get hasRegistrationStep2FormError =>
_nameError != null ||
_genderError != null ||
_maritalStatusError != null ||
_countryError != null;
bool get hasRegistrationStep2FormError => _nameError != null || _genderError != null || _maritalStatusError != null || _countryError != null;
// Clear all step 2 field errors
void clearAllStep2FieldErrors() {
@ -349,7 +350,7 @@ class AuthenticationViewModel extends ChangeNotifier {
if (nationalIdController.text.isEmpty) {
_nationalIdError = LocaleKeys.pleaseEnterAnationalID.tr();
notifyListeners();
return false; // Stop here, don't check phone yet
return false; // Stop here, don't check phone yet
}
// Step 2: Validate National ID format
@ -359,7 +360,7 @@ class AuthenticationViewModel extends ChangeNotifier {
if (!Utils.isSAUDIIDValid(cleanedId)) {
_nationalIdError = LocaleKeys.enterValidNationalId.tr();
notifyListeners();
return false; // Stop here
return false; // Stop here
}
}
@ -368,13 +369,13 @@ class AuthenticationViewModel extends ChangeNotifier {
if (!ValidationUtils.validateIqama(nationalIdController.text)) {
_nationalIdError = LocaleKeys.pleaseEnterAValidIqamaID.tr();
notifyListeners();
return false; // Stop here
return false; // Stop here
}
} else if (selectedCountrySignup == CountryEnum.unitedArabEmirates) {
if (!ValidationUtils.validateUaeNationalId(nationalIdController.text)) {
_nationalIdError = LocaleKeys.pleaseEnterAValidNationalID.tr();
notifyListeners();
return false; // Stop here
return false; // Stop here
}
}
@ -382,7 +383,7 @@ class AuthenticationViewModel extends ChangeNotifier {
if (phoneNumberController.text.isEmpty) {
_phoneNumberError = LocaleKeys.enterValidPhoneNumber.tr();
notifyListeners();
return false; // Stop here
return false; // Stop here
}
// Step 5: Validate phone number format based on country
@ -412,7 +413,7 @@ class AuthenticationViewModel extends ChangeNotifier {
if (nationalIdController.text.isEmpty) {
_nationalIdError = LocaleKeys.pleaseEnterAnationalID.tr();
notifyListeners();
return false; // Stop here, don't check other fields
return false; // Stop here, don't check other fields
}
// Step 2: Validate National ID format
@ -422,7 +423,7 @@ class AuthenticationViewModel extends ChangeNotifier {
if (!Utils.isSAUDIIDValid(cleanedId)) {
_nationalIdError = LocaleKeys.enterValidNationalId.tr();
notifyListeners();
return false; // Stop here
return false; // Stop here
}
}
@ -431,13 +432,13 @@ class AuthenticationViewModel extends ChangeNotifier {
if (!ValidationUtils.validateIqama(nationalIdController.text)) {
_nationalIdError = LocaleKeys.pleaseEnterAValidIqamaID.tr();
notifyListeners();
return false; // Stop here
return false; // Stop here
}
} else if (selectedCountrySignup == CountryEnum.unitedArabEmirates) {
if (!ValidationUtils.validateUaeNationalId(nationalIdController.text)) {
_nationalIdError = LocaleKeys.pleaseEnterAValidNationalID.tr();
notifyListeners();
return false; // Stop here
return false; // Stop here
}
}
@ -445,7 +446,7 @@ class AuthenticationViewModel extends ChangeNotifier {
if (dobController.text.isEmpty || dob == null || dob!.isEmpty) {
_dobError = LocaleKeys.pleaseEnterAValidDateOfBirth.tr();
notifyListeners();
return false; // Stop here
return false; // Stop here
}
// Step 5: Only validate Terms if both National ID and DOB are valid
@ -458,7 +459,7 @@ class AuthenticationViewModel extends ChangeNotifier {
},
);
notifyListeners();
return false; // Stop here
return false; // Stop here
}
// All validations passed
@ -481,11 +482,27 @@ class AuthenticationViewModel extends ChangeNotifier {
_countryError = null;
// Step 1: Validate name (only for UAE users)
// if (isUserFromUAE()) {
// if (nameController.text.trim().isEmpty) {
// _nameError = isArabic ? "الرجاء إدخال الاسم الكامل" : "Please enter full name";
// notifyListeners();
// return false; // Stop here
// }
// }
if (isUserFromUAE()) {
if (nameController.text.trim().isEmpty) {
// Remove extra spaces between words
final fullName = nameController.text.trim().replaceAll(RegExp(r'\s+'), ' ');
// Split into words
final nameParts = fullName.split(' ');
// Require at least 2 words
if (nameParts.length < 2) {
_nameError = isArabic ? "الرجاء إدخال الاسم الكامل" : "Please enter full name";
notifyListeners();
return false; // Stop here
return false;
}
}
@ -603,7 +620,6 @@ class AuthenticationViewModel extends ChangeNotifier {
// Format for display (use Hijri)
dobController.text = Utils.formatHijriDateToDisplay(hijriDateTimeForController.toIso8601String());
} else {
// Gregorian calendar mode
// Validate the date can be parsed
@ -624,7 +640,6 @@ class AuthenticationViewModel extends ChangeNotifier {
clearDobError();
notifyListeners();
} catch (e, stackTrace) {
debugPrint('onDobChange: Unexpected error processing date "$date" - $e');
debugPrint('Stack trace: $stackTrace');
@ -768,7 +783,7 @@ class AuthenticationViewModel extends ChangeNotifier {
},
(apiResponse) {
// LoadingUtils.hideFullScreenLoader();
log("apiResponse: ${apiResponse.data.toString()}");
log("apiResponse: ${apiResponse.data?.toJson().toString()}");
log("messageStatus: ${apiResponse.messageStatus.toString()}");
if (apiResponse.messageStatus == 1) {
onSuccess(apiResponse.data);
@ -856,6 +871,9 @@ class AuthenticationViewModel extends ChangeNotifier {
nationId: nationalIdController.text,
isForRegister: false,
patientOutSA: false,
// patientOutSA: selectedCountrySignup == CountryEnum.others
// ? false
// : (_appState.getSelectDeviceByImeiRespModelElement != null && _appState.getSelectDeviceByImeiRespModelElement!.outSa == true ? true : false),
otpTypeEnum: otpTypeEnum,
patientId: 0,
zipCode: selectedCountrySignup == CountryEnum.others
@ -889,7 +907,10 @@ class AuthenticationViewModel extends ChangeNotifier {
} else if (apiResponse.messageStatus == 1) {
if (apiResponse.data['isSMSSent']) {
_appState.setAppAuthToken = apiResponse.data['LogInTokenID'];
await sendActivationCode(otpTypeEnum: otpTypeEnum, phoneNumber: phoneNumberController.text, nationalIdOrFileNumber: nationalIdController.text, isForRegister: false);
print("============================");
var zipcode = getZipCode();
print("======== Zip Code =========== $zipcode ========");
await sendActivationCode(otpTypeEnum: otpTypeEnum, phoneNumber: phoneNumberController.text, nationalIdOrFileNumber: nationalIdController.text, isForRegister: false, zipCode: zipcode);
} else {
if (apiResponse.data['IsAuthenticated']) {
await checkActivationCode(
@ -911,6 +932,14 @@ class AuthenticationViewModel extends ChangeNotifier {
);
}
String getZipCode() {
return selectedCountrySignup == CountryEnum.others
? "0"
: (_appState.getSelectDeviceByImeiRespModelElement != null && _appState.getSelectDeviceByImeiRespModelElement!.outSa == true
? CountryEnum.unitedArabEmirates.countryCode.toString()
: selectedCountrySignup.countryCode.toString());
}
Future<void> sendActivationCode(
{required OTPTypeEnum otpTypeEnum,
required String nationalIdOrFileNumber,
@ -921,12 +950,13 @@ class AuthenticationViewModel extends ChangeNotifier {
bool isExcludedUser = false,
bool isFormFamilyFile = false,
bool isNeedLoading = false,
int? responseID}) async {
int? responseID,
String? zipCode}) async {
var request = RequestUtils.getCommonRequestSendActivationCode(
otpTypeEnum: otpTypeEnum,
mobileNumber: phoneNumber,
selectedLoginType: otpTypeEnum.toInt(),
zipCode: selectedCountrySignup.countryCode,
zipCode: zipCode ?? selectedCountrySignup.countryCode,
nationalId: nationalIdOrFileNumber,
isFileNo: isForRegister ? isPatientHasFile(request: payload) : false,
patientId: isFormFamilyFile ? _appState.getAuthenticatedUser()!.patientId : 0,
@ -983,6 +1013,7 @@ class AuthenticationViewModel extends ChangeNotifier {
navigateToOTPScreen(
otpTypeEnum: otpTypeEnum,
phoneNumber: phoneNumber,
zipCode: zipCode ?? "",
isComingFromRegister: checkIsUserComingForRegister(request: payload),
payload: payload,
isFormFamilyFile: isFormFamilyFile,
@ -1308,10 +1339,12 @@ class AuthenticationViewModel extends ChangeNotifier {
bool isFormFamilyFile = false,
bool isExcludedUser = false,
int? responseID,
int? patientShareRequestID}) async {
int? patientShareRequestID,
required String zipCode}) async {
_navigationService.pushToOtpScreen(
phoneNumber: phoneNumber,
isFormFamilyFile: isFormFamilyFile,
zipCode: zipCode,
checkActivationCode: (int activationCode) async {
await checkActivationCode(
activationCode: activationCode.toString(),
@ -1324,18 +1357,18 @@ class AuthenticationViewModel extends ChangeNotifier {
},
);
},
onResendOTPPressed: (String phoneNumber) async {
onResendOTPPressed: (String phoneNumber, String zipCode) async {
await sendActivationCode(
otpTypeEnum: otpTypeEnum,
phoneNumber: phoneNumberController.text,
nationalIdOrFileNumber: nationalIdController.text,
isForRegister: isComingFromRegister,
isComingFromResendOTP: true,
payload: payload,
isFormFamilyFile: isFormFamilyFile,
isExcludedUser: isExcludedUser,
responseID: responseID,
);
otpTypeEnum: otpTypeEnum,
phoneNumber: phoneNumberController.text,
nationalIdOrFileNumber: nationalIdController.text,
isForRegister: isComingFromRegister,
isComingFromResendOTP: true,
payload: payload,
isFormFamilyFile: isFormFamilyFile,
isExcludedUser: isExcludedUser,
responseID: responseID,
zipCode: zipCode);
},
);
}

@ -427,12 +427,12 @@ class OTPWidgetState extends State<OTPWidget> with SingleTickerProviderStateMixi
class OTPVerificationScreen extends StatefulWidget {
final String phoneNumber;
final String zipCode;
final Function(int code) checkActivationCode;
final Function(String phoneNumber) onResendOTPPressed;
final Function(String phoneNumber, String zipCode) onResendOTPPressed;
final bool isFormFamilyFile;
const OTPVerificationScreen(
{super.key, required this.phoneNumber, required this.checkActivationCode, required this.onResendOTPPressed, required this.isFormFamilyFile});
const OTPVerificationScreen({super.key, required this.phoneNumber, required this.zipCode, required this.checkActivationCode, required this.onResendOTPPressed, required this.isFormFamilyFile});
@override
State<OTPVerificationScreen> createState() => _OTPVerificationScreenState();
@ -452,7 +452,7 @@ class _OTPVerificationScreenState extends State<OTPVerificationScreen> {
super.initState();
_otpController = TextEditingController();
_startResendTimer();
if(Platform.isAndroid) {
if (Platform.isAndroid) {
checkSignature();
}
}
@ -521,7 +521,7 @@ class _OTPVerificationScreenState extends State<OTPVerificationScreen> {
});
_otpController.clear();
_startResendTimer();
widget.onResendOTPPressed(widget.phoneNumber);
widget.onResendOTPPressed(widget.phoneNumber, widget.zipCode);
}
}
@ -584,12 +584,7 @@ class _OTPVerificationScreenState extends State<OTPVerificationScreen> {
pinBoxColor: AppColors.whiteColor,
autoFocus: true,
onTextChanged: _onOtpChanged,
pinTextStyle: TextStyle(
fontSize: 40.f,
fontWeight: FontWeight.bold,
color: AppColors.whiteColor,
fontFamily: "Poppins"
),
pinTextStyle: TextStyle(fontSize: 40.f, fontWeight: FontWeight.bold, color: AppColors.whiteColor, fontFamily: "Poppins"),
),
),
),

@ -781,6 +781,7 @@ class HmgServicesViewModel extends ChangeNotifier {
navigationService.pushToOtpScreen(
phoneNumber: phoneNumber,
isFormFamilyFile: false,
zipCode: "",
checkActivationCode: (int activationCode) async {
checkEReferralActivationCode(
requestModel: CheckActivationCodeForEReferralRequestModel(
@ -795,7 +796,7 @@ class HmgServicesViewModel extends ChangeNotifier {
},
);
},
onResendOTPPressed: (String phoneNumber) async {
onResendOTPPressed: (String phoneNumber, String zipCode) async {
// await sendActivationCode(
// otpTypeEnum: otpTypeEnum,
// phoneNumber: phoneNumberController.text,

@ -805,8 +805,13 @@ class MedicalFileViewModel extends ChangeNotifier {
}
if (updated) {
// Create new list instances to trigger Selector rebuild
patientFamilyFiles = List.from(patientFamilyFiles);
pendingFamilyFiles = List.from(pendingFamilyFiles);
// Notify listeners to update UI
notifyListeners();
print("🔄 Created new list instances and notified listeners");
} else {
print("⚠️ Family member not found in cache for patientID: $patientID");
}

@ -324,7 +324,7 @@ class MyAppointmentsViewModel extends ChangeNotifier {
// if (patientArrivedAppointmentsHistoryList.isNotEmpty) {
isPatientHasQueueAppointment = false;
notifyListeners();
if(patientArrivedAppointmentsHistoryList.isNotEmpty) {
if (patientArrivedAppointmentsHistoryList.isNotEmpty) {
if (Utils.isDateToday(DateUtil.convertStringToDate(patientArrivedAppointmentsHistoryList.first.appointmentDate))) {
// getPatientAppointmentQueueDetails(appointmentNo: patientArrivedAppointmentsHistoryList.first.appointmentNo, patientID: patientArrivedAppointmentsHistoryList.first.patientID);
getPatientAppointmentQueueDetails();
@ -617,7 +617,12 @@ class MyAppointmentsViewModel extends ChangeNotifier {
patientType: patientType);
result.fold(
(failure) async => await errorHandlerService.handleError(failure: failure),
// (failure) async => await errorHandlerService.handleError(failure: failure),
(failure) async {
if (onError != null) {
onError(failure.message);
}
},
(apiResponse) {
if (apiResponse.messageStatus == 2) {
// dialogService.showErrorDialog(message: apiResponse.errorMessage!, onOkPressed: () {});
@ -943,7 +948,8 @@ class MyAppointmentsViewModel extends ChangeNotifier {
final result = await myAppointmentsRepo.getPatientAppointmentQueueDetails(
clinicID: patientArrivedAppointmentsHistoryList.first.clinicID,
appointmentNo: patientArrivedAppointmentsHistoryList.first.appointmentNo, patientID: patientArrivedAppointmentsHistoryList.first.patientID);
appointmentNo: patientArrivedAppointmentsHistoryList.first.appointmentNo,
patientID: patientArrivedAppointmentsHistoryList.first.patientID);
isAppointmentQueueDetailsLoading = false;
@ -1198,7 +1204,8 @@ class MyAppointmentsViewModel extends ChangeNotifier {
switch (reminderType) {
case ReminderType.appointment:
eventTitle = "Appointment Reminder with ${patientAppointmentHistoryResponseModel.doctorNameObj} on ${DateUtil.convertStringToDate(patientAppointmentHistoryResponseModel.appointmentDate)}, Appointment #${patientAppointmentHistoryResponseModel.appointmentNo}";
eventTitle =
"Appointment Reminder with ${patientAppointmentHistoryResponseModel.doctorNameObj} on ${DateUtil.convertStringToDate(patientAppointmentHistoryResponseModel.appointmentDate)}, Appointment #${patientAppointmentHistoryResponseModel.appointmentNo}";
eventDescription = "Appointment Reminder with ${patientAppointmentHistoryResponseModel.doctorNameObj} in ${patientAppointmentHistoryResponseModel.projectName}";
break;
case ReminderType.payment:
@ -1431,7 +1438,8 @@ class MyAppointmentsViewModel extends ChangeNotifier {
switch (reminderType) {
case ReminderType.appointment:
eventTitle = "Appointment Reminder with ${patientAppointmentHistoryResponseModel.doctorNameObj} on ${DateUtil.convertStringToDate(patientAppointmentHistoryResponseModel.appointmentDate)}, Appointment #${patientAppointmentHistoryResponseModel.appointmentNo}";
eventTitle =
"Appointment Reminder with ${patientAppointmentHistoryResponseModel.doctorNameObj} on ${DateUtil.convertStringToDate(patientAppointmentHistoryResponseModel.appointmentDate)}, Appointment #${patientAppointmentHistoryResponseModel.appointmentNo}";
eventDescription = "Appointment Reminder with ${patientAppointmentHistoryResponseModel.doctorNameObj} in ${patientAppointmentHistoryResponseModel.projectName}";
break;
case ReminderType.payment:
@ -1502,7 +1510,7 @@ class MyAppointmentsViewModel extends ChangeNotifier {
required BuildContext context,
required bool shouldCreateReminder,
required PatientAppointmentHistoryResponseModel patientAppointmentHistoryResponseModel,
required ReminderType reminderType ,
required ReminderType reminderType,
Function(bool)? onSuccess,
Function(String)? onError,
}) async {

@ -0,0 +1,52 @@
class OffersAndDiscountsResponseModel {
int? id;
String? title;
String? description;
String? imageUrl;
String? facilityType; // HMC, HMG, or Both
String? startDate;
String? endDate;
String? discount;
bool? isActive;
OffersAndDiscountsResponseModel({
this.id,
this.title,
this.description,
this.imageUrl,
this.facilityType,
this.startDate,
this.endDate,
this.discount,
this.isActive,
});
factory OffersAndDiscountsResponseModel.fromJson(Map<String, dynamic> json) {
return OffersAndDiscountsResponseModel(
id: json['ID'] as int?,
title: json['Title'] as String?,
description: json['Description'] as String?,
imageUrl: json['ImageURL'] as String?,
facilityType: json['FacilityType'] as String?,
startDate: json['StartDate'] as String?,
endDate: json['EndDate'] as String?,
discount: json['Discount'] as String?,
isActive: json['IsActive'] as bool?,
);
}
Map<String, dynamic> toJson() {
return {
'ID': id,
'Title': title,
'Description': description,
'ImageURL': imageUrl,
'FacilityType': facilityType,
'StartDate': startDate,
'EndDate': endDate,
'Discount': discount,
'IsActive': isActive,
};
}
}

@ -0,0 +1,102 @@
import 'package:dartz/dartz.dart';
import 'package:hmg_patient_app_new/core/api/api_client.dart';
import 'package:hmg_patient_app_new/core/api_consts.dart';
import 'package:hmg_patient_app_new/core/common_models/generic_api_model.dart';
import 'package:hmg_patient_app_new/core/exceptions/api_failure.dart';
import 'package:hmg_patient_app_new/features/offers_and_discounts/models/offers_and_discounts_response_model.dart';
import 'package:hmg_patient_app_new/services/logger_service.dart';
abstract class OffersAndDiscountsRepo {
Future<Either<Failure, GenericApiModel<List<OffersAndDiscountsResponseModel>>>> getOffersAndDiscounts();
Future<Either<Failure, GenericApiModel<List<OffersAndDiscountsResponseModel>>>> getOffersAndDiscountsHistory();
}
class OffersAndDiscountsRepoImp implements OffersAndDiscountsRepo {
final ApiClient apiClient;
final LoggerService loggerService;
OffersAndDiscountsRepoImp({required this.loggerService, required this.apiClient});
@override
Future<Either<Failure, GenericApiModel<List<OffersAndDiscountsResponseModel>>>> getOffersAndDiscounts() async {
Map<String, dynamic> mapDevice = {};
try {
GenericApiModel<List<OffersAndDiscountsResponseModel>>? apiResponse;
Failure? failure;
await apiClient.post(
GET_OFFERS_AND_DISCOUNTS, // TODO: Replace with actual API endpoint
body: mapDevice,
onFailure: (error, statusCode, {messageStatus, failureType}) {
failure = failureType;
},
onSuccess: (response, statusCode, {messageStatus, errorMessage}) {
try {
final list = response['List_OffersAndDiscounts'];
if (list == null || list.isEmpty) {
throw Exception("offers and discounts list is empty");
}
final offers = list.map((item) => OffersAndDiscountsResponseModel.fromJson(item as Map<String, dynamic>)).toList().cast<OffersAndDiscountsResponseModel>();
apiResponse = GenericApiModel<List<OffersAndDiscountsResponseModel>>(
messageStatus: messageStatus,
statusCode: statusCode,
errorMessage: null,
data: offers,
);
} catch (e) {
failure = DataParsingFailure(e.toString());
}
},
);
if (failure != null) return Left(failure!);
if (apiResponse == null) return Left(ServerFailure("Unknown error"));
return Right(apiResponse!);
} catch (e) {
return Left(UnknownFailure(e.toString()));
}
}
@override
Future<Either<Failure, GenericApiModel<List<OffersAndDiscountsResponseModel>>>> getOffersAndDiscountsHistory() async {
Map<String, dynamic> mapDevice = {};
try {
GenericApiModel<List<OffersAndDiscountsResponseModel>>? apiResponse;
Failure? failure;
await apiClient.post(
GET_OFFERS_AND_DISCOUNTS_HISTORY, // TODO: Replace with actual API endpoint
body: mapDevice,
onFailure: (error, statusCode, {messageStatus, failureType}) {
failure = failureType;
},
onSuccess: (response, statusCode, {messageStatus, errorMessage}) {
try {
final list = response['List_OffersAndDiscountsHistory'];
if (list == null || list.isEmpty) {
throw Exception("offers and discounts history list is empty");
}
final historyOffers = list.map((item) => OffersAndDiscountsResponseModel.fromJson(item as Map<String, dynamic>)).toList().cast<OffersAndDiscountsResponseModel>();
apiResponse = GenericApiModel<List<OffersAndDiscountsResponseModel>>(
messageStatus: messageStatus,
statusCode: statusCode,
errorMessage: null,
data: historyOffers,
);
} catch (e) {
failure = DataParsingFailure(e.toString());
}
},
);
if (failure != null) return Left(failure!);
if (apiResponse == null) return Left(ServerFailure("Unknown error"));
return Right(apiResponse!);
} catch (e) {
return Left(UnknownFailure(e.toString()));
}
}
}

@ -0,0 +1,449 @@
import 'package:flutter/material.dart';
import 'package:hmg_patient_app_new/features/offers_and_discounts/models/offers_and_discounts_response_model.dart';
import 'package:hmg_patient_app_new/features/offers_and_discounts/offers_and_discounts_repo.dart';
import 'package:hmg_patient_app_new/services/error_handler_service.dart';
class OffersAndDiscountsViewModel extends ChangeNotifier {
bool isOffersLoading = false;
bool isHistoryLoading = false;
List<String> selectedFacilities = ['All Offers']; // Default to 'All Offers'
String searchQuery = '';
OffersAndDiscountsRepo offersAndDiscountsRepo;
ErrorHandlerService errorHandlerService;
List<OffersAndDiscountsResponseModel> offersList = [];
List<OffersAndDiscountsResponseModel> historyList = [];
OffersAndDiscountsViewModel({
required this.offersAndDiscountsRepo,
required this.errorHandlerService,
}) {
_initializeDummyData();
}
// Initialize with dummy data for testing
void _initializeDummyData() {
offersList = [
OffersAndDiscountsResponseModel(
id: 1,
title: 'Summer Health Checkup Package',
description: 'Complete health screening with blood tests, X-ray, and consultation. Get 30% off on all diagnostic services.',
imageUrl: 'https://images.unsplash.com/photo-1576091160399-112ba8d25d1d?w=800',
facilityType: 'Female',
startDate: '2026-05-01',
endDate: '2026-08-31',
discount: '30% OFF',
isActive: true,
),
OffersAndDiscountsResponseModel(
id: 2,
title: 'Maternity Care Special',
description: 'Comprehensive prenatal care package including ultrasound, consultations, and postnatal support.',
imageUrl: 'https://images.unsplash.com/photo-1584820927498-cfe5211fd8bf?w=800',
facilityType: 'OB-Gyne',
startDate: '2026-05-01',
endDate: '2026-12-31',
discount: '25% OFF',
isActive: true,
),
OffersAndDiscountsResponseModel(
id: 3,
title: 'Skin Treatment Package',
description: 'Advanced dermatology services including acne treatment, anti-aging procedures, and skin rejuvenation.',
imageUrl: 'https://images.unsplash.com/photo-1570172619644-dfd03ed5d881?w=800',
facilityType: 'Dermatology',
startDate: '2026-05-15',
endDate: '2026-07-15',
discount: '40% OFF',
isActive: true,
),
OffersAndDiscountsResponseModel(
id: 4,
title: 'CT & MRI Scanning Discount',
description: 'State-of-the-art imaging services with latest technology. Book your scan today!',
imageUrl: 'https://images.unsplash.com/photo-1516549655169-df83a0774514?w=800',
facilityType: 'Radiology',
startDate: '2026-05-01',
endDate: '2026-06-30',
discount: '20% OFF',
isActive: true,
),
OffersAndDiscountsResponseModel(
id: 5,
title: 'Women\'s Health Screening',
description: 'Comprehensive health package designed specifically for women including mammography, bone density scan, and hormone tests.',
imageUrl: 'https://images.unsplash.com/photo-1559757175-5700dde675bc?w=800',
facilityType: 'Female',
startDate: '2026-05-10',
endDate: '2026-09-30',
discount: '35% OFF',
isActive: true,
),
OffersAndDiscountsResponseModel(
id: 6,
title: 'Gynecology Consultation Special',
description: 'Free follow-up consultation with every initial gynecology visit. Expert care for all women\'s health needs.',
imageUrl: 'https://images.unsplash.com/photo-1631217868264-e5b90bb7e133?w=800',
facilityType: 'OB-Gyne',
startDate: '2026-05-01',
endDate: '2026-07-31',
discount: 'FREE Follow-up',
isActive: true,
),
OffersAndDiscountsResponseModel(
id: 7,
title: 'Laser Hair Removal Package',
description: 'Professional laser hair removal treatment with latest technology. Multiple sessions available.',
imageUrl: 'https://images.unsplash.com/photo-1612349317150-e413f6a5b16d?w=800',
facilityType: 'Dermatology',
startDate: '2026-05-20',
endDate: '2026-10-20',
discount: '50% OFF',
isActive: true,
),
OffersAndDiscountsResponseModel(
id: 8,
title: 'X-Ray & Ultrasound Combo',
description: 'Get both X-ray and ultrasound services at a discounted price. Quick results guaranteed.',
imageUrl: 'https://images.unsplash.com/photo-1530497610245-94d3c16cda28?w=800',
facilityType: 'Radiology',
startDate: '2026-05-01',
endDate: '2026-08-15',
discount: '15% OFF',
isActive: true,
),
OffersAndDiscountsResponseModel(
id: 9,
title: 'Complete Wellness Package',
description: 'Full body checkup including all major tests, consultations, and health assessment report.',
imageUrl: 'https://images.unsplash.com/photo-1505751172876-fa1923c5c528?w=800',
facilityType: 'Both',
startDate: '2026-05-01',
endDate: '2026-12-31',
discount: '45% OFF',
isActive: true,
),
OffersAndDiscountsResponseModel(
id: 10,
title: 'Botox & Filler Treatment',
description: 'Anti-aging treatments with certified dermatologists. Natural-looking results guaranteed.',
imageUrl: 'https://images.unsplash.com/photo-1515377905703-c4788e51af15?w=800',
facilityType: 'Dermatology',
startDate: '2026-05-15',
endDate: '2026-06-15',
discount: '30% OFF',
isActive: true,
),
];
// Past offers for history
historyList = [
OffersAndDiscountsResponseModel(
id: 101,
title: 'Winter Health Campaign',
description: 'Flu shots and winter wellness packages that were offered during the winter season.',
imageUrl: 'https://images.unsplash.com/photo-1584308666744-24d5c474f2ae?w=800',
facilityType: 'Both',
startDate: '2025-12-01',
endDate: '2026-02-28',
discount: '25% OFF',
isActive: false,
),
OffersAndDiscountsResponseModel(
id: 102,
title: 'Valentine\'s Day Couple Checkup',
description: 'Special couple health screening packages offered for Valentine\'s Day.',
imageUrl: 'https://images.unsplash.com/photo-1516549655169-df83a0774514?w=800',
facilityType: 'Both',
startDate: '2026-02-01',
endDate: '2026-02-14',
discount: '40% OFF',
isActive: false,
),
OffersAndDiscountsResponseModel(
id: 103,
title: 'Spring Skin Renewal',
description: 'Spring special dermatology treatments for skin rejuvenation.',
imageUrl: 'https://images.unsplash.com/photo-1556228720-195a672e8a03?w=800',
facilityType: 'Dermatology',
startDate: '2026-03-01',
endDate: '2026-04-30',
discount: '30% OFF',
isActive: false,
),
OffersAndDiscountsResponseModel(
id: 104,
title: 'Summer Wellness Package - Active',
description: 'Active offer for comprehensive summer health checkup with multiple tests.',
imageUrl: 'https://images.unsplash.com/photo-1576091160399-112ba8d25d1d?w=800',
facilityType: 'Female',
startDate: '2026-05-01',
endDate: '2026-08-31',
discount: '35% OFF',
isActive: true,
),
OffersAndDiscountsResponseModel(
id: 105,
title: 'Kids Health Screening - Active',
description: 'Currently active pediatric health screening package for children.',
imageUrl: 'https://images.unsplash.com/photo-1559839734-2b71ea197ec2?w=800',
facilityType: 'Both',
startDate: '2026-05-05',
endDate: '2026-09-30',
discount: '20% OFF',
isActive: true,
),
OffersAndDiscountsResponseModel(
id: 106,
title: 'Laser Treatment Offer - Expired',
description: 'Laser hair removal package that has expired.',
imageUrl: 'https://images.unsplash.com/photo-1612349317150-e413f6a5b16d?w=800',
facilityType: 'Dermatology',
startDate: '2026-01-01',
endDate: '2026-03-31',
discount: '50% OFF',
isActive: false,
),
OffersAndDiscountsResponseModel(
id: 107,
title: 'Cardiology Consultation Package',
description: 'Heart health checkup with ECG and consultation.',
imageUrl: 'https://images.unsplash.com/photo-1628348068343-c6a848d2b6dd?w=800',
facilityType: 'Both',
startDate: '2025-11-01',
endDate: '2026-01-31',
discount: '25% OFF',
isActive: false,
),
OffersAndDiscountsResponseModel(
id: 108,
title: 'Dental Care Special - Active',
description: 'Comprehensive dental checkup and cleaning package.',
imageUrl: 'https://images.unsplash.com/photo-1606811971618-4486d14f3f99?w=800',
facilityType: 'Both',
startDate: '2026-04-01',
endDate: '2026-12-31',
discount: '30% OFF',
isActive: true,
),
OffersAndDiscountsResponseModel(
id: 109,
title: 'Eye Care Package - Expired',
description: 'Complete eye examination with vision testing.',
imageUrl: 'https://images.unsplash.com/photo-1516534775068-ba3e7458af70?w=800',
facilityType: 'Both',
startDate: '2025-10-01',
endDate: '2025-12-31',
discount: '20% OFF',
isActive: false,
),
OffersAndDiscountsResponseModel(
id: 110,
title: 'Maternity Care Premium',
description: 'Premium prenatal and postnatal care package.',
imageUrl: 'https://images.unsplash.com/photo-1584820927498-cfe5211fd8bf?w=800',
facilityType: 'OB-Gyne',
startDate: '2026-01-15',
endDate: '2026-06-30',
discount: '40% OFF',
isActive: true,
),
OffersAndDiscountsResponseModel(
id: 111,
title: 'Blood Test Special - Expired',
description: 'Comprehensive blood work panel at discounted rates.',
imageUrl: 'https://images.unsplash.com/photo-1579154204601-01588f351e67?w=800',
facilityType: 'Both',
startDate: '2025-09-01',
endDate: '2025-11-30',
discount: '15% OFF',
isActive: false,
),
OffersAndDiscountsResponseModel(
id: 112,
title: 'Vaccination Drive',
description: 'Special vaccination package for adults and children.',
imageUrl: 'https://images.unsplash.com/photo-1587854692152-cbe660dbde88?w=800',
facilityType: 'Both',
startDate: '2026-03-01',
endDate: '2026-05-05',
discount: '10% OFF',
isActive: false,
),
OffersAndDiscountsResponseModel(
id: 113,
title: 'Radiology Imaging Package - Active',
description: 'CT, MRI, and X-ray services at reduced prices.',
imageUrl: 'https://images.unsplash.com/photo-1516549655169-df83a0774514?w=800',
facilityType: 'Radiology',
startDate: '2026-05-01',
endDate: '2026-10-31',
discount: '25% OFF',
isActive: false, // Changed to false - will show as "Availed"
),
OffersAndDiscountsResponseModel(
id: 114,
title: 'Physiotherapy Sessions',
description: 'Package of 10 physiotherapy sessions.',
imageUrl: 'https://images.unsplash.com/photo-1576091160550-2173dba999ef?w=800',
facilityType: 'Both',
startDate: '2025-12-01',
endDate: '2026-04-01',
discount: '35% OFF',
isActive: false,
),
OffersAndDiscountsResponseModel(
id: 115,
title: 'Nutrition Consultation Bundle - Active',
description: 'Series of nutrition and diet consultation sessions.',
imageUrl: 'https://images.unsplash.com/photo-1490645935967-10de6ba17061?w=800',
facilityType: 'Both',
startDate: '2026-05-01',
endDate: '2026-11-30',
discount: '20% OFF',
isActive: true,
),
];
}
// For loading state compatibility with existing code
bool get isRegionListLoading => isOffersLoading;
initOffersAndDiscounts() {
// Don't clear the list - keep dummy data until API returns
// Don't show loading state since we have dummy data
// offersList.clear();
// isOffersLoading = true;
notifyListeners();
getOffersAndDiscounts();
}
setIsOffersLoading(bool val) {
isOffersLoading = val;
notifyListeners();
}
setIsHistoryLoading(bool val) {
isHistoryLoading = val;
notifyListeners();
}
setSelectedFacility(List<String> facilities) {
selectedFacilities = facilities;
notifyListeners();
}
setSearchQuery(String query) {
searchQuery = query;
notifyListeners();
}
Future<void> getOffersAndDiscounts({Function(dynamic)? onSuccess, Function(String)? onError}) async {
final result = await offersAndDiscountsRepo.getOffersAndDiscounts();
result.fold(
(failure) async {
isOffersLoading = false;
notifyListeners();
// Keep dummy data if API fails - don't clear the list
// await errorHandlerService.handleError(failure: failure);
if (onError != null) {
onError(failure.toString());
}
},
(response) {
isOffersLoading = false;
if (response.data != null && response.data!.isNotEmpty) {
offersList = response.data!;
}
// If API returns empty or null, keep the dummy data
notifyListeners();
if (onSuccess != null) {
onSuccess(response);
}
},
);
}
Future<void> getOffersAndDiscountsHistory({Function(dynamic)? onSuccess, Function(String)? onError}) async {
isHistoryLoading = true;
notifyListeners();
final result = await offersAndDiscountsRepo.getOffersAndDiscountsHistory();
result.fold(
(failure) async {
isHistoryLoading = false;
notifyListeners();
// Keep dummy data if API fails - don't clear the list
// await errorHandlerService.handleError(failure: failure);
if (onError != null) {
onError(failure.toString());
}
},
(response) {
isHistoryLoading = false;
if (response.data != null && response.data!.isNotEmpty) {
historyList = response.data!;
}
// If API returns empty or null, keep the dummy data
notifyListeners();
if (onSuccess != null) {
onSuccess(response);
}
},
);
}
List<OffersAndDiscountsResponseModel> get filteredOffers {
List<OffersAndDiscountsResponseModel> filtered = offersList;
// Filter by facility types (multi-select support)
if (!selectedFacilities.contains('All Offers')) {
filtered = filtered.where((offer) {
// Check if offer's facility type matches any of the selected types
return selectedFacilities.any((selectedType) =>
offer.facilityType == selectedType || offer.facilityType == 'Both'
);
}).toList();
}
// Filter by search query
if (searchQuery.isNotEmpty) {
filtered = filtered.where((offer) {
final titleMatch = offer.title?.toLowerCase().contains(searchQuery.toLowerCase()) ?? false;
final descriptionMatch = offer.description?.toLowerCase().contains(searchQuery.toLowerCase()) ?? false;
return titleMatch || descriptionMatch;
}).toList();
}
return filtered;
}
List<OffersAndDiscountsResponseModel> get filteredHistory {
List<OffersAndDiscountsResponseModel> filtered = historyList;
// Filter by facility types (multi-select support)
if (!selectedFacilities.contains('All Offers')) {
filtered = filtered.where((offer) {
// Check if offer's facility type matches any of the selected types
return selectedFacilities.any((selectedType) =>
offer.facilityType == selectedType || offer.facilityType == 'Both'
);
}).toList();
}
// Filter by search query
if (searchQuery.isNotEmpty) {
filtered = filtered.where((offer) {
final titleMatch = offer.title?.toLowerCase().contains(searchQuery.toLowerCase()) ?? false;
final descriptionMatch = offer.description?.toLowerCase().contains(searchQuery.toLowerCase()) ?? false;
return titleMatch || descriptionMatch;
}).toList();
}
return filtered;
}
}

@ -0,0 +1,343 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:hmg_patient_app_new/core/app_state.dart';
import 'package:hmg_patient_app_new/features/profile_settings/profile_settings_view_model.dart';
/// ViewModel for managing profile picture state and operations
/// Handles caching, loading, uploading, and user switching scenarios
class ProfilePictureViewModel extends ChangeNotifier {
final AppState _appState;
final ProfileSettingsViewModel _profileSettingsViewModel;
ProfilePictureViewModel({
required AppState appState,
required ProfileSettingsViewModel profileSettingsViewModel,
}) : _appState = appState,
_profileSettingsViewModel = profileSettingsViewModel;
// State variables
File? _selectedImage;
int? _currentPatientId;
bool _isInitialLoadTriggered = false;
/// Cache decoded image bytes to avoid decoding base64 on every rebuild
Uint8List? _cachedImageBytes;
String? _cachedImageDataHash;
/// ValueNotifier for targeted profile image updates (prevents full screen rebuild)
final ValueNotifier<int> _profileImageVersion = ValueNotifier<int>(0);
// Getters
File? get selectedImage => _selectedImage;
Uint8List? get cachedImageBytes => _cachedImageBytes;
bool get isInitialLoadTriggered => _isInitialLoadTriggered;
int? get currentPatientId => _currentPatientId;
ValueNotifier<int> get profileImageVersion => _profileImageVersion;
/// Initialize the provider with current user data
void initialize() {
_currentPatientId = _appState.getAuthenticatedUser()?.patientId;
_tryCacheExistingImage();
print('🔧 ProfilePictureViewModel initialized for patient: $_currentPatientId');
}
/// Trigger initial profile image load after first frame
void triggerInitialLoad() {
if (_isInitialLoadTriggered) return;
_isInitialLoadTriggered = true;
final patientID = _appState.getAuthenticatedUser()?.patientId;
if (patientID == null) {
print('⚠️ No authenticated user found');
return;
}
print('📥 Loading fresh profile image from API for patient: $patientID');
loadProfileImage(forceRefresh: false);
}
/// Pre-cache already-loaded image bytes so we don't flash default avatar
void _tryCacheExistingImage() {
final imageData = _appState.getProfileImageData;
if (imageData != null && imageData.isNotEmpty) {
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;
}
}
}
/// Check if authenticated user has changed (family member switch)
/// Returns true if user has changed
bool checkForUserSwitch() {
final currentPatientId = _appState.getAuthenticatedUser()?.patientId;
if (currentPatientId != null && currentPatientId != _currentPatientId) {
print('🔄 User switched detected: $_currentPatientId -> $currentPatientId');
_handleUserSwitch(currentPatientId);
return true;
}
return false;
}
/// Handle user switch scenario - clear caches and load new user's image
void _handleUserSwitch(int newPatientId) {
final oldPatientId = _currentPatientId;
_currentPatientId = newPatientId;
print('🧹 Clearing cache for old user: $oldPatientId');
// Clear AppState cache
_appState.clearProfileImageCache();
// Clear ViewModel cache
_profileSettingsViewModel.clearProfileImageCache();
// Clear local decoded bytes cache
_cachedImageBytes = null;
_cachedImageDataHash = null;
_selectedImage = null;
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();
},
);
}
/// Load profile image from API
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();
},
onError: (error) {
print('❌ Error loading profile image: $error');
},
);
}
/// Set selected image file (during upload process)
void setSelectedImage(File? file) {
_selectedImage = file;
notifyListeners();
}
/// Pick image from camera or gallery with compression
Future<void> pickImage(
BuildContext context, {
required void Function(
BuildContext context,
bool showFiles,
Function(String, File) onImageSelected, {
required Future<bool> Function() checkCameraPermission,
required Future<bool> Function() checkGalleryPermission,
}) showImagePicker,
required Future<File?> Function(File) compressImage,
required Function(String) onSuccess,
required Function(String) onError,
required String imageSizeTooLargeMessage,
required String failedToProcessImageMessage,
required Future<bool> Function(BuildContext) checkCameraPermission,
required Future<bool> Function(BuildContext) checkGalleryPermission,
}) async {
// Show image picker options
showImagePicker(
context,
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...');
final compressedFile = await compressImage(file);
File finalFile;
String finalBase64;
if (compressedFile == null) {
print('⚠️ Compression failed - using original file as fallback');
// Fallback: use original image if compression fails
final originalSize = await file.length();
final maxSize = 1048576; // 1MB
if (originalSize > maxSize) {
print('❌ Original file is too large: ${originalSize / 1024} KB');
onError(imageSizeTooLargeMessage);
return;
}
print('✅ Using original file (${originalSize / 1024} KB)');
finalFile = file;
var bytes = await file.readAsBytes();
finalBase64 = base64.encode(bytes);
} else {
// Check compressed file size
final fileSize = await compressedFile.length();
final maxSize = 1048576; // 1MB
print('✅ Compression successful: ${fileSize / 1024} KB');
if (fileSize > maxSize) {
print('❌ Compressed file still too large');
onError(imageSizeTooLargeMessage);
return;
}
finalFile = compressedFile;
var bytes = await compressedFile.readAsBytes();
finalBase64 = base64.encode(bytes);
}
print('Converting to base64... Length: ${finalBase64.length}');
// Set selected image
setSelectedImage(finalFile);
print('📤 Starting upload...');
// Upload the image
uploadProfileImage(finalBase64, onSuccess: onSuccess, onError: onError);
print('=== Image processing complete ===');
} catch (e, stackTrace) {
print('❌ Error in pickImage: $e');
print('Stack trace: $stackTrace');
onError(failedToProcessImageMessage);
}
},
checkCameraPermission: () => checkCameraPermission(context),
checkGalleryPermission: () => checkGalleryPermission(context),
);
}
/// Upload profile image
void uploadProfileImage(
String base64String, {
required Function(String) onSuccess,
required Function(String) onError,
}) {
final patientID = _appState.getAuthenticatedUser()?.patientId;
if (patientID == null) {
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;
// Add a small delay to ensure AppState is fully updated
await Future.delayed(const Duration(milliseconds: 50));
// Update cached bytes with the new data from AppState
_tryCacheExistingImage();
_selectedImage = null; // Clear selected image after successful upload
// Increment version to trigger targeted rebuild (no full screen refresh)
_profileImageVersion.value++;
print('🔄 Profile image version updated to ${_profileImageVersion.value} (targeted rebuild)');
onSuccess(data);
},
onError: (error) {
print('❌ Error uploading profile image: $error');
onError(error);
},
);
}
/// Update cached image bytes if source data has changed
void updateCacheIfNeeded() {
final String? imageData = _appState.getProfileImageData;
final String? currentHash = (imageData != null && imageData.isNotEmpty) ? '${imageData.length}_${imageData.hashCode}' : null;
// Re-decode only if the underlying data actually changed
if (currentHash != null && currentHash != _cachedImageDataHash) {
try {
_cachedImageBytes = base64Decode(imageData!);
_cachedImageDataHash = currentHash;
print('🔄 Updated cached image bytes');
} catch (e) {
print('❌ Error decoding profile image: $e');
_cachedImageBytes = null;
_cachedImageDataHash = null;
}
} else if (currentHash == null) {
_cachedImageBytes = null;
_cachedImageDataHash = null;
}
}
/// Clear all cached data
void clearCache() {
_cachedImageBytes = null;
_cachedImageDataHash = null;
_selectedImage = null;
notifyListeners();
print('🧹 Cleared all profile picture cache');
}
/// Check if we should show shimmer loading
bool shouldShowShimmer() {
return _profileSettingsViewModel.isProfileImageLoading && _cachedImageBytes == null && _selectedImage == null;
}
@override
void dispose() {
_profileImageVersion.dispose();
print('🗑️ ProfilePictureViewModel disposed');
super.dispose();
}
}

@ -381,14 +381,20 @@ class ProfileSettingsViewModel extends ChangeNotifier {
(response) {
isUploadingProfileImage = false;
profileImageData = imageData;
// Store in AppState for global access
// Store in AppState for global access FIRST
GetIt.instance<AppState>().setProfileImageData = imageData;
// Update the family files cache with the new profile image
// Update the family files cache with the new profile image (after AppState update)
try {
final medicalFileViewModel = GetIt.instance.get<MedicalFileViewModel>();
medicalFileViewModel.updateFamilyMemberProfileImage(patientID, imageData);
print("✅ Updated profile image in family files cache for patient: $patientID");
// Only update if family files are loaded
if (medicalFileViewModel.patientFamilyFiles.isNotEmpty ||
medicalFileViewModel.pendingFamilyFiles.isNotEmpty) {
medicalFileViewModel.updateFamilyMemberProfileImage(patientID, imageData);
} else {
print(" Family files not loaded yet, skipping cache update");
}
} catch (e) {
print("⚠️ Could not update family files cache: $e");
}

@ -36,8 +36,10 @@ import 'package:hmg_patient_app_new/features/my_appointments/appointment_via_reg
import 'package:hmg_patient_app_new/features/my_appointments/my_appointments_view_model.dart';
import 'package:hmg_patient_app_new/features/my_invoices/my_invoices_view_model.dart';
import 'package:hmg_patient_app_new/features/notifications/notifications_view_model.dart';
import 'package:hmg_patient_app_new/features/offers_and_discounts/offers_and_discounts_view_model.dart';
import 'package:hmg_patient_app_new/features/payfort/payfort_view_model.dart';
import 'package:hmg_patient_app_new/features/prescriptions/prescriptions_view_model.dart';
import 'package:hmg_patient_app_new/features/profile_picture/profile_picture_view_model.dart';
import 'package:hmg_patient_app_new/features/profile_settings/profile_settings_view_model.dart';
import 'package:hmg_patient_app_new/features/qr_parking/qr_parking_view_model.dart';
import 'package:hmg_patient_app_new/features/radiology/radiology_view_model.dart';
@ -166,6 +168,9 @@ void main() async {
ChangeNotifierProvider<ProfileSettingsViewModel>(
create: (_) => getIt.get<ProfileSettingsViewModel>(),
),
ChangeNotifierProvider<ProfilePictureViewModel>(
create: (_) => getIt.get<ProfilePictureViewModel>(),
),
ChangeNotifierProvider<MyAppointmentsViewModel>(
create: (_) => getIt.get<MyAppointmentsViewModel>(),
),
@ -256,6 +261,10 @@ void main() async {
ChangeNotifierProvider<ServicesPriceListViewModel>(
create: (_) => getIt.get<ServicesPriceListViewModel>(),
),
),
ChangeNotifierProvider<OffersAndDiscountsViewModel>(
create: (_) => getIt.get<OffersAndDiscountsViewModel>(),
),
ChangeNotifierProvider<WeatherMonitorViewModel>(
create: (_) => getIt.get<WeatherMonitorViewModel>(),
),

@ -862,6 +862,16 @@ class _AppointmentPaymentPageState extends State<AppointmentPaymentPage> {
});
}
});
},
onError: (err) {
LoaderBottomSheet.hideLoader();
showCommonBottomSheetWithoutHeight(
context,
child: Utils.getErrorWidget(loadingText: err.toString()),
callBackFunc: () {},
isFullScreen: false,
isCloseButtonVisible: true,
);
});
}, onError: (err) {
showCommonBottomSheetWithoutHeight(

@ -42,15 +42,12 @@ class _RegisterNew extends State<RegisterNewStep2> {
WidgetsBinding.instance.addPostFrameCallback((_) {
authVM?.clearAllStep2FieldErrors();
});
// Call insurance API to fetch data
WidgetsBinding.instance.addPostFrameCallback((_) {
debugPrint("Registration Step 2: Calling insurance API");
// Reset the flag to ensure API gets called
insuranceVM?.setIsInsuranceDataToBeLoaded(true);
insuranceVM?.initInsuranceProvider();
debugPrint("Registration Step 2: Insurance API call initiated");
});
if (!authVM!.isUserFromUAE()) {
WidgetsBinding.instance.addPostFrameCallback((_) {
insuranceVM?.setIsInsuranceDataToBeLoaded(true);
insuranceVM?.initInsuranceProvider();
});
}
}
@override
@ -188,6 +185,8 @@ class _RegisterNew extends State<RegisterNewStep2> {
),
padding: EdgeInsets.only(left: 16.h, right: 16.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
TextInputWidget(
labelText: authVM!.isUserFromUAE() ? LocaleKeys.fullName.tr(context: context) : LocaleKeys.name.tr(context: context),
@ -197,13 +196,12 @@ class _RegisterNew extends State<RegisterNewStep2> {
prefix: null,
isAllowRadius: false,
isBorderAllowed: false,
// hintColor: Color(0xff898A8D),
keyboardType: TextInputType.text,
// textInputAction: TextInputAction.done,
onSubmitted: (value) {
FocusScope.of(context).unfocus();
},
onChange: (value) {
// Clear error when user starts typing
authVM!.clearNameError();
},
isAllowLeadingIcon: true,
@ -214,7 +212,7 @@ class _RegisterNew extends State<RegisterNewStep2> {
// Show error message if exists
if (authVM!.isUserFromUAE() && authVM!.nameError != null)
Padding(
padding: EdgeInsets.only(left: 52.w, top: 4.h, bottom: 4.h, right: 16.w),
padding: EdgeInsets.only(left: 0.w, top: 4.h, bottom: 4.h, right: 16.w),
child: Text(
authVM!.nameError!,
style: TextStyle(
@ -226,9 +224,7 @@ class _RegisterNew extends State<RegisterNewStep2> {
Divider(height: 1.h, color: AppColors.greyColor),
TextInputWidget(
labelText: LocaleKeys.nationalIdNumber.tr(context: context),
hintText: authVM!.isUserFromUAE()
? appState.getUserRegistrationPayload.patientIdentificationId.toString()
: (appState.getNHICUserData.idNumber ?? ""),
hintText: authVM!.isUserFromUAE() ? appState.getUserRegistrationPayload.patientIdentificationId.toString() : (appState.getNHICUserData.idNumber ?? ""),
controller: null,
isEnable: true,
prefix: null,
@ -279,7 +275,7 @@ class _RegisterNew extends State<RegisterNewStep2> {
// Show gender error message if exists (for UAE users)
if (authVM!.isUserFromUAE() && authVM!.genderError != null)
Padding(
padding: EdgeInsets.only(left: 52.w, top: 4.h, bottom: 4.h, right: 16.w),
padding: EdgeInsets.only(left: 0.w, top: 4.h, bottom: 4.h, right: 16.w),
child: Text(
authVM!.genderError!,
style: TextStyle(
@ -330,7 +326,7 @@ class _RegisterNew extends State<RegisterNewStep2> {
// Show marital status error message if exists (for UAE users)
if (authVM!.isUserFromUAE() && authVM!.maritalStatusError != null)
Padding(
padding: EdgeInsets.only(left: 52.w, top: 4.h, bottom: 4.h, right: 16.w),
padding: EdgeInsets.only(left: 0.w, top: 4.h, bottom: 4.h, right: 16.w),
child: Text(
authVM!.maritalStatusError!,
style: TextStyle(
@ -341,8 +337,7 @@ class _RegisterNew extends State<RegisterNewStep2> {
),
Divider(height: 1.h, color: AppColors.greyColor),
authVM!.isUserFromUAE()
? Selector<AuthenticationViewModel,
({List<NationalityCountries>? countriesList, NationalityCountries? selectedCountry, bool isArabic})>(
? Selector<AuthenticationViewModel, ({List<NationalityCountries>? countriesList, NationalityCountries? selectedCountry, bool isArabic})>(
selector: (context, authViewModel) {
final appState = getIt.get<AppState>();
return (
@ -352,9 +347,7 @@ class _RegisterNew extends State<RegisterNewStep2> {
);
},
shouldRebuild: (previous, next) =>
previous.countriesList != next.countriesList ||
previous.selectedCountry != next.selectedCountry ||
previous.isArabic != next.isArabic,
previous.countriesList != next.countriesList || previous.selectedCountry != next.selectedCountry || previous.isArabic != next.isArabic,
builder: (context, data, child) {
final authVM = context.read<AuthenticationViewModel>();
return DropdownWidget(
@ -381,16 +374,8 @@ class _RegisterNew extends State<RegisterNewStep2> {
: TextInputWidget(
labelText: LocaleKeys.nationality.tr(context: context),
hintText: appState.isArabic()
? (authVM!.countriesList!
.firstWhere((e) => e.id == (appState.getNHICUserData.nationalityCode ?? ""),
orElse: () => NationalityCountries())
.nameN ??
"")
: (authVM!.countriesList!
.firstWhere((e) => e.id == (appState.getNHICUserData.nationalityCode ?? ""),
orElse: () => NationalityCountries())
.name ??
""),
? (authVM!.countriesList!.firstWhere((e) => e.id == (appState.getNHICUserData.nationalityCode ?? ""), orElse: () => NationalityCountries()).nameN ?? "")
: (authVM!.countriesList!.firstWhere((e) => e.id == (appState.getNHICUserData.nationalityCode ?? ""), orElse: () => NationalityCountries()).name ?? ""),
isEnable: true,
prefix: null,
isAllowRadius: false,
@ -404,7 +389,7 @@ class _RegisterNew extends State<RegisterNewStep2> {
// Show country error message if exists (for UAE users)
if (authVM!.isUserFromUAE() && authVM!.countryError != null)
Padding(
padding: EdgeInsets.only(left: 52.w, top: 4.h, bottom: 4.h, right: 16.w),
padding: EdgeInsets.only(left: 0.w, top: 4.h, bottom: 4.h, right: 16.w),
child: Text(
authVM!.countryError!,
style: TextStyle(
@ -436,9 +421,7 @@ class _RegisterNew extends State<RegisterNewStep2> {
),
TextInputWidget(
labelText: LocaleKeys.dob.tr(context: context),
hintText: authVM!.isUserFromUAE()
? (appState.getUserRegistrationPayload.dob ?? '')
: (appState.getNHICUserData.dateOfBirth ?? ""),
hintText: authVM!.isUserFromUAE() ? (appState.getUserRegistrationPayload.dob ?? '') : (appState.getNHICUserData.dateOfBirth ?? ""),
controller: authVM!.isUserFromUAE() ? authVM!.dobController : null,
isEnable: false,
prefix: null,

@ -87,14 +87,12 @@ class _SavedLogin extends State<SavedLogin> {
LocaleKeys.welcomeBack.tr().toText16(color: AppColors.inputLabelTextColor),
SizedBox(height: 16.h),
appState.getSelectDeviceByImeiRespModelElement != null
? appState.getSelectDeviceByImeiRespModelElement!.name!.toCamelCase
.toText26(isBold: true, height: 26 / 36, color: AppColors.textColor, isEnglishOnly: true)
? appState.getSelectDeviceByImeiRespModelElement!.name!.toCamelCase.toText26(isBold: true, height: 26 / 36, color: AppColors.textColor, isEnglishOnly: true)
: SizedBox(),
SizedBox(height: 24.h),
Container(
padding: EdgeInsets.all(16.h),
decoration: RoundedRectangleBorder()
.toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 20.h, hasShadow: false, isCustomShadow: [
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 20.h, hasShadow: false, isCustomShadow: [
BoxShadow(color: Color(0x0D000000), blurRadius: 16.h, offset: Offset(0, 0), spreadRadius: 5.h),
]),
child: Column(
@ -106,9 +104,7 @@ class _SavedLogin extends State<SavedLogin> {
textDirection: ui.TextDirection.ltr,
child: appState.getSelectDeviceByImeiRespModelElement != null
? (appState.getSelectDeviceByImeiRespModelElement!.createdOn != null
? DateUtil.getFormattedDate(
DateUtil.convertStringToDate(appState.getSelectDeviceByImeiRespModelElement!.createdOn!),
"d MMMM, y 'at' HH:mm")
? DateUtil.getFormattedDate(DateUtil.convertStringToDate(appState.getSelectDeviceByImeiRespModelElement!.createdOn!), "d MMMM, y 'at' HH:mm")
: '--')
.toText16(isBold: true, color: AppColors.textColor, isEnglishOnly: true)
: SizedBox(),
@ -118,14 +114,10 @@ class _SavedLogin extends State<SavedLogin> {
? Container(
margin: EdgeInsets.all(16.h),
child: Utils.buildSvgWithAssets(
icon: (isOther == true && loginType == LoginTypeEnum.sms)
? AppAssets.whatsapp
: getTypeIcons(appState.getSelectDeviceByImeiRespModelElement!.logInType!),
icon: (isOther == true && loginType == LoginTypeEnum.sms) ? AppAssets.whatsapp : getTypeIcons(appState.getSelectDeviceByImeiRespModelElement!.logInType!),
height: 54.h,
width: 54.w,
iconColor: (isOther == true && loginType == LoginTypeEnum.sms) || loginType.toInt == 4
? null
: AppColors.primaryRedColor))
iconColor: (isOther == true && loginType == LoginTypeEnum.sms) || loginType.toInt == 4 ? null : AppColors.primaryRedColor))
: SizedBox(),
// Main login button - for isOther with SMS, show WhatsApp, otherwise keep original login type
CustomButton(
@ -138,9 +130,7 @@ class _SavedLogin extends State<SavedLogin> {
} else {
// For isOther with SMS, use WhatsApp; otherwise use the original login type
authVm.checkUserAuthentication(
otpTypeEnum: (isOther == true && loginType == LoginTypeEnum.sms)
? OTPTypeEnum.whatsapp
: (loginType == LoginTypeEnum.sms ? OTPTypeEnum.sms : OTPTypeEnum.whatsapp),
otpTypeEnum: (isOther == true && loginType == LoginTypeEnum.sms) ? OTPTypeEnum.whatsapp : (loginType == LoginTypeEnum.sms ? OTPTypeEnum.sms : OTPTypeEnum.whatsapp),
);
}
},
@ -153,8 +143,7 @@ class _SavedLogin extends State<SavedLogin> {
height: 44.h,
padding: EdgeInsets.symmetric(vertical: 10.h),
icon: (isOther == true && loginType == LoginTypeEnum.sms) ? AppAssets.whatsapp : getTypeIcons(loginType.toInt),
iconColor:
(isOther == true && loginType == LoginTypeEnum.sms) || loginType == LoginTypeEnum.whatsapp ? null : Colors.white,
iconColor: (isOther == true && loginType == LoginTypeEnum.sms) || loginType == LoginTypeEnum.whatsapp ? null : Colors.white,
),
],
),
@ -189,13 +178,13 @@ class _SavedLogin extends State<SavedLogin> {
backgroundColor: Colors.transparent,
enableDrag: false,
// Prevent dragging to avoid focus conflicts
builder: (bottomSheetContext) =>
StatefulBuilder(builder: (BuildContext context, StateSetter setModalState) {
builder: (bottomSheetContext) => StatefulBuilder(builder: (BuildContext context, StateSetter setModalState) {
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(bottomSheetContext).viewInsets.bottom),
child: SingleChildScrollView(
child: GenericBottomSheet(
countryCode: "966",
countryCode: appState.getSelectDeviceByImeiRespModelElement!.outSa == true ? "971" : "966",
// countryCode: "966",
initialPhoneNumber: "",
textController: TextEditingController(),
isFromSavedLogin: true,
@ -221,9 +210,7 @@ class _SavedLogin extends State<SavedLogin> {
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 8.h),
child: (LocaleKeys.oR.tr()).toText16(color: AppColors.textColor)),
Padding(padding: EdgeInsets.symmetric(horizontal: 8.h), child: (LocaleKeys.oR.tr()).toText16(color: AppColors.textColor)),
],
),
Padding(

@ -6,6 +6,7 @@ import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:hmg_patient_app_new/core/app_assets.dart';
import 'package:hmg_patient_app_new/core/app_export.dart';
import 'package:hmg_patient_app_new/core/app_state.dart';
import 'package:hmg_patient_app_new/core/cache_consts.dart';
import 'package:hmg_patient_app_new/core/dependencies.dart';
import 'package:hmg_patient_app_new/core/location_util.dart';
import 'package:hmg_patient_app_new/core/utils/utils.dart';
@ -132,12 +133,12 @@ class _BookAppointmentPageState extends State<BookAppointmentPage> {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.network(
"https://hmgwebservices.com/Images/MobileImages/DUBAI/unkown_female.png",
width: 64.h,
height: 64.h,
fit: BoxFit.cover,
).circle(100).toShimmer2(isShow: true, radius: 50.r),
SizedBox(height: 8.h),
"https://hmgwebservices.com/Images/MobileImages/DUBAI/unkown_female.png",
width: 64.h,
height: 64.h,
fit: BoxFit.cover,
).circle(100).toShimmer2(isShow: true, radius: 50.r),
SizedBox(height: 8.h),
("Dr. John").toString().toText12(isBold: true, isCenter: true, maxLine: 2).toShimmer2(isShow: true),
],
);
@ -337,96 +338,99 @@ class _BookAppointmentPageState extends State<BookAppointmentPage> {
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 24.h),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 24.h),
LocaleKeys.favourites.tr(context: context).toText18(isBold: true).paddingSymmetrical(24.w, 0.h),
SizedBox(height: 16.h),
SizedBox(
height: 110.h,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: myAppointmentsVM.patientFavouriteDoctorsList.length + 1,
shrinkWrap: true,
padding: EdgeInsets.only(left: 24.w, right: 24.w),
itemBuilder: (context, index) {
// Last item: static "Add" button
if (index == myAppointmentsVM.patientFavouriteDoctorsList.length) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
height: 110.h,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: myAppointmentsVM.patientFavouriteDoctorsList.length + 1,
shrinkWrap: true,
padding: EdgeInsets.only(left: 24.w, right: 24.w),
itemBuilder: (context, index) {
// Last item: static "Add" button
if (index == myAppointmentsVM.patientFavouriteDoctorsList.length) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Utils.buildSvgWithAssets(icon: AppAssets.add_new_family_icon, height: 64.h, width: 64.h, applyThemeColor: false),
SizedBox(
width: 80.w,
child: LocaleKeys.add.tr(context: context).toText12(
child: LocaleKeys.add
.tr(context: context)
.toText12(
color: AppColors.textColor,
isBold: true,
isCenter: true,
).paddingOnly(top: 4.h),
),
],
).onPress(() {
Navigator.of(context).push(CustomPageRoute(page: SearchDoctorByName()));
});
}
return AnimationConfiguration.staggeredList(
position: index,
duration: const Duration(milliseconds: 1000),
child: SlideAnimation(
horizontalOffset: 100.0,
child: FadeInAnimation(
child: SizedBox(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.network(
myAppointmentsVM.patientFavouriteDoctorsList[index].doctorImageUrl!,
width: 64.h,
height: 64.h,
fit: BoxFit.cover,
).circle(100).toShimmer2(isShow: false, radius: 50.r),
SizedBox(height: 8.h),
SizedBox(
width: 80.w,
child: (myAppointmentsVM.patientFavouriteDoctorsList[index].doctorName)
.toString()
.toText12(isBold: true, isCenter: true, maxLine: 2)
.toShimmer2(isShow: false),
),
],
)
.paddingOnly(top: 4.h),
),
],
).onPress(() {
Navigator.of(context).push(CustomPageRoute(page: SearchDoctorByName()));
});
}
return AnimationConfiguration.staggeredList(
position: index,
duration: const Duration(milliseconds: 1000),
child: SlideAnimation(
horizontalOffset: 100.0,
child: FadeInAnimation(
child: SizedBox(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.network(
myAppointmentsVM.patientFavouriteDoctorsList[index].doctorImageUrl!,
width: 64.h,
height: 64.h,
fit: BoxFit.cover,
).circle(100).toShimmer2(isShow: false, radius: 50.r),
SizedBox(height: 8.h),
SizedBox(
width: 80.w,
child: (myAppointmentsVM.patientFavouriteDoctorsList[index].doctorName)
.toString()
.toText12(isBold: true, isCenter: true, maxLine: 2)
.toShimmer2(isShow: false),
),
).onPress(() async {
bookAppointmentsViewModel.setSelectedDoctor(DoctorsListResponseModel(
clinicID: myAppointmentsVM.patientFavouriteDoctorsList[index].clinicId,
projectID: myAppointmentsVM.patientFavouriteDoctorsList[index].projectId,
doctorID: myAppointmentsVM.patientFavouriteDoctorsList[index].doctorId,
));
LoaderBottomSheet.showLoader();
await bookAppointmentsViewModel.getDoctorProfile(onSuccess: (dynamic respData) {
LoaderBottomSheet.hideLoader();
Navigator.of(context).push(
CustomPageRoute(
page: DoctorProfilePage(isDoctorAllowedToBook: true),
),
);
}, onError: (err) {
LoaderBottomSheet.hideLoader();
showCommonBottomSheetWithoutHeight(
context,
child: Utils.getErrorWidget(loadingText: err),
callBackFunc: () {},
isFullScreen: false,
isCloseButtonVisible: true,
);
});
}),
],
),
),
);
},
separatorBuilder: (BuildContext cxt, int index) => SizedBox(width: 8.h),
),
),
).onPress(() async {
bookAppointmentsViewModel.setSelectedDoctor(DoctorsListResponseModel(
clinicID: myAppointmentsVM.patientFavouriteDoctorsList[index].clinicId,
projectID: myAppointmentsVM.patientFavouriteDoctorsList[index].projectId,
doctorID: myAppointmentsVM.patientFavouriteDoctorsList[index].doctorId,
));
LoaderBottomSheet.showLoader();
await bookAppointmentsViewModel.getDoctorProfile(onSuccess: (dynamic respData) {
LoaderBottomSheet.hideLoader();
Navigator.of(context).push(
CustomPageRoute(
page: DoctorProfilePage(isDoctorAllowedToBook: true),
),
);
}, onError: (err) {
LoaderBottomSheet.hideLoader();
showCommonBottomSheetWithoutHeight(
context,
child: Utils.getErrorWidget(loadingText: err),
callBackFunc: () {},
isFullScreen: false,
isCloseButtonVisible: true,
);
});
}),
),
),
);
},
separatorBuilder: (BuildContext cxt, int index) => SizedBox(width: 8.h),
),
),
],
);
}),
@ -552,96 +556,98 @@ class _BookAppointmentPageState extends State<BookAppointmentPage> {
],
).paddingSymmetrical(24.h, 0.h);
case 1:
//TODO: Get LiveCare type Select UI from Hussain
//TODO: Get LiveCare type Select UI from Hussain
return appState.isAuthenticated
? Column(
children: [
Container(
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(
color: AppColors.whiteColor,
borderRadius: 24.h,
hasShadow: false,
),
child: Padding(
padding: EdgeInsets.all(16.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
children: [
Container(
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(
color: AppColors.whiteColor,
borderRadius: 24.h,
hasShadow: false,
),
child: Padding(
padding: EdgeInsets.all(16.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Utils.buildSvgWithAssets(icon: AppAssets.search_by_clinic_icon, width: 40.h, height: 40.h, applyThemeColor: false),
SizedBox(width: 12.h),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LocaleKeys.immediateConsultation.tr(context: context).toText14(color: AppColors.textColor, isBold: true),
LocaleKeys.tapToSelectClinic.tr(context: context).toText12(color: AppColors.primaryRedColor, isBold: true),
],
),
],
),
Transform.flip(flipX: appState.isArabic(), child: Utils.buildSvgWithAssets(icon: AppAssets.forward_arrow_icon, iconColor: AppColors.textColor, width: 40.h, height: 40.h)),
],
).onPress(() async {
//TODO Implement API to check for existing LiveCare Requests
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LocaleKeys.immediateConsultation.tr(context: context).toText14(color: AppColors.textColor, isBold: true),
LocaleKeys.tapToSelectClinic.tr(context: context).toText12(color: AppColors.primaryRedColor, isBold: true),
],
),
],
),
Transform.flip(
flipX: appState.isArabic(), child: Utils.buildSvgWithAssets(icon: AppAssets.forward_arrow_icon, iconColor: AppColors.textColor, width: 40.h, height: 40.h)),
],
).onPress(() async {
//TODO Implement API to check for existing LiveCare Requests
LoaderBottomSheet.showLoader();
await immediateLiveCareViewModel.getPatientLiveCareHistory();
LoaderBottomSheet.hideLoader();
LoaderBottomSheet.showLoader();
await immediateLiveCareViewModel.getPatientLiveCareHistory();
LoaderBottomSheet.hideLoader();
if (immediateLiveCareViewModel.patientHasPendingLiveCareRequest) {
Navigator.of(context).push(
CustomPageRoute(
page: ImmediateLiveCarePendingRequestPage(),
),
);
} else {
Navigator.of(context).push(
CustomPageRoute(
page: SelectImmediateLiveCareClinicPage(),
),
);
}
}),
SizedBox(height: 16.h),
Divider(color: AppColors.borderOnlyColor.withValues(alpha: 0.1), height: 1.h),
SizedBox(height: 16.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
if (immediateLiveCareViewModel.patientHasPendingLiveCareRequest) {
Navigator.of(context).push(
CustomPageRoute(
page: ImmediateLiveCarePendingRequestPage(),
),
);
} else {
Navigator.of(context).push(
CustomPageRoute(
page: SelectImmediateLiveCareClinicPage(),
),
);
}
}),
SizedBox(height: 16.h),
Divider(color: AppColors.borderOnlyColor.withValues(alpha: 0.1), height: 1.h),
SizedBox(height: 16.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Utils.buildSvgWithAssets(icon: AppAssets.search_by_doctor_icon, width: 40.h, height: 40.h, applyThemeColor: false),
SizedBox(width: 12.h),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LocaleKeys.scheduledConsultation.tr(context: context).toText14(color: AppColors.textColor, isBold: true),
LocaleKeys.tapToSelectClinic.tr(context: context).toText12(color: AppColors.primaryRedColor, isBold: true),
],
),
],
),
Transform.flip(flipX: appState.isArabic(), child: Utils.buildSvgWithAssets(icon: AppAssets.forward_arrow_icon, iconColor: AppColors.textColor, width: 40.h, height: 40.h)),
],
).onPress(() {
bookAppointmentsViewModel.setIsClinicsListLoading(true);
bookAppointmentsViewModel.setIsLiveCareSchedule(true);
Navigator.of(context).push(
CustomPageRoute(
page: SelectClinicPage(),
),
);
}),
],
),
),
),
],
).paddingSymmetrical(24.h, 0.h)
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LocaleKeys.scheduledConsultation.tr(context: context).toText14(color: AppColors.textColor, isBold: true),
LocaleKeys.tapToSelectClinic.tr(context: context).toText12(color: AppColors.primaryRedColor, isBold: true),
],
),
],
),
Transform.flip(
flipX: appState.isArabic(), child: Utils.buildSvgWithAssets(icon: AppAssets.forward_arrow_icon, iconColor: AppColors.textColor, width: 40.h, height: 40.h)),
],
).onPress(() {
bookAppointmentsViewModel.setIsClinicsListLoading(true);
bookAppointmentsViewModel.setIsLiveCareSchedule(true);
Navigator.of(context).push(
CustomPageRoute(
page: SelectClinicPage(),
),
);
}),
],
),
),
),
],
).paddingSymmetrical(24.h, 0.h)
: getLiveCareNotLoggedInUI();
default:
SizedBox.shrink();
@ -657,28 +663,28 @@ class _BookAppointmentPageState extends State<BookAppointmentPage> {
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LocaleKeys.notSureHelpMeChooseClinic.tr(context: context).toText16(isBold: true, color: AppColors.textColor),
SizedBox(height: 8.h),
LocaleKeys.checkYourSymptomsWithScale.tr(context: context).toText12(
isBold: true,
color: AppColors.greyTextColor,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LocaleKeys.notSureHelpMeChooseClinic.tr(context: context).toText16(isBold: true, color: AppColors.textColor),
SizedBox(height: 8.h),
LocaleKeys.checkYourSymptomsWithScale.tr(context: context).toText12(
isBold: true,
color: AppColors.greyTextColor,
),
],
),
),
SizedBox(width: 16.w),
CustomButton(
height: 42.h,
width: 42.w,
text: "",
onPressed: () => context.navigateWithName(AppRoutes.userInfoSelection),
icon: getIt.get<AppState>().isArabic() ? AppAssets.arrow_back : AppAssets.arrow_forward,
)
],
),
),
SizedBox(width: 16.w),
CustomButton(
height: 42.h,
width: 42.w,
text: "",
onPressed: () => context.navigateWithName(AppRoutes.userInfoSelection),
icon: getIt.get<AppState>().isArabic() ? AppAssets.arrow_back : AppAssets.arrow_forward,
)
],
).paddingAll(24.w),
).paddingAll(24.w),
)
: SizedBox.shrink();
}
@ -687,13 +693,10 @@ class _BookAppointmentPageState extends State<BookAppointmentPage> {
regionalViewModel.flush();
regionalViewModel.setBottomSheetType(type);
// AppointmentViaRegionViewmodel? viewmodel = null;
showCommonBottomSheetWithoutHeight(context, title: "",
titleWidget: Consumer<AppointmentViaRegionViewmodel>(builder: (_, data, __) => getTitle(data)),
isDismissible: false,
showCommonBottomSheetWithoutHeight(context, title: "", titleWidget: Consumer<AppointmentViaRegionViewmodel>(builder: (_, data, __) => getTitle(data)), isDismissible: false,
child: Consumer<AppointmentViaRegionViewmodel>(builder: (_, data, __) {
return getRegionalSelectionWidget(data);
}),
callBackFunc: () {});
return getRegionalSelectionWidget(data);
}), callBackFunc: () {});
}
Widget getRegionalSelectionWidget(AppointmentViaRegionViewmodel data) {
@ -749,7 +752,7 @@ class _BookAppointmentPageState extends State<BookAppointmentPage> {
return SizedBox.shrink();
}
void _handleSortByLocationToggle(bool value, AppointmentViaRegionViewmodel regionVM) {
void _handleSortByLocationToggle(bool value, AppointmentViaRegionViewmodel regionVM) {
if (value) {
final locationUtils = getIt.get<LocationUtils>();
locationUtils.getLocation(
@ -817,6 +820,7 @@ class _BookAppointmentPageState extends State<BookAppointmentPage> {
}
}
}
bookAppointmentsViewModel.addListener(listener);
bookAppointmentsViewModel.getRegionMappedProjectList();
}
@ -927,52 +931,58 @@ class _BookAppointmentPageState extends State<BookAppointmentPage> {
).paddingSymmetrical(24.h, 0.h);
}
void showUnKnownClinicBottomSheet() {
showCommonBottomSheetWithoutHeight(
context,
title: "",
isDismissible: true,
void showUnKnownClinicBottomSheet() async {
if (await Utils.getBoolFromPrefs(CacheConst.isShowSymptomCheckerBottomSheet)) {
showCommonBottomSheetWithoutHeight(
context,
title: "",
isDismissible: true,
isCloseButtonVisible: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LocaleKeys.notSureHelpMeChooseClinic.tr(context: context).toText28(color: AppColors.textColor, isBold: true, height: 1.5),
SizedBox(height: 4.h),
LocaleKeys.mentionYourSymptomsAndFindDoctors.tr(context: context).toText12(color: AppColors.greyTextColor, isBold: true,),
SizedBox(height: 24.h),
CustomButton(
text: LocaleKeys.yesPleaseINeedHelp.tr(context: context),
onPressed: () {
context.pop();
context.navigateWithName(AppRoutes.userInfoSelection);
},
backgroundColor: AppColors.primaryRedColor,
borderColor: AppColors.primaryRedColor,
textColor: AppColors.whiteColor,
fontSize: 16.f,
isBold: true,
borderRadius: 12.r,
// padding: EdgeInsets.fromLTRB(10, 0, 10, 0),
height: 56.h,
),
SizedBox(height: 8.h),
CustomButton(
text: LocaleKeys.noThanksIKnowTheClinic.tr(context: context),
onPressed: () {
context.pop();
},
backgroundColor: AppColors.chipSecondaryLightRedColor,
borderColor: AppColors.chipSecondaryLightRedColor,
textColor: AppColors.primaryRedColor,
fontSize: 16.f,
isBold: true,
borderRadius: 12.r,
// padding: EdgeInsets.fromLTRB(10, 0, 10, 0),
height: 56.h,
),
],
).paddingSymmetrical(24.w, 20.h),
callBackFunc: () {},
);
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LocaleKeys.notSureHelpMeChooseClinic.tr(context: context).toText28(color: AppColors.textColor, isBold: true, height: 1.5),
SizedBox(height: 4.h),
LocaleKeys.mentionYourSymptomsAndFindDoctors.tr(context: context).toText12(
color: AppColors.greyTextColor,
isBold: true,
),
SizedBox(height: 24.h),
CustomButton(
text: LocaleKeys.yesPleaseINeedHelp.tr(context: context),
onPressed: () {
context.pop();
context.navigateWithName(AppRoutes.userInfoSelection);
},
backgroundColor: AppColors.primaryRedColor,
borderColor: AppColors.primaryRedColor,
textColor: AppColors.whiteColor,
fontSize: 16.f,
isBold: true,
borderRadius: 12.r,
// padding: EdgeInsets.fromLTRB(10, 0, 10, 0),
height: 56.h,
),
SizedBox(height: 8.h),
CustomButton(
text: LocaleKeys.noThanksIKnowTheClinic.tr(context: context),
onPressed: () {
Utils.saveBoolFromPrefs(CacheConst.isShowSymptomCheckerBottomSheet, false);
context.pop();
},
backgroundColor: AppColors.chipSecondaryLightRedColor,
borderColor: AppColors.chipSecondaryLightRedColor,
textColor: AppColors.primaryRedColor,
fontSize: 16.f,
isBold: true,
borderRadius: 12.r,
// padding: EdgeInsets.fromLTRB(10, 0, 10, 0),
height: 56.h,
),
],
).paddingSymmetrical(24.w, 20.h),
callBackFunc: () {},
);
}
}
}

@ -1,7 +1,10 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:typed_data';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:hmg_patient_app_new/core/app_assets.dart';
import 'package:hmg_patient_app_new/core/app_state.dart';
import 'package:hmg_patient_app_new/core/dependencies.dart';
@ -14,6 +17,7 @@ import 'package:hmg_patient_app_new/features/authentication/authentication_view_
import 'package:hmg_patient_app_new/features/book_appointments/book_appointments_view_model.dart';
import 'package:hmg_patient_app_new/features/my_appointments/models/resp_models/patient_appointment_history_response_model.dart';
import 'package:hmg_patient_app_new/features/my_appointments/my_appointments_view_model.dart';
import 'package:hmg_patient_app_new/features/profile_settings/profile_settings_view_model.dart';
import 'package:hmg_patient_app_new/features/symptoms_checker/symptoms_checker_view_model.dart';
import 'package:hmg_patient_app_new/generated/locale_keys.g.dart';
import 'package:hmg_patient_app_new/presentation/book_appointment/waiting_appointment/waiting_appointment_payment_page.dart';
@ -41,6 +45,9 @@ class _ReviewAppointmentPageState extends State<ReviewAppointmentPage> {
late MyAppointmentsViewModel myAppointmentsViewModel;
late SymptomsCheckerViewModel symptomsCheckerViewModel;
Uint8List? _cachedImageBytes;
String? _cachedImageDataHash;
@override
Widget build(BuildContext context) {
bookAppointmentsViewModel = Provider.of<BookAppointmentsViewModel>(context, listen: false);
@ -156,11 +163,14 @@ class _ReviewAppointmentPageState extends State<ReviewAppointmentPage> {
padding: EdgeInsets.all(16.h),
child: Row(
children: [
Image.asset(
appState.getAuthenticatedUser()?.gender == 1 ? AppAssets.maleImg : AppAssets.femaleImg,
width: 52.h,
height: 52.h,
),
// Image.asset(
// appState.getAuthenticatedUser()?.gender == 1 ? AppAssets.maleImg : AppAssets.femaleImg,
// width: 52.h,
// height: 52.h,
// ),
Consumer<ProfileSettingsViewModel>(builder: (context, profileVm, _) {
return _buildProfileImage(profileVm);
}),
SizedBox(width: 8.h),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -260,6 +270,79 @@ class _ReviewAppointmentPageState extends State<ReviewAppointmentPage> {
);
}
Widget _buildProfileImage(ProfileSettingsViewModel profileVm) {
// Always get fresh user data
final currentUser = appState.getAuthenticatedUser();
final currentPatientId = currentUser?.patientId;
final gender = currentUser?.gender ?? 1;
final age = currentUser?.age ?? 0;
// Determine the default image based on gender and age
final String defaultImage;
if (gender == 1) {
// Male
defaultImage = age < 7 ? AppAssets.babyBoyImg : AppAssets.maleImg;
} else {
// Female
defaultImage = age < 7 ? AppAssets.babyGirlImg : AppAssets.femaleImg;
}
// Show selected image if available (only during upload)
// if (_selectedImage != null) {
// return ClipOval(
// child: Image.file(
// _selectedImage!,
// width: 136.w,
// height: 136.h,
// fit: BoxFit.cover,
// ),
// );
// }
// Use cached decoded bytes update cache if source data changed
final String? imageData = GetIt.instance<AppState>().getProfileImageData;
final String? currentHash = (imageData != null && imageData.isNotEmpty) ? '${imageData.length}_${imageData.hashCode}' : null;
// Re-decode only if the underlying data actually changed
if (currentHash != null && currentHash != _cachedImageDataHash) {
try {
_cachedImageBytes = base64Decode(imageData!);
_cachedImageDataHash = currentHash;
} catch (e) {
print('❌ Error decoding profile image: $e');
_cachedImageBytes = null;
_cachedImageDataHash = null;
}
} else if (currentHash == null) {
_cachedImageBytes = null;
_cachedImageDataHash = null;
}
// Show cached decoded image if available
if (_cachedImageBytes != null) {
return ClipOval(
child: Image.memory(
_cachedImageBytes!,
key: ValueKey('profile_$currentPatientId'),
width: 52.w,
height: 52.h,
fit: BoxFit.cover,
gaplessPlayback: true, // Prevents blink during image rebuild
),
);
}
// Show default image (no image data or user has no uploaded image)
print('📷 Showing default avatar for user $currentPatientId');
return Image.asset(
defaultImage,
key: ValueKey('default_$currentPatientId'),
width: 52.w,
height: 52.h,
gaplessPlayback: true,
);
}
void getWalkInAppointmentPatientShare() async {
LoaderBottomSheet.showLoader(loadingText: LocaleKeys.fetchingAppointmentShare.tr(context: context));
await bookAppointmentsViewModel.getWalkInPatientShareAppointment(onSuccess: (val) {

@ -29,6 +29,7 @@ import 'package:hmg_patient_app_new/features/my_appointments/appointment_rating_
import 'package:hmg_patient_app_new/features/my_appointments/models/resp_models/patient_appointment_history_response_model.dart';
import 'package:hmg_patient_app_new/features/my_appointments/my_appointments_view_model.dart';
import 'package:hmg_patient_app_new/features/notifications/notifications_view_model.dart';
import 'package:hmg_patient_app_new/features/profile_settings/profile_settings_view_model.dart';
import 'package:hmg_patient_app_new/features/paytabs/paytabs_view_model.dart';
import 'package:hmg_patient_app_new/features/todo_section/todo_section_view_model.dart';
import 'package:hmg_patient_app_new/generated/locale_keys.g.dart';
@ -50,6 +51,8 @@ import 'package:hmg_patient_app_new/presentation/insurance/widgets/insurance_upd
import 'package:hmg_patient_app_new/presentation/medical_file/medical_file_page.dart';
import 'package:hmg_patient_app_new/presentation/my_family/my_family.dart';
import 'package:hmg_patient_app_new/presentation/notifications/notifications_list_page.dart';
import 'package:hmg_patient_app_new/presentation/offers_and_discounts/offers_and_discounts_page.dart';
import 'package:hmg_patient_app_new/presentation/offers_and_discounts/widgets/offers_and_discounts.dart';
import 'package:hmg_patient_app_new/presentation/rate_appointment/rate_appointment_doctor.dart';
import 'package:hmg_patient_app_new/presentation/todo_section/ancillary_procedures_details_page.dart';
import 'package:hmg_patient_app_new/presentation/todo_section/todo_page.dart';
@ -240,11 +243,20 @@ class _LandingPageState extends State<LandingPage> {
);
},
name: ('${appState.getAuthenticatedUser()!.firstName!} ${appState.getAuthenticatedUser()!.lastName!}'),
imageWidget: UserAvatarWidget(
width: 42.w,
height: 42.h,
fit: BoxFit.cover,
isCircular: true,
imageWidget: Selector<ProfileSettingsViewModel, String?>(
selector: (_, profileVM) => profileVM.profileImageData ?? appState.getProfileImageData,
shouldRebuild: (previous, next) => previous != next,
builder: (context, profileImageData, child) {
// Only rebuild when profile image data changes
return UserAvatarWidget(
key: ValueKey('landing_avatar_${profileImageData?.hashCode ?? 0}'),
width: 42.w,
height: 42.h,
fit: BoxFit.cover,
isCircular: true,
customProfileImageData: profileImageData,
);
},
),
).expanded
: CustomButton(
@ -336,9 +348,7 @@ class _LandingPageState extends State<LandingPage> {
// }),
!appState.isAuthenticated
? Row(children: [
SizedBox(
width: 24.w,
),
SizedBox(width: 24.w),
Utils.buildSvgWithAssets(icon: appState.isArabic() ? AppAssets.enLangIcon : AppAssets.arLangIcon, height: 24.h, width: 24.h).onPress(() {
context.setLocale(appState.isArabic() ? Locale('en', 'US') : Locale('ar', 'SA'));
})
@ -394,6 +404,34 @@ class _LandingPageState extends State<LandingPage> {
),
).paddingSymmetrical(24.w, 0.h)
: SizedBox.shrink(),
// Offers And Discounts Carousel - Auto-scrolling from right to left
appState.isAuthenticated && appState.isEnabledOffersAndDiscountsCarousel
? Column(
children: [
SizedBox(height: 12.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
("${LocaleKeys.offers.tr(context: context)} & ${LocaleKeys.discount.tr(context: context)}").toText16(isBold: true),
Row(
children: [
LocaleKeys.viewAll.tr(context: context).toText14(color: AppColors.primaryRedColor, isBold: true),
SizedBox(width: 2.h),
Icon(Icons.arrow_forward_ios, color: AppColors.primaryRedColor, size: 14.h),
],
),
],
).paddingSymmetrical(24.h, 0.h).onPress(() {
Navigator.of(context).push(CustomPageRoute(page: OffersAndDiscountsPage()));
}),
SizedBox(height: 16.h),
OffersAndDiscountsCarousel().paddingSymmetrical(24.h, 0.h),
SizedBox(height: 18.h),
],
)
: SizedBox.shrink(),
appState.isAuthenticated
? Column(
children: [

@ -30,6 +30,7 @@ import 'package:hmg_patient_app_new/features/my_appointments/models/resp_models/
import 'package:hmg_patient_app_new/features/my_appointments/my_appointments_view_model.dart';
import 'package:hmg_patient_app_new/features/my_invoices/my_invoices_view_model.dart';
import 'package:hmg_patient_app_new/features/prescriptions/prescriptions_view_model.dart';
import 'package:hmg_patient_app_new/features/profile_settings/profile_settings_view_model.dart';
import 'package:hmg_patient_app_new/features/todo_section/todo_section_view_model.dart';
import 'package:hmg_patient_app_new/features/water_monitor/water_monitor_view_model.dart';
import 'package:hmg_patient_app_new/generated/locale_keys.g.dart';
@ -215,11 +216,19 @@ class _MedicalFilePageState extends State<MedicalFilePage> {
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
UserAvatarWidget(
width: 56.h,
height: 56.h,
fit: BoxFit.cover,
isCircular: true,
Selector<ProfileSettingsViewModel, String?>(
selector: (_, profileVM) => profileVM.profileImageData ?? appState.getProfileImageData,
shouldRebuild: (previous, next) => previous != next,
builder: (context, profileImageData, child) {
return UserAvatarWidget(
key: ValueKey('medical_avatar_${profileImageData?.hashCode ?? 0}'),
width: 56.h,
height: 56.h,
fit: BoxFit.cover,
isCircular: true,
customProfileImageData: profileImageData,
);
},
),
SizedBox(width: 8.w),
Column(
@ -262,9 +271,7 @@ class _MedicalFilePageState extends State<MedicalFilePage> {
runSpacing: 4.h,
children: [
AppCustomChipWidget(
labelText: LocaleKeys.ageYearsOld.tr(
namedArgs: {'age': '${appState.getAuthenticatedUser()!.age}', 'yearsOld': LocaleKeys.yearsOld.tr(context: context)},
context: context),
labelText: LocaleKeys.ageYearsOld.tr(namedArgs: {'age': '${appState.getAuthenticatedUser()!.age}', 'yearsOld': LocaleKeys.yearsOld.tr(context: context)}, context: context),
labelPadding: EdgeInsetsDirectional.only(start: 8.w, end: 8.w),
),
AppCustomChipWidget(
@ -307,14 +314,9 @@ class _MedicalFilePageState extends State<MedicalFilePage> {
onChipTap: () {
if (!insuranceVM.isInsuranceActive) {
insuranceVM.setIsInsuranceUpdateDetailsLoading(true);
insuranceVM.getPatientInsuranceDetailsForUpdate(appState.getAuthenticatedUser()!.patientId.toString(),
appState.getAuthenticatedUser()!.patientIdentificationNo.toString());
showCommonBottomSheetWithoutHeight(context,
child: PatientInsuranceCardUpdateCard(),
callBackFunc: () {},
title: "",
isCloseButtonVisible: false,
isFullScreen: false);
insuranceVM.getPatientInsuranceDetailsForUpdate(
appState.getAuthenticatedUser()!.patientId.toString(), appState.getAuthenticatedUser()!.patientIdentificationNo.toString());
showCommonBottomSheetWithoutHeight(context, child: PatientInsuranceCardUpdateCard(), callBackFunc: () {}, title: "", isCloseButtonVisible: false, isFullScreen: false);
// showCommonBottomSheetWithoutHeight(
// title: LocaleKeys.notice.tr(context: navigationService.navigatorKey.currentContext!),
// navigationService.navigatorKey.currentContext!,
@ -488,13 +490,10 @@ class _MedicalFilePageState extends State<MedicalFilePage> {
getSelectedTabData(0),
],
),
ExpandableListItem(
title: LocaleKeys.medicalReports.tr(context: context).toText18(isBold: true),
expandedBackgroundColor: Colors.transparent,
children: [
SizedBox(height: 10.h),
getSelectedTabData(2),
]),
ExpandableListItem(title: LocaleKeys.medicalReports.tr(context: context).toText18(isBold: true), expandedBackgroundColor: Colors.transparent, children: [
SizedBox(height: 10.h),
getSelectedTabData(2),
]),
ExpandableListItem(
title: LocaleKeys.insuranceAndPayments.tr(context: context).toText18(isBold: true),
expandedBackgroundColor: Colors.transparent,
@ -598,14 +597,9 @@ class _MedicalFilePageState extends State<MedicalFilePage> {
text: "${LocaleKeys.updateInsurance.tr(context: context)} ${LocaleKeys.updateInsuranceSubtitle.tr(context: context)}",
onPressed: () {
insuranceViewModel.setIsInsuranceUpdateDetailsLoading(true);
insuranceViewModel.getPatientInsuranceDetailsForUpdate(appState.getAuthenticatedUser()!.patientId.toString(),
appState.getAuthenticatedUser()!.patientIdentificationNo.toString());
showCommonBottomSheetWithoutHeight(context,
child: PatientInsuranceCardUpdateCard(),
callBackFunc: () {},
title: "",
isCloseButtonVisible: false,
isFullScreen: false);
insuranceViewModel.getPatientInsuranceDetailsForUpdate(
appState.getAuthenticatedUser()!.patientId.toString(), appState.getAuthenticatedUser()!.patientIdentificationNo.toString());
showCommonBottomSheetWithoutHeight(context, child: PatientInsuranceCardUpdateCard(), callBackFunc: () {}, title: "", isCloseButtonVisible: false, isFullScreen: false);
},
backgroundColor: AppColors.bgGreenColor.withOpacity(0.20),
borderColor: AppColors.bgGreenColor.withOpacity(0.0),
@ -736,8 +730,7 @@ class _MedicalFilePageState extends State<MedicalFilePage> {
? Container(
padding: EdgeInsets.all(12.w),
width: MediaQuery.of(context).size.width,
decoration:
RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 12.r, hasShadow: false),
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 12.r, hasShadow: false),
child: Column(
children: [
Utils.buildSvgWithAssets(icon: AppAssets.home_calendar_icon, width: 32.h, height: 32.h),
@ -896,8 +889,7 @@ class _MedicalFilePageState extends State<MedicalFilePage> {
? const CommonShimmerWidget().paddingSymmetrical(0.w, 0.h)
: prescriptionVM.patientPrescriptionOrders.isNotEmpty
? Container(
decoration:
RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 20.r, hasShadow: false),
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 20.r, hasShadow: false),
child: Padding(
padding: EdgeInsets.all(16.w),
child: Column(
@ -934,15 +926,13 @@ class _MedicalFilePageState extends State<MedicalFilePage> {
spacing: 3.w,
runSpacing: 4.w,
children: [
AppCustomChipWidget(
labelText: prescriptionVM.patientPrescriptionOrders[index].clinicDescription!),
AppCustomChipWidget(labelText: prescriptionVM.patientPrescriptionOrders[index].clinicDescription!),
Directionality(
textDirection: ui.TextDirection.ltr,
child: AppCustomChipWidget(
icon: AppAssets.doctor_calendar_icon,
labelText: DateUtil.formatDateToDate(
DateUtil.convertStringToDate(
prescriptionVM.patientPrescriptionOrders[index].appointmentDate),
DateUtil.convertStringToDate(prescriptionVM.patientPrescriptionOrders[index].appointmentDate),
false,
),
isEnglishOnly: true,
@ -956,20 +946,14 @@ class _MedicalFilePageState extends State<MedicalFilePage> {
// SizedBox(width: 40.h),
Transform.flip(
flipX: appState.isArabic(),
child: Utils.buildSvgWithAssets(
icon: AppAssets.forward_arrow_icon_small,
width: 15.w,
height: 15.h,
fit: BoxFit.contain,
iconColor: AppColors.textColor)),
child:
Utils.buildSvgWithAssets(icon: AppAssets.forward_arrow_icon_small, width: 15.w, height: 15.h, fit: BoxFit.contain, iconColor: AppColors.textColor)),
],
).onPress(() {
prescriptionVM.setPrescriptionsDetailsLoading();
Navigator.of(context).push(
CustomPageRoute(
page: PrescriptionDetailPage(
isFromAppointments: false,
prescriptionsResponseModel: prescriptionVM.patientPrescriptionOrders[index]),
page: PrescriptionDetailPage(isFromAppointments: false, prescriptionsResponseModel: prescriptionVM.patientPrescriptionOrders[index]),
),
);
}),
@ -1131,10 +1115,7 @@ class _MedicalFilePageState extends State<MedicalFilePage> {
SizedBox(height: 8.h),
SizedBox(
width: 80.w,
child: (myAppointmentsVM.patientMyDoctorsList[index].doctorName)
.toString()
.toText12(isBold: true, isCenter: true, maxLine: 2)
.toShimmer2(isShow: false),
child: (myAppointmentsVM.patientMyDoctorsList[index].doctorName).toString().toText12(isBold: true, isCenter: true, maxLine: 2).toShimmer2(isShow: false),
),
],
),
@ -1149,8 +1130,7 @@ class _MedicalFilePageState extends State<MedicalFilePage> {
LoaderBottomSheet.hideLoader();
Navigator.of(context).push(
CustomPageRoute(
page: DoctorProfilePage(
isDoctorAllowedToBook: !(myAppointmentsVM.patientMyDoctorsList[index].isLiveCareClinic ?? false)),
page: DoctorProfilePage(isDoctorAllowedToBook: !(myAppointmentsVM.patientMyDoctorsList[index].isLiveCareClinic ?? false)),
),
);
}, onError: (err) {
@ -1287,13 +1267,8 @@ class _MedicalFilePageState extends State<MedicalFilePage> {
);
}),
SizedBox(height: 16.h),
Selector<MedicalFileViewModel,
({bool isLoading, List<PatientMedicalReportResponseModel> listRequest, List<PatientMedicalReportResponseModel> listReady})>(
selector: (context, vm) => (
isLoading: vm.isPatientMedicalReportsListLoading,
listRequest: vm.patientMedicalReportRequestedList,
listReady: vm.patientMedicalReportReadyList
),
Selector<MedicalFileViewModel, ({bool isLoading, List<PatientMedicalReportResponseModel> listRequest, List<PatientMedicalReportResponseModel> listReady})>(
selector: (context, vm) => (isLoading: vm.isPatientMedicalReportsListLoading, listRequest: vm.patientMedicalReportRequestedList, listReady: vm.patientMedicalReportReadyList),
builder: (context, data, _) {
return MedicalReportCard(isLoading: data.isLoading, listRequest: data.listRequest, listReady: data.listReady);
},
@ -1549,17 +1524,11 @@ class _MedicalFilePageState extends State<MedicalFilePage> {
child: _buildVitalSignCard(
icon: AppAssets.bloodPressure,
label: LocaleKeys.bloodPressure.tr(context: context),
value: (vitalSign.bloodPressureLower != null &&
vitalSign.bloodPressureHigher != null &&
vitalSign.bloodPressureLower != 0 &&
vitalSign.bloodPressureHigher != 0)
value: (vitalSign.bloodPressureLower != null && vitalSign.bloodPressureHigher != null && vitalSign.bloodPressureLower != 0 && vitalSign.bloodPressureHigher != 0)
? "${vitalSign.bloodPressureHigher}/${vitalSign.bloodPressureLower}"
: '--',
unit: '',
status: (vitalSign.bloodPressureLower != null &&
vitalSign.bloodPressureHigher != null &&
vitalSign.bloodPressureLower != 0 &&
vitalSign.bloodPressureHigher != 0)
status: (vitalSign.bloodPressureLower != null && vitalSign.bloodPressureHigher != null && vitalSign.bloodPressureLower != 0 && vitalSign.bloodPressureHigher != 0)
? _getBloodPressureStatus(
systolic: vitalSign.bloodPressureHigher,
diastolic: vitalSign.bloodPressureLower,
@ -1643,8 +1612,7 @@ class _MedicalFilePageState extends State<MedicalFilePage> {
weight: FontWeight.w600,
),
),
Utils.buildSvgWithAssets(
icon: getIt.get<AppState>().isArabic() ? AppAssets.arrow_back : AppAssets.arrow_forward, width: 18.w, height: 18.h),
Utils.buildSvgWithAssets(icon: getIt.get<AppState>().isArabic() ? AppAssets.arrow_back : AppAssets.arrow_forward, width: 18.w, height: 18.h),
],
),
Spacer(),

@ -92,7 +92,10 @@ class _FamilyMedicalScreenState extends State<FamilyMedicalScreen> {
Selector<MedicalFileViewModel, ({int selectedIndex, List<FamilyFileResponseModelLists> patientFiles, List<FamilyFileResponseModelLists> pendingFiles})>(
selector: (_, model) => (selectedIndex: model.getSelectedFamilyFileTabIndex, patientFiles: model.patientFamilyFiles, pendingFiles: model.pendingFamilyFiles),
shouldRebuild: (previous, next) {
return previous.selectedIndex != next.selectedIndex || previous.patientFiles.length != next.patientFiles.length || previous.pendingFiles.length != next.pendingFiles.length;
// Only rebuild if something actually changed
return previous.selectedIndex != next.selectedIndex ||
!identical(previous.patientFiles, next.patientFiles) ||
!identical(previous.pendingFiles, next.pendingFiles);
},
builder: (context, data, child) => getFamilyTabs(index: data.selectedIndex, patientFiles: data.patientFiles, pendingFiles: data.pendingFiles),
),

@ -1,6 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@ -16,11 +14,13 @@ import 'package:hmg_patient_app_new/extensions/widget_extensions.dart';
import 'package:hmg_patient_app_new/features/habib_wallet/habib_wallet_view_model.dart';
import 'package:hmg_patient_app_new/features/insurance/insurance_view_model.dart';
import 'package:hmg_patient_app_new/features/medical_file/models/family_file_response_model.dart';
import 'package:hmg_patient_app_new/features/profile_picture/profile_picture_view_model.dart';
import 'package:hmg_patient_app_new/features/profile_settings/profile_settings_view_model.dart';
import 'package:hmg_patient_app_new/generated/locale_keys.g.dart';
import 'package:hmg_patient_app_new/presentation/insurance/widgets/insurance_update_details_card.dart';
import 'package:hmg_patient_app_new/services/dialog_service.dart';
import 'package:hmg_patient_app_new/services/navigation_service.dart';
import 'package:hmg_patient_app_new/services/permission_service.dart';
import 'package:hmg_patient_app_new/theme/colors.dart';
import 'package:hmg_patient_app_new/widgets/buttons/custom_button.dart';
import 'package:hmg_patient_app_new/widgets/chip/app_custom_chip_widget.dart';
@ -29,7 +29,6 @@ import 'package:hmg_patient_app_new/widgets/common_bottom_sheet.dart';
import 'package:hmg_patient_app_new/widgets/expandable_list_widget.dart';
import 'package:hmg_patient_app_new/widgets/user_avatar_widget.dart';
import 'package:hmg_patient_app_new/widgets/image_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
class FamilyCards extends StatefulWidget {
@ -63,10 +62,9 @@ class FamilyCards extends StatefulWidget {
class _FamilyCardsState extends State<FamilyCards> {
AppState appState = getIt<AppState>();
final PermissionService _permissionService = getIt<PermissionService>();
late InsuranceViewModel insuranceViewModel;
late ProfileSettingsViewModel profileSettingsViewModel;
File? _selectedImage;
bool _isUploadingImage = false;
@override
void initState() {
@ -77,300 +75,27 @@ class _FamilyCardsState extends State<FamilyCards> {
}
void _pickImage() {
// Show image picker options without checking permissions first
ImageOptions.showImageOptionsNew(
context,
false, // Don't show files option, only camera and gallery
(base64String, file) async {
try {
// Compress and resize the image
final compressedFile = await ImageCompressionHelper.compressAndResizeImage(file);
File finalFile;
String finalBase64;
if (compressedFile == null) {
// Fallback: use original image if compression fails
final originalSize = await file.length();
final maxSize = 1048576; // 1MB
if (originalSize > maxSize) {
if (mounted) {
Utils.showToast(
LocaleKeys.imageSizeTooLarge.tr(context: context),
);
}
return;
}
finalFile = file;
var bytes = await file.readAsBytes();
finalBase64 = base64.encode(bytes);
} else {
// Check compressed file size
final fileSize = await compressedFile.length();
final maxSize = 1048576; // 1MB
if (fileSize > maxSize) {
if (mounted) {
Utils.showToast(
LocaleKeys.imageSizeTooLarge.tr(context: context),
);
}
return;
}
finalFile = compressedFile;
var bytes = await compressedFile.readAsBytes();
finalBase64 = base64.encode(bytes);
}
if (mounted) {
setState(() {
_selectedImage = finalFile;
});
// Upload the image
_uploadImage(finalBase64);
}
} catch (e, stackTrace) {
if (mounted) {
Utils.showToast(
LocaleKeys.failedToProcessImage.tr(context: context),
);
}
}
},
checkCameraPermission: _checkCameraPermission,
checkGalleryPermission: _checkGalleryPermission,
);
}
Future<bool> _checkCameraPermission() async {
try {
print('=== Checking camera permission ===');
// First check current status
PermissionStatus currentStatus = await Permission.camera.status;
print('Current camera permission status: $currentStatus');
// If already granted, return true
if (currentStatus.isGranted) {
print('✅ Camera permission already granted');
return true;
}
// If denied or permanently denied, show settings dialog
if (currentStatus.isDenied || currentStatus.isPermanentlyDenied) {
// Request permission first
PermissionStatus newStatus = await Permission.camera.request();
print('Camera permission after request: $newStatus');
final profilePictureViewModel = context.read<ProfilePictureViewModel>();
if (newStatus.isGranted) {
print('✅ Camera permission granted');
return true;
}
// Still denied - show settings dialog
print('⚠️ Camera permission denied - showing settings dialog');
profilePictureViewModel.pickImage(
context,
showImagePicker: ImageOptions.showImageOptionsNew,
compressImage: ImageCompressionHelper.compressAndResizeImage,
onSuccess: (data) {
if (mounted) {
showCommonBottomSheetWithoutHeight(
title: LocaleKeys.notice.tr(context: context),
context,
child: Utils.getWarningWidget(
loadingText: LocaleKeys.cameraPermissionMessage.tr(context: context),
isShowActionButtons: true,
onCancelTap: () {
Navigator.pop(context);
},
onConfirmTap: () async {
openAppSettings();
},
),
callBackFunc: () {},
isFullScreen: false,
isCloseButtonVisible: true,
);
Utils.showToast(LocaleKeys.profileImageUpdatedSuccessfully.tr(context: context));
}
return false;
}
// Request permission for the first time
PermissionStatus newStatus = await Permission.camera.request();
print('Camera permission after request: $newStatus');
if (newStatus.isGranted) {
print('✅ Camera permission granted');
return true;
}
// Denied - show settings dialog
print('❌ Camera permission denied - showing settings dialog');
if (mounted) {
showCommonBottomSheetWithoutHeight(
title: LocaleKeys.notice.tr(context: context),
context,
child: Utils.getWarningWidget(
loadingText: LocaleKeys.cameraPermissionMessage.tr(context: context),
isShowActionButtons: true,
onCancelTap: () {
Navigator.pop(context);
},
onConfirmTap: () async {
openAppSettings();
},
),
callBackFunc: () {},
isFullScreen: false,
isCloseButtonVisible: true,
);
}
return false;
} catch (e) {
print('❌ Error checking camera permission: $e');
if (mounted) {
Utils.showToast(
LocaleKeys.failedToCheckPermissions.tr(context: context),
);
}
return false;
}
}
Future<bool> _checkGalleryPermission() async {
try {
print('=== Checking gallery permission ===');
// Determine which permission to check based on platform and Android version
Permission galleryPermission;
if (Platform.isIOS) {
galleryPermission = Permission.photos;
} else {
// Android: use photos permission which handles API level differences automatically
galleryPermission = Permission.photos;
}
// First check current status
PermissionStatus currentStatus = await galleryPermission.status;
print('Current gallery permission status: $currentStatus');
// If already granted, return true
if (currentStatus.isGranted || currentStatus.isLimited) {
print('✅ Gallery permission already granted');
return true;
}
// If denied or permanently denied, request permission first
if (currentStatus.isDenied || currentStatus.isPermanentlyDenied) {
// Request permission first
PermissionStatus newStatus = await galleryPermission.request();
print('Gallery permission after request: $newStatus');
if (newStatus.isGranted || newStatus.isLimited) {
print('✅ Gallery permission granted');
return true;
}
// Still denied - show settings dialog
print('⚠️ Gallery permission denied - showing settings dialog');
},
onError: (error) {
if (mounted) {
showCommonBottomSheetWithoutHeight(
title: LocaleKeys.notice.tr(context: context),
context,
child: Utils.getWarningWidget(
loadingText: LocaleKeys.galleryPermissionMessage.tr(context: context),
isShowActionButtons: true,
onCancelTap: () {
Navigator.pop(context);
},
onConfirmTap: () async {
openAppSettings();
},
),
callBackFunc: () {},
isFullScreen: false,
isCloseButtonVisible: true,
);
}
return false;
}
// Request permission for the first time
PermissionStatus newStatus = await galleryPermission.request();
print('Gallery permission after request: $newStatus');
if (newStatus.isGranted || newStatus.isLimited) {
print('✅ Gallery permission granted');
return true;
}
// Denied - show settings dialog
print('❌ Gallery permission denied - showing settings dialog');
if (mounted) {
showCommonBottomSheetWithoutHeight(
title: LocaleKeys.notice.tr(context: context),
context,
child: Utils.getWarningWidget(
loadingText: LocaleKeys.galleryPermissionMessage.tr(context: context),
isShowActionButtons: true,
onCancelTap: () {
Navigator.pop(context);
},
onConfirmTap: () async {
openAppSettings();
},
),
callBackFunc: () {},
isFullScreen: false,
isCloseButtonVisible: true,
);
}
return false;
} catch (e) {
print('❌ Error checking gallery permission: $e');
if (mounted) {
Utils.showToast(
LocaleKeys.failedToCheckPermissions.tr(context: context),
);
}
return false;
}
}
void _uploadImage(String base64String) {
final patientID = appState.getAuthenticatedUser()?.patientId;
if (patientID != null) {
setState(() {
_isUploadingImage = true;
});
profileSettingsViewModel.uploadProfileImage(
patientID: patientID,
imageData: base64String,
onSuccess: (data) {
if (mounted) {
setState(() {
_selectedImage = null; // Clear selected image after successful upload
_isUploadingImage = false;
});
Utils.showToast(
LocaleKeys.profileImageUpdatedSuccessfully.tr(context: context),
);
}
},
onError: (error) {
if (mounted) {
setState(() {
_isUploadingImage = false;
});
}
Utils.showToast(error);
},
);
}
}
},
imageSizeTooLargeMessage: LocaleKeys.imageSizeTooLarge.tr(context: context),
failedToProcessImageMessage: LocaleKeys.failedToProcessImage.tr(context: context),
checkCameraPermission: (ctx) => _permissionService.checkCameraPermission(ctx),
checkGalleryPermission: (ctx) => _permissionService.checkGalleryPermission(ctx),
);
}
double _calculateAspectRatio(BuildContext context) {
@ -536,40 +261,44 @@ class _FamilyCardsState extends State<FamilyCards> {
Positioned(
right: 0,
bottom: 0,
child: GestureDetector(
onTap: () {
if (!_isUploadingImage) {
_pickImage();
}
},
child: Container(
width: 20.w,
height: 20.h,
decoration: BoxDecoration(
color: AppColors.primaryRedColor,
shape: BoxShape.circle,
border: Border.all(
color: AppColors.whiteColor,
width: 1.5.w,
),
),
child: _isUploadingImage
? SizedBox(
width: 10.w,
height: 10.h,
child: CircularProgressIndicator(
strokeWidth: 1.5.w,
valueColor: AlwaysStoppedAnimation<Color>(
AppColors.whiteColor,
),
).paddingAll(4.w),
)
: Icon(
Icons.camera_alt,
child: Consumer<ProfileSettingsViewModel>(
builder: (context, profileVm, _) {
return GestureDetector(
onTap: () {
if (!profileVm.isUploadingProfileImage) {
_pickImage();
}
},
child: Container(
width: 20.w,
height: 20.h,
decoration: BoxDecoration(
color: AppColors.primaryRedColor,
shape: BoxShape.circle,
border: Border.all(
color: AppColors.whiteColor,
size: 10.w,
width: 1.5.w,
),
),
),
child: profileVm.isUploadingProfileImage
? SizedBox(
width: 10.w,
height: 10.h,
child: CircularProgressIndicator(
strokeWidth: 1.5.w,
valueColor: AlwaysStoppedAnimation<Color>(
AppColors.whiteColor,
),
).paddingAll(4.w),
)
: Icon(
Icons.camera_alt,
color: AppColors.whiteColor,
size: 10.w,
),
),
);
},
),
),
],

@ -0,0 +1,262 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:hmg_patient_app_new/core/app_assets.dart';
import 'package:hmg_patient_app_new/core/app_state.dart';
import 'package:hmg_patient_app_new/core/dependencies.dart';
import 'package:hmg_patient_app_new/core/utils/size_utils.dart';
import 'package:hmg_patient_app_new/core/utils/utils.dart';
import 'package:hmg_patient_app_new/theme/colors.dart';
import 'package:hmg_patient_app_new/widgets/buttons/custom_button.dart';
import 'package:share_plus/share_plus.dart';
class OfferAndDiscountsFullScreenSwiperPage extends StatefulWidget {
final List<String>? images;
final int initialIndex;
const OfferAndDiscountsFullScreenSwiperPage({
super.key,
this.images,
this.initialIndex = 0,
});
@override
State<OfferAndDiscountsFullScreenSwiperPage> createState() => _OfferAndDiscountsFullScreenSwiperPageState();
}
class _OfferAndDiscountsFullScreenSwiperPageState extends State<OfferAndDiscountsFullScreenSwiperPage> {
late PageController _pageController;
late int _currentPage;
late List<String> _promoImages;
@override
void initState() {
super.initState();
// Use provided images or default demo images
_promoImages = widget.images ??
[
'assets/images/offersanddiscounts/promo.jpg',
'assets/images/offersanddiscounts/promo.jpg',
'assets/images/offersanddiscounts/promo.jpg',
'assets/images/offersanddiscounts/promo.jpg',
];
_currentPage = widget.initialIndex;
_pageController = PageController(initialPage: widget.initialIndex);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFADA6D0),
body: SafeArea(
child: Stack(
children: [
// Main content area
Column(
children: [
SizedBox(height: 26.h),
// Page indicators
_buildPageIndicators(),
SizedBox(height: 70.h),
// Swipeable image section
Expanded(
child: PageView.builder(
controller: _pageController,
onPageChanged: (index) {
setState(() {
_currentPage = index;
});
},
itemCount: _promoImages.length,
itemBuilder: (context, index) {
return Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 0.h),
child: Transform.flip(
flipX: getIt.get<AppState>().isArabic(),
child: ClipRRect(
borderRadius: BorderRadius.circular(16.r),
child: Image.asset(
_promoImages[index],
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16.r),
),
child: Center(
child: Icon(
Icons.image_outlined,
size: 100.h,
color: Colors.white.withValues(alpha: 0.5),
),
),
);
},
),
),
),
),
);
},
),
),
SizedBox(height: 70.h),
// Bottom action buttons
_buildBottomActions(),
SizedBox(height: 12.h),
],
),
// Close button (top right)
Positioned(
top: 26.h + 14.h, // After indicator spacing
right: 24.w,
child: _buildCloseButton()),
],
),
),
);
}
Widget _buildPageIndicators() {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 24.w),
child: Row(
children: List.generate(
_promoImages.length,
(index) => Expanded(
child: Container(
margin: EdgeInsets.only(
right: index < _promoImages.length - 1 ? 4.w : 0,
),
height: 4.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2.r),
// Fill bar if it's current page or any previous page
color: index <= _currentPage ? Colors.white : const Color(0x33000000), // 20% opacity
),
),
),
),
),
);
}
Widget _buildCloseButton() {
return GestureDetector(
onTap: () {
Navigator.of(context).pop();
},
child: Container(
width: 32.w,
height: 32.h,
decoration: BoxDecoration(
color: const Color(0x33FFFFFF), // 20% opacity
borderRadius: BorderRadius.circular(8.r),
),
child: Center(
child: Icon(
Icons.close,
color: AppColors.textColor,
size: 20.h,
),
),
),
);
}
Widget _buildBottomActions() {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 24.w),
child: Row(
children: [
// Buy Now button (takes remaining space)
Expanded(
child: CustomButton(
text: 'Buy Now',
icon: AppAssets.shoppingCart,
iconColor: AppColors.whiteColor,
onPressed: () {
// Handle buy now action
},
backgroundColor: AppColors.primaryRedColor,
borderColor: AppColors.primaryRedColor,
textColor: AppColors.whiteColor,
fontSize: 16.f,
isBold: true,
borderRadius: 12.r,
height: 56.h,
),
),
SizedBox(width: 12.w),
// Next button
_buildIconButton(
svgIcon: AppAssets.nextSwiper,
onTap: () {
if (_currentPage < _promoImages.length - 1) {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
},
),
SizedBox(width: 12.w),
_buildIconButton(
svgIcon: AppAssets.share,
onTap: () async {
await Share.share('Check out this amazing offer!');
}),
],
),
);
}
Widget _buildIconButton({
IconData? icon,
String? svgIcon,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 56.w,
height: 56.h,
decoration: BoxDecoration(
color: const Color(0x57303957), // 34% opacity approximation
borderRadius: BorderRadius.circular(12.r),
),
child: Center(
child: svgIcon != null
? Utils.buildSvgWithAssets(
icon: svgIcon,
iconColor: AppColors.whiteColor,
width: 24.h,
height: 24.h,
)
: Icon(
icon!,
color: AppColors.whiteColor,
size: 24.h,
),
),
),
);
}
}

@ -0,0 +1,223 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hmg_patient_app_new/core/app_assets.dart';
import 'package:hmg_patient_app_new/core/utils/size_utils.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/offers_and_discounts/models/offers_and_discounts_response_model.dart';
import 'package:hmg_patient_app_new/generated/locale_keys.g.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:hmg_patient_app_new/widgets/chip/app_custom_chip_widget.dart';
class OffersAndDiscountsDetailedPage extends StatelessWidget {
final OffersAndDiscountsResponseModel offer;
const OffersAndDiscountsDetailedPage({
super.key,
required this.offer,
});
String _formatDateString(String? dateString) {
if (dateString == null || dateString.isEmpty) return '';
try {
DateTime date = DateTime.parse(dateString);
return DateFormat('d MMM, yyyy').format(date);
} catch (e) {
return dateString;
}
}
// Determine the status of the offer based on dates and isActive flag
String _getOfferStatus(String? endDate, bool? isActive) {
if (endDate == null || endDate.isEmpty) return 'Expired';
try {
DateTime end = DateTime.parse(endDate);
DateTime now = DateTime.now();
if (now.isAfter(end)) {
return 'Expired';
} else {
if (isActive == true) {
return 'Active';
} else {
return 'Availed';
}
}
} catch (e) {
return 'Expired';
}
}
Color _getStatusBgColor(String status) {
switch (status) {
case 'Active':
return AppColors.successColor.withValues(alpha: 0.1);
case 'Availed':
return AppColors.infoColor.withValues(alpha: 0.1);
case 'Expired':
return AppColors.errorColor.withValues(alpha: 0.1);
default:
return AppColors.greyColor;
}
}
Color _getStatusTextColor(String status) {
switch (status) {
case 'Active':
return AppColors.successColor;
case 'Availed':
return AppColors.infoColor;
case 'Expired':
return AppColors.errorColor;
default:
return AppColors.textColor;
}
}
@override
Widget build(BuildContext context) {
final status = _getOfferStatus(offer.endDate, offer.isActive);
return CollapsingListView(
title: "${LocaleKeys.offers.tr(context: context)} ${LocaleKeys.details.tr(context: context)}",
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Offer Image
if (offer.imageUrl != null)
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(24.h),
child: Image.network(
offer.imageUrl!,
width: double.infinity,
height: 250.h,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
height: 250.h,
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(
color: AppColors.greyColor,
borderRadius: 24.h,
),
child: Center(
child: Icon(Icons.image_not_supported, size: 64.h),
),
);
},
),
),
// Discount badge at top right on image
// if (offer.discount != null)
// Positioned(
// top: 16.h,
// right: 16.w,
// child: Container(
// padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
// decoration: BoxDecoration(
// color: AppColors.primaryRedColor,
// borderRadius: BorderRadius.circular(8.r),
// ),
// child: Text(
// offer.discount!,
// style: TextStyle(
// fontSize: 14.f,
// fontWeight: FontWeight.w700,
// color: AppColors.whiteColor,
// ),
// ),
// ),
// ),
],
),
SizedBox(height: 24.h),
// Title
if (offer.title != null)
offer.title!.toText24(
isBold: true,
fontWeight: FontWeight.w700,
),
SizedBox(height: 16.h),
// Chips Row (Valid Till and Status)
Wrap(
spacing: 12.w,
runSpacing: 12.h,
children: [
// Valid till chip
if (offer.endDate != null)
AppCustomChipWidget(
labelText: "Valid till ${_formatDateString(offer.endDate)}",
backgroundColor: AppColors.chipBgColor,
textColor: AppColors.textColor,
icon: AppAssets.calendar,
iconSize: 14.h,
iconColor: AppColors.textColor,
isEnglishOnly: true,
),
// Status chip
AppCustomChipWidget(
labelText: status,
backgroundColor: _getStatusBgColor(status),
textColor: _getStatusTextColor(status),
),
// Facility type chip
if (offer.facilityType != null)
AppCustomChipWidget(
labelText: offer.facilityType!,
backgroundColor: AppColors.chipBgColor,
textColor: AppColors.textColor,
),
],
),
SizedBox(height: 32.h),
// Description Heading
LocaleKeys.description.tr(context: context).toText18(
isBold: true,
weight: FontWeight.w700,
),
SizedBox(height: 12.h),
// Description Content
if (offer.description != null)
offer.description!.toText16(
color: AppColors.greyTextColor,
height: 1.5,
),
SizedBox(height: 32.h),
// Buy Now Button
CustomButton(
text: "Buy Now",
icon: AppAssets.shoppingCart,
iconColor: AppColors.whiteColor,
onPressed: () {
// Handle buy now action
},
backgroundColor: AppColors.primaryRedColor,
borderColor: AppColors.primaryRedColor,
textColor: AppColors.whiteColor,
fontSize: 16.f,
isBold: true,
borderRadius: 12.r,
height: 56.h,
),
SizedBox(height: 24.h),
],
).paddingSymmetrical(24.h, 0.h),
),
);
}
}

@ -0,0 +1,292 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:hmg_patient_app_new/core/app_assets.dart';
import 'package:hmg_patient_app_new/core/app_state.dart';
import 'package:hmg_patient_app_new/core/dependencies.dart';
import 'package:hmg_patient_app_new/core/utils/size_utils.dart';
import 'package:hmg_patient_app_new/core/utils/utils.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/offers_and_discounts/offers_and_discounts_view_model.dart';
import 'package:hmg_patient_app_new/generated/locale_keys.g.dart';
import 'package:hmg_patient_app_new/presentation/offers_and_discounts/offers_and_discounts_detailed_page.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:hmg_patient_app_new/widgets/chip/app_custom_chip_widget.dart';
import 'package:hmg_patient_app_new/widgets/routes/custom_page_route.dart';
import 'package:provider/provider.dart';
class OffersAndDiscountsHistoryPage extends StatefulWidget {
const OffersAndDiscountsHistoryPage({super.key});
@override
State<OffersAndDiscountsHistoryPage> createState() => _OffersAndDiscountsHistoryPageState();
}
class _OffersAndDiscountsHistoryPageState extends State<OffersAndDiscountsHistoryPage> {
late OffersAndDiscountsViewModel offersAndDiscountsViewModel;
late AppState appState;
@override
void initState() {
scheduleMicrotask(() {
offersAndDiscountsViewModel.getOffersAndDiscountsHistory();
});
super.initState();
}
String _formatDateString(String? dateString) {
if (dateString == null || dateString.isEmpty) return '';
try {
DateTime date = DateTime.parse(dateString);
return DateFormat('d MMM, yyyy').format(date);
} catch (e) {
return dateString;
}
}
// Determine the status of the offer based on dates and isActive flag
String _getOfferStatus(String? endDate, bool? isActive) {
if (endDate == null || endDate.isEmpty) return 'Expired';
try {
DateTime end = DateTime.parse(endDate);
DateTime now = DateTime.now();
if (now.isAfter(end)) {
return 'Expired';
} else {
// If end date is in future, check isActive flag
if (isActive == true) {
return 'Active';
} else {
return 'Availed';
}
}
} catch (e) {
return 'Expired';
}
}
Color _getStatusBgColor(String status) {
switch (status) {
case 'Active':
return AppColors.successColor.withValues(alpha: 0.1);
case 'Availed':
return AppColors.infoColor.withValues(alpha: 0.1);
case 'Expired':
return AppColors.errorColor.withValues(alpha: 0.1);
default:
return AppColors.greyColor;
}
}
Color _getStatusTextColor(String status) {
switch (status) {
case 'Active':
return AppColors.successColor;
case 'Availed':
return AppColors.infoColor;
case 'Expired':
return AppColors.errorColor;
default:
return AppColors.textColor;
}
}
@override
Widget build(BuildContext context) {
appState = getIt.get<AppState>();
offersAndDiscountsViewModel = Provider.of<OffersAndDiscountsViewModel>(context, listen: false);
return CollapsingListView(
title: '${LocaleKeys.order.tr(context: context)} ${LocaleKeys.history.tr(context: context)}',
child: SingleChildScrollView(
child: Consumer<OffersAndDiscountsViewModel>(builder: (context, offersAndDiscountVM, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
offersAndDiscountVM.isHistoryLoading
? Container(
height: 200.h,
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(
color: AppColors.whiteColor,
borderRadius: 24.h,
hasShadow: true,
),
child: Center(child: Utils.getLoadingWidget()),
).paddingSymmetrical(24.h, 0.h)
: ListView.separated(
padding: EdgeInsets.only(top: 12.h),
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemCount: offersAndDiscountVM.filteredHistory.isNotEmpty ? offersAndDiscountVM.filteredHistory.length : 1,
itemBuilder: (context, index) {
if (offersAndDiscountVM.filteredHistory.isEmpty) {
return Utils.getNoDataWidget(context, noDataText: LocaleKeys.noDataAvailable.tr(context: context));
}
final offer = offersAndDiscountVM.filteredHistory[index];
final status = _getOfferStatus(offer.endDate, offer.isActive);
return AnimationConfiguration.staggeredList(
position: index,
duration: const Duration(milliseconds: 500),
child: SlideAnimation(
verticalOffset: 100.0,
child: FadeInAnimation(
child: AnimatedContainer(
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(
color: AppColors.whiteColor,
borderRadius: 24.h,
hasShadow: true,
),
child: Stack(
children: [
Padding(
padding: EdgeInsets.all(16.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title with space for status chip
Padding(
padding: EdgeInsets.only(right: 80.w),
child: offer.title?.toText18(
isBold: true,
weight: FontWeight.w700,
) ??
SizedBox(),
),
SizedBox(height: 12.h),
// Info chips
Wrap(
spacing: 8.w,
runSpacing: 8.h,
children: [
// Start Date chip
if (offer.startDate != null)
AppCustomChipWidget(
labelText: _formatDateString(offer.startDate),
backgroundColor: AppColors.chipBgColor,
textColor: AppColors.textColor,
icon: AppAssets.calendar,
iconSize: 12.h,
iconColor: AppColors.textColor,
isEnglishOnly: true,
),
// Hospital/Facility chip
if (offer.facilityType != null)
AppCustomChipWidget(
labelText: offer.facilityType!,
backgroundColor: AppColors.chipBgColor,
textColor: AppColors.textColor,
),
// Purchase date chip (using startDate as purchased date)
if (offer.startDate != null)
AppCustomChipWidget(
labelText: "Purchased ${_formatDateString(offer.startDate)}",
backgroundColor: AppColors.chipBgColor,
textColor: AppColors.textColor,
isEnglishOnly: true,
),
// Valid until chip
if (offer.endDate != null)
AppCustomChipWidget(
labelText: "Valid till ${_formatDateString(offer.endDate)}",
backgroundColor: AppColors.chipBgColor,
textColor: AppColors.textColor,
icon: AppAssets.calendar,
iconSize: 12.h,
iconColor: AppColors.textColor,
isEnglishOnly: true,
),
// Order ID chip (using offer.id)
if (offer.id != null)
AppCustomChipWidget(
labelText: "ID: #${offer.id}",
backgroundColor: AppColors.chipBgColor,
textColor: AppColors.textColor,
isEnglishOnly: true,
),
],
),
SizedBox(height: 16.h),
// Action buttons
Row(
children: [
Expanded(
child: CustomButton(
text: 'View Offer Details',
onPressed: () {
Navigator.of(context).push(
CustomPageRoute(
page: OffersAndDiscountsDetailedPage(
offer: offersAndDiscountVM.filteredOffers[index],
),
),
);
},
backgroundColor: AppColors.errorColor.withValues(alpha: 0.1),
borderColor: Colors.transparent,
textColor: AppColors.errorColor,
fontSize: 12.f,
isBold: true,
borderRadius: 12.r,
height: 40.h,
),
),
SizedBox(width: 12.w),
Expanded(
child: CustomButton(
text: 'View Order Details',
onPressed: () {
// Handle view order details
},
backgroundColor: AppColors.whiteColor,
borderColor: AppColors.textColor,
textColor: AppColors.textColor,
fontSize: 12.f,
isBold: true,
borderRadius: 12.r,
height: 40.h,
),
),
],
),
],
),
),
// Status chip at top right
Positioned(
top: 16.h,
right: 16.w,
child: AppCustomChipWidget(
labelText: status,
backgroundColor: _getStatusBgColor(status),
textColor: _getStatusTextColor(status),
),
),
],
),
).paddingSymmetrical(24.h, 0.h),
),
),
);
},
separatorBuilder: (BuildContext cxt, int index) => SizedBox(height: 16.h),
),
SizedBox(height: 24.h),
],
);
}),
),
);
}
}

@ -0,0 +1,279 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:hmg_patient_app_new/core/app_assets.dart';
import 'package:hmg_patient_app_new/core/app_state.dart';
import 'package:hmg_patient_app_new/core/dependencies.dart';
import 'package:hmg_patient_app_new/core/enums.dart';
import 'package:hmg_patient_app_new/core/utils/size_utils.dart';
import 'package:hmg_patient_app_new/core/utils/utils.dart';
import 'package:hmg_patient_app_new/extensions/widget_extensions.dart';
import 'package:hmg_patient_app_new/features/offers_and_discounts/offers_and_discounts_view_model.dart';
import 'package:hmg_patient_app_new/generated/locale_keys.g.dart';
import 'package:hmg_patient_app_new/presentation/offers_and_discounts/offers_and_discounts_detailed_page.dart';
import 'package:hmg_patient_app_new/presentation/offers_and_discounts/offers_and_discounts_history_page.dart';
import 'package:hmg_patient_app_new/presentation/offers_and_discounts/widgets/offers_and_discount_type_selection_widget.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:hmg_patient_app_new/widgets/chip/app_custom_chip_widget.dart';
import 'package:hmg_patient_app_new/widgets/routes/custom_page_route.dart';
import 'package:provider/provider.dart';
import '../../widgets/input_widget.dart';
class OffersAndDiscountsPage extends StatefulWidget {
const OffersAndDiscountsPage({super.key});
@override
State<OffersAndDiscountsPage> createState() => _OffersAndDiscountsPageState();
}
class _OffersAndDiscountsPageState extends State<OffersAndDiscountsPage> {
late OffersAndDiscountsViewModel offersAndDiscountsViewModel;
late AppState appState;
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
scheduleMicrotask(() {
offersAndDiscountsViewModel.initOffersAndDiscounts();
});
super.initState();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
appState = getIt.get<AppState>();
offersAndDiscountsViewModel = Provider.of<OffersAndDiscountsViewModel>(context, listen: false);
return CollapsingListView(
title: "${LocaleKeys.offers.tr(context: context)} & ${LocaleKeys.discount.tr(context: context)}",
history: () {
Navigator.of(context).push(
CustomPageRoute(
page: OffersAndDiscountsHistoryPage(),
),
);
},
child: SingleChildScrollView(
child: Consumer<OffersAndDiscountsViewModel>(builder: (context, offersAndDiscountVM, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Search Input
TextInputWidget(
labelText: LocaleKeys.search.tr(context: context),
hintText: LocaleKeys.search.tr(context: context),
controller: _searchController,
onChange: (value) {
offersAndDiscountVM.setSearchQuery(value!);
},
isEnable: true,
prefix: null,
autoFocus: false,
isBorderAllowed: false,
keyboardType: TextInputType.text,
isAllowLeadingIcon: true,
selectionType: SelectionTypeEnum.search,
padding: EdgeInsets.symmetric(
vertical: ResponsiveExtension(10).h,
horizontal: ResponsiveExtension(15).h,
),
),
SizedBox(height: 16.h),
OffersAndDiscountTypeSelectionWidget(
selectedOffers: offersAndDiscountVM.selectedFacilities,
onOfferClicked: (selectedValues) {
offersAndDiscountVM.setSelectedFacility(selectedValues);
},
),
SizedBox(height: 16.h),
// Offers Grid
offersAndDiscountVM.isOffersLoading
? GridView.builder(
padding: EdgeInsets.symmetric(horizontal: 24.w),
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16.w,
mainAxisSpacing: 16.h,
childAspectRatio: 0.55, // Adjust this to control card height
),
itemCount: 6,
// Show 6 loading placeholders
itemBuilder: (context, index) {
return Container(
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(
color: AppColors.whiteColor,
borderRadius: 24.h,
hasShadow: true,
),
child: Center(child: Utils.getLoadingWidget()),
);
},
)
: offersAndDiscountVM.filteredOffers.isNotEmpty
? GridView.builder(
padding: EdgeInsets.symmetric(horizontal: 0.w),
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2, crossAxisSpacing: 16.w, mainAxisSpacing: 16.h, childAspectRatio: 0.55),
itemCount: offersAndDiscountVM.filteredOffers.length,
itemBuilder: (context, index) {
return AnimationConfiguration.staggeredGrid(
position: index,
duration: const Duration(milliseconds: 500),
columnCount: 2,
child: ScaleAnimation(
child: FadeInAnimation(
child: AnimatedContainer(
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(
color: AppColors.whiteColor,
borderRadius: 24.h,
hasShadow: true,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (offersAndDiscountVM.filteredOffers[index].imageUrl != null)
Expanded(
flex: 3,
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.vertical(top: Radius.circular(24.h)),
child: SizedBox.expand(
child: Image.network(
offersAndDiscountVM.filteredOffers[index].imageUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
color: AppColors.greyColor,
child: Center(
child: Icon(Icons.image_not_supported, size: 32.h),
),
);
},
),
),
).onPress(() {
Navigator.of(context).push(
CustomPageRoute(
page: OffersAndDiscountsDetailedPage(
offer: offersAndDiscountVM.filteredOffers[index],
),
),
);
}),
// Discount badge at top right
// if (offersAndDiscountVM.filteredOffers[index].discount != null)
// Positioned(
// top: 8.h,
// right: 8.w,
// child: Container(
// padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
// decoration: BoxDecoration(
// color: AppColors.primaryRedColor,
// borderRadius: BorderRadius.circular(6.r),
// ),
// child: Text(
// offersAndDiscountVM.filteredOffers[index].discount!,
// style: TextStyle(
// fontSize: 10.f,
// fontWeight: FontWeight.w700,
// color: AppColors.whiteColor,
// ),
// ),
// ),
// ),
],
),
),
Expanded(
flex: 3,
child: Padding(
padding: EdgeInsets.all(12.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (offersAndDiscountVM.filteredOffers[index].title != null)
Text(
offersAndDiscountVM.filteredOffers[index].title!,
style: TextStyle(
fontSize: 14.f,
fontWeight: FontWeight.w700,
color: AppColors.textColor,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 8.h),
// Valid till chip
if (offersAndDiscountVM.filteredOffers[index].endDate != null)
AppCustomChipWidget(
labelText: "Valid till ${Utils.formatDateToDisplay(offersAndDiscountVM.filteredOffers[index].endDate ?? "")}",
// labelText: "Valid till ${_formatDateString(offersAndDiscountVM.filteredOffers[index].endDate)}",
backgroundColor: AppColors.chipBgColor,
textColor: AppColors.textColor,
icon: AppAssets.calendar,
iconSize: 12.h,
iconColor: AppColors.textColor,
isEnglishOnly: true,
labelPadding: EdgeInsetsDirectional.only(start: 4.w, end: 8.w),
),
SizedBox(height: 20.h),
CustomButton(
text: 'Buy Now',
icon: AppAssets.shoppingCart,
iconColor: AppColors.whiteColor,
onPressed: () {
// Handle buy now action
},
backgroundColor: AppColors.primaryRedColor,
borderColor: AppColors.primaryRedColor,
textColor: AppColors.whiteColor,
fontSize: 14.f,
isBold: true,
borderRadius: 12.r,
height: 40.h,
),
],
),
),
),
],
),
),
),
),
);
},
)
: Padding(
padding: EdgeInsets.symmetric(horizontal: 24.w),
child: Utils.getNoDataWidget(context, noDataText: LocaleKeys.noDataAvailable.tr(context: context)),
),
SizedBox(height: 24.h),
],
).paddingSymmetrical(24.h, 0.h);
}),
),
);
}
}

@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:hmg_patient_app_new/core/utils/size_utils.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';
class OffersAndDiscountTypeSelectionWidget extends StatelessWidget {
final List<String> selectedOffers;
final Function(List<String>) onOfferClicked;
const OffersAndDiscountTypeSelectionWidget({
super.key,
required this.selectedOffers,
required this.onOfferClicked,
});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildOfferTypeCard(
context: context,
title: "All Offers",
facilityType: 'All Offers',
isSelected: selectedOffers.contains('All Offers'),
),
SizedBox(width: 12.w),
_buildOfferTypeCard(
context: context,
title: 'Female',
facilityType: 'Female',
isSelected: selectedOffers.contains('Female'),
),
SizedBox(width: 12.w),
_buildOfferTypeCard(
context: context,
title: 'OB-Gyne',
facilityType: 'OB-Gyne',
isSelected: selectedOffers.contains('OB-Gyne'),
),
SizedBox(width: 12.w),
_buildOfferTypeCard(
context: context,
title: 'Dermatology',
facilityType: 'Dermatology',
isSelected: selectedOffers.contains('Dermatology'),
),
SizedBox(width: 12.w),
_buildOfferTypeCard(
context: context,
title: 'Radiology',
facilityType: 'Radiology',
isSelected: selectedOffers.contains('Radiology'),
),
],
),
);
}
Widget _buildOfferTypeCard({
required BuildContext context,
required String title,
required String facilityType,
required bool isSelected,
}) {
return AnimatedContainer(
duration: Duration(milliseconds: 200),
padding: EdgeInsets.symmetric(vertical: 12.h, horizontal: 16.w),
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(
color: isSelected ? AppColors.bgRedLightColor : AppColors.whiteColor,
borderRadius: 12.r,
hasShadow: true,
side: isSelected ? BorderSide(color: AppColors.primaryRedColor, width: 2) : BorderSide(color: AppColors.borderGrayColor, width: 1),
),
child: Center(
child: title.toText14(
color: isSelected ? AppColors.primaryRedColor : AppColors.textColor,
weight: isSelected ? FontWeight.w700 : FontWeight.w500,
isBold: isSelected,
),
),
).onPress(() {
List<String> updatedSelection = List.from(selectedOffers);
if (facilityType == 'All Offers') {
if (updatedSelection.contains('All Offers')) {
updatedSelection.clear();
} else {
updatedSelection.clear();
updatedSelection.add('All Offers');
}
} else {
// Remove "All Offers" if any specific type is selected
updatedSelection.remove('All Offers');
// Toggle the clicked facility type
if (updatedSelection.contains(facilityType)) {
updatedSelection.remove(facilityType);
// If no selection left, default to "All Offers"
if (updatedSelection.isEmpty) {
updatedSelection.add('All Offers');
}
} else {
updatedSelection.add(facilityType);
}
}
onOfferClicked(updatedSelection);
});
}
}

@ -0,0 +1,135 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hmg_patient_app_new/core/utils/size_utils.dart';
import 'package:hmg_patient_app_new/presentation/offers_and_discounts/offer_and_discounts_full_screen_swiper_page.dart';
import 'package:hmg_patient_app_new/theme/colors.dart';
import 'package:smooth_corner/smooth_corner.dart';
class OffersAndDiscountsCarousel extends StatefulWidget {
const OffersAndDiscountsCarousel({super.key});
@override
State<OffersAndDiscountsCarousel> createState() => _OffersAndDiscountsCarouselState();
}
class _OffersAndDiscountsCarouselState extends State<OffersAndDiscountsCarousel> {
final ScrollController _scrollController = ScrollController();
Timer? _autoScrollTimer;
// List of offer images - you can add more images here
final List<String> offerImages = [
'assets/images/offersanddiscounts/img1.png',
'assets/images/offersanddiscounts/img2.png',
'assets/images/offersanddiscounts/img3.png',
'assets/images/offersanddiscounts/img1.png',
'assets/images/offersanddiscounts/img2.png',
'assets/images/offersanddiscounts/img3.png',
'assets/images/offersanddiscounts/img1.png',
];
@override
void initState() {
super.initState();
// _startAutoScroll();
}
void _startAutoScroll() {
_autoScrollTimer = Timer.periodic(const Duration(milliseconds: 50), (timer) {
if (_scrollController.hasClients) {
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.position.pixels;
final delta = 1.0; // Scroll speed
if (currentScroll >= maxScroll) {
// Reset to beginning for infinite scroll effect
_scrollController.jumpTo(0);
} else {
_scrollController.animateTo(
currentScroll + delta,
duration: const Duration(milliseconds: 50),
curve: Curves.linear,
);
}
}
});
}
@override
void dispose() {
_autoScrollTimer?.cancel();
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: 79.h,
child: ListView.separated(
controller: _scrollController,
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 0.w),
itemCount: offerImages.length * 100,
itemBuilder: (context, index) {
final imageIndex = index % offerImages.length;
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const OfferAndDiscountsFullScreenSwiperPage()),
);
},
child: _buildOfferItem(offerImages[imageIndex]),
);
},
separatorBuilder: (context, index) => SizedBox(width: 12.w),
),
);
}
Widget _buildOfferItem(String imagePath) {
return Container(
width: 79.w,
height: 79.h,
decoration: ShapeDecoration(
shape: SmoothRectangleBorder(
borderRadius: BorderRadius.circular(13.r),
smoothness: 0.6,
side: BorderSide(
color: Color(0xff2E3039),
width: 2.w,
),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(13.r),
child: Container(
decoration: BoxDecoration(
border: Border.all(color: AppColors.textColor, width: 2.w),
borderRadius: BorderRadius.circular(11.r),
),
child: Padding(
padding: const EdgeInsets.all(2.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(11.r),
child: Image.asset(
imagePath,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
color: AppColors.greyColor,
child: Icon(
Icons.image,
color: AppColors.textColorLight,
size: 32.h,
),
);
},
),
),
),
),
),
);
}
}

@ -1,10 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:hmg_patient_app_new/core/app_assets.dart';
import 'package:hmg_patient_app_new/core/app_export.dart';
import 'package:hmg_patient_app_new/core/app_state.dart';
@ -12,12 +8,12 @@ import 'package:hmg_patient_app_new/core/dependencies.dart';
import 'package:hmg_patient_app_new/core/utils/utils.dart';
import 'package:hmg_patient_app_new/core/utils/image_compression_helper.dart';
import 'package:hmg_patient_app_new/extensions/widget_extensions.dart';
import 'package:hmg_patient_app_new/features/profile_picture/profile_picture_view_model.dart';
import 'package:hmg_patient_app_new/features/profile_settings/profile_settings_view_model.dart';
import 'package:hmg_patient_app_new/generated/locale_keys.g.dart';
import 'package:hmg_patient_app_new/services/permission_service.dart';
import 'package:hmg_patient_app_new/theme/colors.dart';
import 'package:hmg_patient_app_new/widgets/image_picker.dart';
import 'package:hmg_patient_app_new/widgets/common_bottom_sheet.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
class ProfilePictureWidget extends StatefulWidget {
@ -29,467 +25,63 @@ class ProfilePictureWidget extends StatefulWidget {
class _ProfilePictureWidgetState extends State<ProfilePictureWidget> {
final AppState _appState = getIt.get<AppState>();
File? _selectedImage;
int? _currentPatientId;
bool _isInitialLoadTriggered = false;
/// Cache decoded image bytes to avoid decoding base64 on every rebuild
Uint8List? _cachedImageBytes;
String? _cachedImageDataHash;
final PermissionService _permissionService = getIt.get<PermissionService>();
@override
void initState() {
super.initState();
_currentPatientId = _appState.getAuthenticatedUser()?.patientId;
// Pre-cache existing image data if available (prevents blink from default loaded)
_tryCacheExistingImage();
// Use addPostFrameCallback to ensure widget is built before loading
// Initialize view model
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _isInitialLoadTriggered) return;
_isInitialLoadTriggered = true;
final patientID = _appState.getAuthenticatedUser()?.patientId;
if (patientID == null) {
print('⚠️ No authenticated user found');
return;
}
// Load fresh data from API
print('📥 Loading fresh profile image from API for patient: $patientID');
_loadProfileImage(forceRefresh: false);
if (!mounted) return;
final profilePictureViewModel = context.read<ProfilePictureViewModel>();
profilePictureViewModel.initialize();
profilePictureViewModel.triggerInitialLoad();
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Only check for user switch, NOT on initial load (initState handles that)
if (_isInitialLoadTriggered) {
_checkAndUpdateUserImage();
// Check for user switch
final profilePictureViewModel = context.read<ProfilePictureViewModel>();
if (profilePictureViewModel.isInitialLoadTriggered) {
profilePictureViewModel.checkForUserSwitch();
}
}
@override
void didUpdateWidget(ProfilePictureWidget oldWidget) {
super.didUpdateWidget(oldWidget);
_checkAndUpdateUserImage();
}
/// Pre-cache already-loaded image bytes so we don't flash default avatar
void _tryCacheExistingImage() {
final imageData = _appState.getProfileImageData;
if (imageData != null && imageData.isNotEmpty) {
try {
_cachedImageBytes = base64Decode(imageData);
_cachedImageDataHash = '${imageData.length}_${imageData.hashCode}';
} catch (_) {
_cachedImageBytes = null;
_cachedImageDataHash = null;
}
}
}
void _checkAndUpdateUserImage() {
// Check if the authenticated user has changed (family member switch)
final currentPatientId = _appState.getAuthenticatedUser()?.patientId;
if (currentPatientId != null && currentPatientId != _currentPatientId) {
print('🔄 User switched detected: $_currentPatientId -> $currentPatientId');
// Update patient ID IMMEDIATELY before any other operations
final oldPatientId = _currentPatientId;
_currentPatientId = currentPatientId;
// Clear the old profile image data from BOTH AppState and ViewModel
try {
final profileVm = context.read<ProfileSettingsViewModel>();
print('🧹 Clearing cache for old user: $oldPatientId');
// Clear AppState cache first
_appState.clearProfileImageCache();
// Then clear ViewModel cache
profileVm.clearProfileImageCache();
// Clear local decoded bytes cache
_cachedImageBytes = null;
_cachedImageDataHash = null;
// Force rebuild to show default avatar immediately
if (mounted) {
setState(() {
_selectedImage = null; // Clear any selected image
});
}
print('📥 Loading profile image for new user: $currentPatientId');
// Load the new user's profile image immediately
profileVm.getProfileImage(
patientID: currentPatientId,
forceRefresh: true,
onSuccess: (data) {
print('✅ Profile image loaded successfully for user: $currentPatientId');
if (mounted) {
_tryCacheExistingImage();
setState(() {}); // Force rebuild to show new data
}
},
onError: (error) {
print('❌ Error loading profile image: $error');
if (mounted) {
setState(() {}); // Force rebuild to show default avatar
}
},
);
} catch (e) {
print('❌ Error in _checkAndUpdateUserImage: $e');
}
}
}
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) {
// Image already loaded, no need to call API
return;
}
final profileVm = context.read<ProfileSettingsViewModel>();
final patientID = _appState.getAuthenticatedUser()?.patientId;
if (patientID != null) {
print('📥 Loading profile image for patient: $patientID (forceRefresh: $forceRefresh)');
profileVm.getProfileImage(
patientID: patientID,
forceRefresh: forceRefresh,
onSuccess: (data) {
print('✅ Profile image loaded successfully');
if (mounted) {
_tryCacheExistingImage();
setState(() {}); // Rebuild with new cached bytes
}
},
onError: (error) {
print('❌ Error loading profile image: $error');
// Error loading image
},
);
}
// Check for user switch
context.read<ProfilePictureViewModel>().checkForUserSwitch();
}
void _pickImage() {
// Show image picker options without checking permissions first
ImageOptions.showImageOptionsNew(
context,
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...');
final compressedFile = await ImageCompressionHelper.compressAndResizeImage(file);
File finalFile;
String finalBase64;
if (compressedFile == null) {
print('⚠️ Compression failed - using original file as fallback');
final profilePictureViewModel = context.read<ProfilePictureViewModel>();
// Fallback: use original image if compression fails
final originalSize = await file.length();
final maxSize = 1048576; // 1MB
if (originalSize > maxSize) {
print('❌ Original file is too large: ${originalSize / 1024} KB');
if (mounted) {
Utils.showToast(
LocaleKeys.imageSizeTooLarge.tr(context: context),
);
}
return;
}
print('✅ Using original file (${originalSize / 1024} KB)');
finalFile = file;
var bytes = await file.readAsBytes();
finalBase64 = base64.encode(bytes);
} else {
// Check compressed file size
final fileSize = await compressedFile.length();
final maxSize = 1048576; // 1MB
print('✅ Compression successful: ${fileSize / 1024} KB');
if (fileSize > maxSize) {
print('❌ Compressed file still too large');
if (mounted) {
Utils.showToast(
LocaleKeys.imageSizeTooLarge.tr(context: context),
);
}
return;
}
finalFile = compressedFile;
var bytes = await compressedFile.readAsBytes();
finalBase64 = base64.encode(bytes);
}
print('Converting to base64... Length: ${finalBase64.length}');
if (mounted) {
setState(() {
_selectedImage = finalFile;
});
print('📤 Starting upload...');
// Upload the image
_uploadImage(finalBase64);
}
print('=== Image processing complete ===');
} catch (e, stackTrace) {
print('❌ Error in _pickImage: $e');
print('Stack trace: $stackTrace');
if (mounted) {
Utils.showToast(
LocaleKeys.failedToProcessImage.tr(context: context),
);
}
}
},
checkCameraPermission: _checkCameraPermission,
checkGalleryPermission: _checkGalleryPermission,
);
}
Future<bool> _checkCameraPermission() async {
try {
print('=== Checking camera permission ===');
// First check current status
PermissionStatus currentStatus = await Permission.camera.status;
print('Current camera permission status: $currentStatus');
// If already granted, return true
if (currentStatus.isGranted) {
print('✅ Camera permission already granted');
return true;
}
// If denied or permanently denied, show settings dialog
if (currentStatus.isDenied || currentStatus.isPermanentlyDenied) {
// Request permission first
PermissionStatus newStatus = await Permission.camera.request();
print('Camera permission after request: $newStatus');
if (newStatus.isGranted) {
print('✅ Camera permission granted');
return true;
}
// Still denied - show settings dialog
print('⚠️ Camera permission denied - showing settings dialog');
profilePictureViewModel.pickImage(
context,
showImagePicker: ImageOptions.showImageOptionsNew,
compressImage: ImageCompressionHelper.compressAndResizeImage,
onSuccess: (data) {
if (mounted) {
showCommonBottomSheetWithoutHeight(
title: LocaleKeys.notice.tr(context: context),
context,
child: Utils.getWarningWidget(
loadingText: LocaleKeys.cameraPermissionMessage.tr(context: context),
isShowActionButtons: true,
onCancelTap: () {
Navigator.pop(context);
},
onConfirmTap: () async {
openAppSettings();
},
),
callBackFunc: () {},
isFullScreen: false,
isCloseButtonVisible: true,
);
}
return false;
}
// Request permission for the first time
PermissionStatus newStatus = await Permission.camera.request();
print('Camera permission after request: $newStatus');
if (newStatus.isGranted) {
print('✅ Camera permission granted');
return true;
}
// Denied - show settings dialog
print('❌ Camera permission denied - showing settings dialog');
if (mounted) {
showCommonBottomSheetWithoutHeight(
title: LocaleKeys.notice.tr(context: context),
context,
child: Utils.getWarningWidget(
loadingText: LocaleKeys.cameraPermissionMessage.tr(context: context),
isShowActionButtons: true,
onCancelTap: () {
Navigator.pop(context);
},
onConfirmTap: () async {
openAppSettings();
},
),
callBackFunc: () {},
isFullScreen: false,
isCloseButtonVisible: true,
);
}
return false;
} catch (e) {
print('❌ Error checking camera permission: $e');
if (mounted) {
Utils.showToast(
LocaleKeys.failedToCheckPermissions.tr(context: context),
);
}
return false;
}
}
Future<bool> _checkGalleryPermission() async {
try {
print('=== Checking gallery permission ===');
// For Android 13+ (API 33+), the Android Photo Picker handles permissions internally
// No need to request READ_MEDIA_IMAGES or READ_EXTERNAL_STORAGE permissions
if (Platform.isAndroid) {
print('✅ Android Photo Picker will handle permissions internally');
return true;
}
// iOS: Check photos permission
Permission galleryPermission = Permission.photos;
// First check current status
PermissionStatus currentStatus = await galleryPermission.status;
print('Current gallery permission status: $currentStatus');
// If already granted, return true
if (currentStatus.isGranted || currentStatus.isLimited) {
print('✅ Gallery permission already granted');
return true;
}
// If denied or permanently denied, request permission first
if (currentStatus.isDenied || currentStatus.isPermanentlyDenied) {
// Request permission first
PermissionStatus newStatus = await galleryPermission.request();
print('Gallery permission after request: $newStatus');
if (newStatus.isGranted || newStatus.isLimited) {
print('✅ Gallery permission granted');
return true;
Utils.showToast(LocaleKeys.profileImageUpdatedSuccessfully.tr(context: context));
}
// Still denied - show settings dialog
print('⚠️ Gallery permission denied - showing settings dialog');
},
onError: (error) {
if (mounted) {
showCommonBottomSheetWithoutHeight(
title: LocaleKeys.notice.tr(context: context),
context,
child: Utils.getWarningWidget(
loadingText: LocaleKeys.galleryPermissionMessage.tr(context: context),
isShowActionButtons: true,
onCancelTap: () {
Navigator.pop(context);
},
onConfirmTap: () async {
openAppSettings();
},
),
callBackFunc: () {},
isFullScreen: false,
isCloseButtonVisible: true,
);
}
return false;
}
// Request permission for the first time
PermissionStatus newStatus = await galleryPermission.request();
print('Gallery permission after request: $newStatus');
if (newStatus.isGranted || newStatus.isLimited) {
print('✅ Gallery permission granted');
return true;
}
// Denied - show settings dialog
print('❌ Gallery permission denied - showing settings dialog');
if (mounted) {
showCommonBottomSheetWithoutHeight(
title: LocaleKeys.notice.tr(context: context),
context,
child: Utils.getWarningWidget(
loadingText: LocaleKeys.galleryPermissionMessage.tr(context: context),
isShowActionButtons: true,
onCancelTap: () {
Navigator.pop(context);
},
onConfirmTap: () async {
openAppSettings();
},
),
callBackFunc: () {},
isFullScreen: false,
isCloseButtonVisible: true,
);
}
return false;
} catch (e) {
print('❌ Error checking gallery permission: $e');
if (mounted) {
Utils.showToast(
LocaleKeys.failedToCheckPermissions.tr(context: context),
);
}
return false;
}
}
void _uploadImage(String base64String) {
final profileVm = context.read<ProfileSettingsViewModel>();
final patientID = _appState.getAuthenticatedUser()?.patientId;
if (patientID != null) {
profileVm.uploadProfileImage(
patientID: patientID,
imageData: base64String,
onSuccess: (data) {
if (mounted) {
// Update cached bytes immediately from the uploaded data
_tryCacheExistingImage();
setState(() {
_selectedImage = null; // Clear selected image after successful upload
});
print(
LocaleKeys.profileImageUpdatedSuccessfully.tr(context: context),
);
}
},
onError: (error) {
Utils.showToast(error);
},
);
}
}
},
imageSizeTooLargeMessage: LocaleKeys.imageSizeTooLarge.tr(context: context),
failedToProcessImageMessage: LocaleKeys.failedToProcessImage.tr(context: context),
checkCameraPermission: (ctx) => _permissionService.checkCameraPermission(ctx),
checkGalleryPermission: (ctx) => _permissionService.checkGalleryPermission(ctx),
);
}
Widget _buildProfileImage(ProfileSettingsViewModel profileVm) {
Widget _buildProfileImage(ProfilePictureViewModel profilePictureViewModel, ProfileSettingsViewModel profileVm) {
// Always get fresh user data
final currentUser = _appState.getAuthenticatedUser();
final currentPatientId = currentUser?.patientId;
@ -507,10 +99,10 @@ class _ProfilePictureWidgetState extends State<ProfilePictureWidget> {
}
// Show selected image if available (only during upload)
if (_selectedImage != null) {
if (profilePictureViewModel.selectedImage != null) {
return ClipOval(
child: Image.file(
_selectedImage!,
profilePictureViewModel.selectedImage!,
width: 136.w,
height: 136.h,
fit: BoxFit.cover,
@ -518,30 +110,14 @@ class _ProfilePictureWidgetState extends State<ProfilePictureWidget> {
);
}
// Use cached decoded bytes update cache if source data changed
final String? imageData = GetIt.instance<AppState>().getProfileImageData;
final String? currentHash = (imageData != null && imageData.isNotEmpty) ? '${imageData.length}_${imageData.hashCode}' : null;
// Re-decode only if the underlying data actually changed
if (currentHash != null && currentHash != _cachedImageDataHash) {
try {
_cachedImageBytes = base64Decode(imageData!);
_cachedImageDataHash = currentHash;
} catch (e) {
print('❌ Error decoding profile image: $e');
_cachedImageBytes = null;
_cachedImageDataHash = null;
}
} else if (currentHash == null) {
_cachedImageBytes = null;
_cachedImageDataHash = null;
}
// Update cache if needed
profilePictureViewModel.updateCacheIfNeeded();
// Show cached decoded image if available
if (_cachedImageBytes != null) {
if (profilePictureViewModel.cachedImageBytes != null) {
return ClipOval(
child: Image.memory(
_cachedImageBytes!,
profilePictureViewModel.cachedImageBytes!,
key: ValueKey('profile_$currentPatientId'),
width: 136.w,
height: 136.h,
@ -564,33 +140,34 @@ class _ProfilePictureWidgetState extends State<ProfilePictureWidget> {
@override
Widget build(BuildContext context) {
// Removed addPostFrameCallback from build() it was causing redundant
// _checkAndUpdateUserImage calls on every single rebuild.
// didChangeDependencies + didUpdateWidget already handle user switches.
return Consumer<ProfileSettingsViewModel>(
builder: (context, profileVm, _) {
return Consumer2<ProfilePictureViewModel, ProfileSettingsViewModel>(
builder: (context, profilePictureViewModel, profileVm, _) {
// If we already have cached bytes, show the image even while "loading"
// to prevent the shimmerimage blink on page open
final bool showShimmer = profileVm.isProfileImageLoading && _cachedImageBytes == null && _selectedImage == null;
final bool showShimmer = profilePictureViewModel.shouldShowShimmer();
return Center(
child: Stack(
children: [
// Profile Image use AnimatedSwitcher to smooth transition
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: showShimmer
? Container(
key: const ValueKey('shimmer'),
width: 136.w,
height: 136.h,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.greyTextColor.withValues(alpha: 0.2),
),
).toShimmer2(isShow: true)
: _buildProfileImage(profileVm),
// Profile Image use ValueListenableBuilder for targeted updates
ValueListenableBuilder<int>(
valueListenable: profilePictureViewModel.profileImageVersion,
builder: (context, version, child) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: showShimmer
? Container(
key: const ValueKey('shimmer'),
width: 136.w,
height: 136.h,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.greyTextColor.withValues(alpha: 0.2),
),
).toShimmer2(isShow: true)
: _buildProfileImage(profilePictureViewModel, profileVm),
);
},
),
// Edit button

@ -58,10 +58,10 @@ class NavigationService {
}
Future<T?> pushToOtpScreen<T>(
{required String phoneNumber, required Function(int code) checkActivationCode, required Function(String phoneNumber) onResendOTPPressed, bool isFormFamilyFile = false}) {
{required String phoneNumber, required String zipCode, required Function(int code) checkActivationCode, required Function(String phoneNumber, String zipCode) onResendOTPPressed, bool isFormFamilyFile = false}) {
return navigatorKey.currentState!.push(
MaterialPageRoute(
builder: (_) => OTPVerificationScreen(phoneNumber: phoneNumber, checkActivationCode: checkActivationCode, onResendOTPPressed: onResendOTPPressed, isFormFamilyFile: isFormFamilyFile)),
builder: (_) => OTPVerificationScreen(phoneNumber: phoneNumber, zipCode: zipCode, checkActivationCode: checkActivationCode, onResendOTPPressed: onResendOTPPressed, isFormFamilyFile: isFormFamilyFile)),
);
}

@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
// import 'package:vibration/vibration.dart';
import 'package:geolocator/geolocator.dart' as geo;
import 'package:hmg_patient_app_new/core/utils/utils.dart';
import 'package:hmg_patient_app_new/generated/locale_keys.g.dart';
import 'package:hmg_patient_app_new/widgets/common_bottom_sheet.dart';
class PermissionService {
// final LocalStorage storage = new LocalStorage("permission");
@ -73,6 +76,206 @@ class PermissionService {
openAppSettings();
}
/// Check and request camera permission with proper dialog handling
/// Returns true if permission is granted, false otherwise
Future<bool> checkCameraPermission(BuildContext context) async {
try {
print('=== Checking camera permission ===');
// First check current status
PermissionStatus currentStatus = await Permission.camera.status;
print('Current camera permission status: $currentStatus');
// If already granted, return true
if (currentStatus.isGranted) {
print('✅ Camera permission already granted');
return true;
}
// If denied or permanently denied, show settings dialog
if (currentStatus.isDenied || currentStatus.isPermanentlyDenied) {
// Request permission first
PermissionStatus newStatus = await Permission.camera.request();
print('Camera permission after request: $newStatus');
if (newStatus.isGranted) {
print('✅ Camera permission granted');
return true;
}
// Still denied - show settings dialog
print('⚠️ Camera permission denied - showing settings dialog');
if (context.mounted) {
showCommonBottomSheetWithoutHeight(
title: LocaleKeys.notice.tr(context: context),
context,
child: Utils.getWarningWidget(
loadingText: LocaleKeys.cameraPermissionMessage.tr(context: context),
isShowActionButtons: true,
onCancelTap: () {
Navigator.pop(context);
},
onConfirmTap: () async {
openAppSettings();
},
),
callBackFunc: () {},
isFullScreen: false,
isCloseButtonVisible: true,
);
}
return false;
}
// Request permission for the first time
PermissionStatus newStatus = await Permission.camera.request();
print('Camera permission after request: $newStatus');
if (newStatus.isGranted) {
print('✅ Camera permission granted');
return true;
}
// Denied - show settings dialog
print('❌ Camera permission denied - showing settings dialog');
if (context.mounted) {
showCommonBottomSheetWithoutHeight(
title: LocaleKeys.notice.tr(context: context),
context,
child: Utils.getWarningWidget(
loadingText: LocaleKeys.cameraPermissionMessage.tr(context: context),
isShowActionButtons: true,
onCancelTap: () {
Navigator.pop(context);
},
onConfirmTap: () async {
openAppSettings();
},
),
callBackFunc: () {},
isFullScreen: false,
isCloseButtonVisible: true,
);
}
return false;
} catch (e) {
print('❌ Error checking camera permission: $e');
if (context.mounted) {
Utils.showToast(
LocaleKeys.failedToCheckPermissions.tr(context: context),
);
}
return false;
}
}
/// Check and request gallery/photos permission with proper dialog handling
/// Returns true if permission is granted, false otherwise
Future<bool> checkGalleryPermission(BuildContext context) async {
try {
print('=== Checking gallery permission ===');
// For Android 13+ (API 33+), the Android Photo Picker handles permissions internally
// No need to request READ_MEDIA_IMAGES or READ_EXTERNAL_STORAGE permissions
// Determine which permission to check based on platform and Android version
Permission galleryPermission;
if (Platform.isIOS) {
galleryPermission = Permission.photos;
} else {
// Android: use photos permission which handles API level differences automatically
print('✅ Android Photo Picker will handle permissions internally');
return true;
}
// First check current status
PermissionStatus currentStatus = await galleryPermission.status;
print('Current gallery permission status: $currentStatus');
// If already granted, return true
if (currentStatus.isGranted || currentStatus.isLimited) {
print('✅ Gallery permission already granted');
return true;
}
// If denied or permanently denied, request permission first
if (currentStatus.isDenied || currentStatus.isPermanentlyDenied) {
// Request permission first
PermissionStatus newStatus = await galleryPermission.request();
print('Gallery permission after request: $newStatus');
if (newStatus.isGranted || newStatus.isLimited) {
print('✅ Gallery permission granted');
return true;
}
// Still denied - show settings dialog
print('⚠️ Gallery permission denied - showing settings dialog');
if (context.mounted) {
showCommonBottomSheetWithoutHeight(
title: LocaleKeys.notice.tr(context: context),
context,
child: Utils.getWarningWidget(
loadingText: LocaleKeys.galleryPermissionMessage.tr(context: context),
isShowActionButtons: true,
onCancelTap: () {
Navigator.pop(context);
},
onConfirmTap: () async {
Navigator.pop(context);
openAppSettings();
},
),
callBackFunc: () {},
isFullScreen: false,
isCloseButtonVisible: true,
);
}
return false;
}
// Request permission for the first time
PermissionStatus newStatus = await galleryPermission.request();
print('Gallery permission after request: $newStatus');
if (newStatus.isGranted || newStatus.isLimited) {
print('✅ Gallery permission granted');
return true;
}
// Denied - show settings dialog
print('❌ Gallery permission denied - showing settings dialog');
if (context.mounted) {
showCommonBottomSheetWithoutHeight(
title: LocaleKeys.notice.tr(context: context),
context,
child: Utils.getWarningWidget(
loadingText: LocaleKeys.galleryPermissionMessage.tr(context: context),
isShowActionButtons: true,
onCancelTap: () {
Navigator.pop(context);
},
onConfirmTap: () async {
openAppSettings();
},
),
callBackFunc: () {},
isFullScreen: false,
isCloseButtonVisible: true,
);
}
return false;
} catch (e) {
print('❌ Error checking gallery permission: $e');
if (context.mounted) {
Utils.showToast(
LocaleKeys.failedToCheckPermissions.tr(context: context),
);
}
return false;
}
}
static isLocationEnabled() async {
var permission = await geo.Geolocator.checkPermission();
if (permission == geo.LocationPermission.denied) {

@ -21,6 +21,7 @@ class DropdownWidget extends StatelessWidget {
final Color? labelColor;
final String? errorMessage;
final bool? hasError;
const DropdownWidget(
{Key? key,
required this.labelText,
@ -37,8 +38,7 @@ class DropdownWidget extends StatelessWidget {
this.leadingIcon,
this.labelColor,
this.errorMessage,
this.hasError =false
})
this.hasError = false})
: super(key: key);
@override
@ -46,32 +46,33 @@ class DropdownWidget extends StatelessWidget {
Widget content = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [_buildLabelText(labelColor), _buildDropdown(context),],
children: [
_buildLabelText(labelColor),
_buildDropdown(context),
],
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [Container(
padding: padding,
alignment: Alignment.center, // This might need adjustment based on layout
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Container(
padding: padding,
alignment: Alignment.center, // This might need adjustment based on layout
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(
color: AppColors.whiteColor,
borderRadius: isAllowRadius ? 15.h : null,
side: isBorderAllowed ? BorderSide(color: hasError! ? Colors.red: const Color(0xffefefef), width: 1) : null,
),
child: Row(
// Wrap with a Row
crossAxisAlignment: CrossAxisAlignment.center, // Align items vertically in the center
children: [
if (leadingIcon != null) ...[
_buildLeadingIcon(),
SizedBox(width: 3.h),
side: isBorderAllowed ? BorderSide(color: hasError! ? Colors.red : const Color(0xffefefef), width: 1) : null,
),
child: Row(
// Wrap with a Row
crossAxisAlignment: CrossAxisAlignment.center, // Align items vertically in the center
children: [
if (leadingIcon != null) ...[
_buildLeadingIcon(),
SizedBox(width: 3.h),
],
Expanded(child: content),
],
Expanded(child: content),
],
),
),
),
if (hasError! && errorMessage != null)
Padding(
padding: EdgeInsets.only(top: 4.h, left: 12.h), // Adjust padding as needed
@ -82,16 +83,17 @@ class DropdownWidget extends StatelessWidget {
fontSize: 12.f,
),
),
)]);
)
]);
}
Widget _buildLeadingIcon() {
return Container(
height: 40.h,
width: 40.h,
margin: EdgeInsets.only(right: 10.h),
padding: EdgeInsets.all(8.h),
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(borderRadius: 10.h, color: AppColors.greyColor),
height: 40.h,
width: 40.h,
margin: EdgeInsets.only(right: 10.h),
padding: EdgeInsets.all(8.h),
decoration: RoundedRectangleBorder().toSmoothCornerDecoration(borderRadius: 10.h, color: AppColors.greyColor),
child: Utils.buildSvgWithAssets(icon: leadingIcon!),
);
}
@ -116,35 +118,26 @@ class DropdownWidget extends StatelessWidget {
final renderBox = context.findRenderObject() as RenderBox;
final offset = renderBox.localToGlobal(Offset.zero);
final selected = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
offset.dx,
offset.dy + renderBox.size.height,
offset.dx + renderBox.size.width,
0,
),
items: dropdownItems
.map(
(e) => PopupMenuItem<String>(
value: e,
child: Text(
e,
style: TextStyle(
fontSize: 14.f,
height: 21 / 14,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
context: context,
position: RelativeRect.fromLTRB(
offset.dx,
offset.dy + renderBox.size.height,
offset.dx + renderBox.size.width,
0,
),
items: dropdownItems
.map(
(e) => PopupMenuItem<String>(
value: e,
child: Text(
e,
style: TextStyle(color: AppColors.textColor, fontSize: 14.f, height: 21 / 14, fontWeight: FontWeight.w600, letterSpacing: -0.2),
),
),
),
)
.toList(),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
color: Colors.black
);
)
.toList(),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
color: AppColors.whiteColor);
if (selected != null && onChange != null) {
onChange!(selected);
@ -165,7 +158,7 @@ class DropdownWidget extends StatelessWidget {
height: 21 / 14,
fontWeight: FontWeight.w600,
// color: (selectedValue != null && selectedValue!.isNotEmpty) ? const Color(0xff2E3039) : const Color(0xffB0B0B0),
color: AppColors.textColor,
color: (selectedValue == null || selectedValue!.isEmpty) ? AppColors.inputLabelTextColor :AppColors.textColor,
letterSpacing: -0.2,
),
),

@ -79,7 +79,7 @@ class ImageOptions {
}
},
onFilesTap: () async {
FilePickerResult? result = await FilePicker.platform.pickFiles(
FilePickerResult? result = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: [
'jpg',

@ -64,11 +64,16 @@ class UserAvatarWidget extends StatelessWidget {
if (profileImageData != null && profileImageData.isNotEmpty) {
try {
final bytes = base64Decode(profileImageData);
// Use a key based on data hash to help Flutter detect changes
final imageKey = ValueKey('avatar_${profileImageData.hashCode}');
final imageWidget = Image.memory(
bytes,
key: imageKey,
width: width,
height: height,
fit: fit ?? BoxFit.cover,
gaplessPlayback: true, // Smooth transition without flicker
);
return ClipRRect(

Loading…
Cancel
Save