diff --git a/DYNAMIC_THEME_IMPLEMENTATION_PLAN.md b/DYNAMIC_THEME_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..6f10744f --- /dev/null +++ b/DYNAMIC_THEME_IMPLEMENTATION_PLAN.md @@ -0,0 +1,1836 @@ +# Dynamic Multi-Theme Implementation Plan +## HMG Patient App - Firebase Remote Config Driven Themes + +> **🔥 Firebase-First Approach**: All theme configurations, colors, dates, and features are controlled via Firebase Remote Config. Zero app updates needed for new events! + +--- + +## 🎯 Quick Overview + +### What We're Building +A dynamic theming system that allows you to change your app's appearance for special events (National Day, Ramadan, Eid, etc.) without deploying new app versions. Everything is controlled via Firebase Remote Config. + +### Why Firebase Remote Config? +- ✅ **Instant Updates**: Change themes without app store approval +- ✅ **A/B Testing**: Test different theme variations +- ✅ **Gradual Rollout**: Deploy to 10%, 50%, 100% of users +- ✅ **Instant Rollback**: Disable themes immediately if needed +- ✅ **Targeting**: Different themes for different regions/languages +- ✅ **No Code Changes**: Add unlimited new events via JSON + +### Example: National Day 2026 +``` +September 19, 2026 (Firebase Console): +1. Create JSON config with Saudi green colors +2. Set dates: Sept 20-24 +3. Click "Publish" +4. ✨ ALL users see green theme within 1 hour! + +September 25, 2026: +- Theme automatically deactivates (date passed) +- App returns to normal colors +- Zero developer intervention +``` + +--- + +# Dynamic Multi-Theme Implementation Plan +## HMG Patient App - National Day & International Events Theming + +--- + +## 📋 Executive Summary + +**Goal**: Implement dynamic event-based themes (National Day, Eid, New Year, etc.) while maintaining the existing Light/Dark mode architecture with minimal changes, controlled via Firebase Remote Config. + +**Strategy**: Extend the current dual-theme system to support a **Theme Layer System** where event themes override base colors while preserving dark mode functionality. All theme activation, dates, colors, and features are controlled remotely via Firebase. + +**Impact**: +- ✅ Zero breaking changes to existing code +- ✅ Maintains current dark/light mode toggle +- ✅ Supports unlimited event themes +- ✅ **100% controlled via Firebase Remote Config** (no app updates needed) +- ✅ Real-time theme activation/deactivation +- ✅ A/B testing support for event themes +- ✅ Instant rollback capability + +--- + +## 🏗️ Architecture Overview + +### Current Architecture (Preserved) +``` +User Toggle → ProfileSettingsViewModel → AppColors.isDarkMode → Theme Switch +``` + +### New Architecture (Firebase-Driven) +``` +Firebase Remote Config → RemoteConfigService → EventThemeManager → Active Event Theme + ↓ + Color Overlay + ↓ +User Toggle → ProfileSettingsViewModel → Dark/Light Mode → AppColors Resolution → Final UI +``` + +### Data Flow +``` +1. App Startup → Fetch Remote Config +2. Parse Active Event Themes (JSON) +3. Check Current Date/Time +4. Activate Highest Priority Theme +5. Apply Color Overrides +6. Listen for Config Updates (Real-time) +``` + +--- + +## 📁 File Structure Changes + +### New Files to Create (9 files) +``` +lib/ +├── theme/ +│ ├── app_theme.dart # [MODIFY] - Add event theme support +│ ├── colors.dart # [MODIFY] - Add event color override system +│ ├── event_themes/ # [NEW DIRECTORY] +│ │ ├── event_theme_config.dart # [NEW] - Event theme definitions from Remote Config +│ │ ├── event_theme_manager.dart # [NEW] - Theme activation logic +│ │ ├── remote_config_service.dart # [NEW] - Firebase Remote Config wrapper +│ │ ├── themes/ # [NEW DIRECTORY] +│ │ │ ├── base_event_theme.dart # [NEW] - Abstract base class +│ │ │ ├── dynamic_event_theme.dart # [NEW] - Dynamic theme from Remote Config +│ │ └── models/ +│ │ ├── event_theme_model.dart # [NEW] - Event metadata model +│ │ └── remote_theme_config.dart # [NEW] - Remote Config data model +├── features/ +│ └── profile_settings/ +│ └── profile_settings_view_model.dart # [MODIFY] - Add event theme state +└── core/ + └── app_state.dart # [MODIFY] - Add event theme flags +``` + +### Modified Files (4 files) +- `lib/theme/app_theme.dart` - Add event theme injection +- `lib/theme/colors.dart` - Add event color override getters +- `lib/features/profile_settings/profile_settings_view_model.dart` - Add event theme state +- `lib/main.dart` - Initialize theme manager and Remote Config + +### Firebase Remote Config Keys +- `event_themes_config` - JSON array of active event themes +- `enable_event_themes` - Global on/off switch +- `debug_event_theme` - Force specific theme for testing + +--- + +## 🎨 Design Pattern: Theme Layer System + +### Layer Hierarchy (Bottom to Top) +``` +1. Base Light Colors (Always present) + ↓ +2. Base Dark Colors (When isDarkMode = true) + ↓ +3. Event Theme Colors (When active event exists) + ↓ +4. Final Rendered Color +``` + +### Color Resolution Logic +```dart +Color resolveColor() { + if (hasActiveEvent && eventTheme.overridesThisColor) { + return eventTheme.getColor(isDarkMode); // Event-specific color + } + return isDarkMode ? darkColor : lightColor; // Standard color +} +``` + +--- + +## 🔧 Implementation Steps + +### **Phase 1: Firebase Remote Config Setup (Day 1-2)** + +#### Step 1.0: Firebase Remote Config Service + +**File**: `lib/theme/event_themes/remote_config_service.dart` (NEW) +```dart +import 'dart:convert'; +import 'package:firebase_remote_config/firebase_remote_config.dart'; +import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/services/logger_service.dart'; + +class RemoteConfigService extends ChangeNotifier { + static RemoteConfigService? _instance; + factory RemoteConfigService() => _instance ??= RemoteConfigService._internal(); + RemoteConfigService._internal(); + + final FirebaseRemoteConfig _remoteConfig = FirebaseRemoteConfig.instance; + final LoggerService _logger = LoggerService(); + + bool _isInitialized = false; + bool get isInitialized => _isInitialized; + + // Remote Config Keys + static const String _eventThemesConfigKey = 'event_themes_config'; + static const String _enableEventThemesKey = 'enable_event_themes'; + static const String _debugEventThemeKey = 'debug_event_theme'; + + /// Initialize Remote Config with defaults + Future initialize() async { + try { + await _remoteConfig.setConfigSettings(RemoteConfigSettings( + fetchTimeout: const Duration(minutes: 1), + minimumFetchInterval: const Duration(hours: 1), + )); + + // Set default values + await _remoteConfig.setDefaults({ + _eventThemesConfigKey: _getDefaultThemesConfig(), + _enableEventThemesKey: true, + _debugEventThemeKey: '', + }); + + // Fetch and activate + await _remoteConfig.fetchAndActivate(); + + _isInitialized = true; + _logger.logInfo('Remote Config initialized successfully'); + + // Listen for real-time updates + _remoteConfig.onConfigUpdated.listen((event) async { + await _remoteConfig.activate(); + _logger.logInfo('Remote Config updated'); + notifyListeners(); // Notify listeners of config changes + }); + + } catch (e) { + _logger.logError('Remote Config initialization failed: $e'); + _isInitialized = false; + } + } + + /// Get event themes configuration from Remote Config + List> getEventThemesConfig() { + try { + final jsonString = _remoteConfig.getString(_eventThemesConfigKey); + if (jsonString.isEmpty) return []; + + final decoded = jsonDecode(jsonString); + if (decoded is List) { + return List>.from( + decoded.map((item) => Map.from(item)) + ); + } + return []; + } catch (e) { + _logger.logError('Error parsing event themes config: $e'); + return []; + } + } + + /// Check if event themes are globally enabled + bool areEventThemesEnabled() { + return _remoteConfig.getBool(_enableEventThemesKey); + } + + /// Get debug theme (for testing) + String? getDebugEventTheme() { + final value = _remoteConfig.getString(_debugEventThemeKey); + return value.isEmpty ? null : value; + } + + /// Force refresh config (call when app comes to foreground) + Future refresh() async { + try { + await _remoteConfig.fetchAndActivate(); + notifyListeners(); + } catch (e) { + _logger.logError('Remote Config refresh failed: $e'); + } + } + + /// Default configuration (fallback) + String _getDefaultThemesConfig() { + return jsonEncode([ + // Default: No active themes + // Themes are activated via Firebase Console + ]); + } +} +``` + +#### Step 1.1: Remote Theme Configuration Model + +**File**: `lib/theme/event_themes/models/remote_theme_config.dart` (NEW) +```dart +import 'package:flutter/material.dart'; + +/// Model for event theme configuration from Firebase Remote Config +class RemoteThemeConfig { + final String id; + final String type; // 'nationalDay', 'eid', 'ramadan', etc. + final String nameEn; + final String nameAr; + final DateTime startDate; + final DateTime endDate; + final bool isActive; + final int priority; + + // Color overrides (hex strings from Remote Config) + final String? primaryColor; + final String? primaryColorDark; + final String? secondaryColor; + final String? secondaryColorDark; + final String? accentColor; + final String? accentColorDark; + final String? scaffoldBgColor; + final String? scaffoldBgColorDark; + final String? cardBgColor; + final String? cardBgColorDark; + final String? appBarColor; + final String? appBarColorDark; + + // Gradient configuration + final List? gradientColors; + final String? gradientStart; // 'topLeft', 'topCenter', etc. + final String? gradientEnd; + + // Special features + final String? bannerImageUrl; + final bool showConfetti; + final bool enableAnimations; + + RemoteThemeConfig({ + required this.id, + required this.type, + required this.nameEn, + required this.nameAr, + required this.startDate, + required this.endDate, + required this.isActive, + required this.priority, + this.primaryColor, + this.primaryColorDark, + this.secondaryColor, + this.secondaryColorDark, + this.accentColor, + this.accentColorDark, + this.scaffoldBgColor, + this.scaffoldBgColorDark, + this.cardBgColor, + this.cardBgColorDark, + this.appBarColor, + this.appBarColorDark, + this.gradientColors, + this.gradientStart, + this.gradientEnd, + this.bannerImageUrl, + this.showConfetti = false, + this.enableAnimations = false, + }); + + /// Create from Remote Config JSON + factory RemoteThemeConfig.fromJson(Map json) { + return RemoteThemeConfig( + id: json['id'] ?? '', + type: json['type'] ?? '', + nameEn: json['nameEn'] ?? '', + nameAr: json['nameAr'] ?? '', + startDate: DateTime.parse(json['startDate'] ?? DateTime.now().toIso8601String()), + endDate: DateTime.parse(json['endDate'] ?? DateTime.now().toIso8601String()), + isActive: json['isActive'] ?? false, + priority: json['priority'] ?? 0, + primaryColor: json['primaryColor'], + primaryColorDark: json['primaryColorDark'], + secondaryColor: json['secondaryColor'], + secondaryColorDark: json['secondaryColorDark'], + accentColor: json['accentColor'], + accentColorDark: json['accentColorDark'], + scaffoldBgColor: json['scaffoldBgColor'], + scaffoldBgColorDark: json['scaffoldBgColorDark'], + cardBgColor: json['cardBgColor'], + cardBgColorDark: json['cardBgColorDark'], + appBarColor: json['appBarColor'], + appBarColorDark: json['appBarColorDark'], + gradientColors: json['gradientColors'] != null + ? List.from(json['gradientColors']) + : null, + gradientStart: json['gradientStart'], + gradientEnd: json['gradientEnd'], + bannerImageUrl: json['bannerImageUrl'], + showConfetti: json['showConfetti'] ?? false, + enableAnimations: json['enableAnimations'] ?? false, + ); + } + + /// Check if theme should be active based on dates + bool get shouldActivate { + final now = DateTime.now(); + return now.isAfter(startDate) && + now.isBefore(endDate) && + isActive; + } + + /// Parse hex color string to Color + Color? parseColor(String? hexString) { + if (hexString == null || hexString.isEmpty) return null; + + try { + final hex = hexString.replaceAll('#', ''); + if (hex.length == 6) { + return Color(int.parse('FF$hex', radix: 16)); + } else if (hex.length == 8) { + return Color(int.parse(hex, radix: 16)); + } + } catch (e) { + // Invalid color format + } + return null; + } + + /// Get gradient alignment from string + Alignment _parseAlignment(String? alignment) { + switch (alignment?.toLowerCase()) { + case 'topleft': return Alignment.topLeft; + case 'topcenter': return Alignment.topCenter; + case 'topright': return Alignment.topRight; + case 'centerleft': return Alignment.centerLeft; + case 'center': return Alignment.center; + case 'centerright': return Alignment.centerRight; + case 'bottomleft': return Alignment.bottomLeft; + case 'bottomcenter': return Alignment.bottomCenter; + case 'bottomright': return Alignment.bottomRight; + default: return Alignment.topLeft; + } + } + + /// Build gradient from configuration + LinearGradient? get gradient { + if (gradientColors == null || gradientColors!.length < 2) return null; + + final colors = gradientColors! + .map((hex) => parseColor(hex)) + .whereType() + .toList(); + + if (colors.length < 2) return null; + + return LinearGradient( + colors: colors, + begin: _parseAlignment(gradientStart), + end: _parseAlignment(gradientEnd), + ); + } +} +``` + +#### Step 1.2: Create Base Event Theme System + +**File**: `lib/theme/event_themes/models/event_theme_model.dart` +```dart +enum EventType { + nationalDay, + eid, + newYear, + ramadan, + foundingDay, + motherDay, + custom +} + +class EventThemeModel { + final String id; + final EventType type; + final String nameEn; + final String nameAr; + final DateTime startDate; + final DateTime endDate; + final bool isActive; + final int priority; // Higher number = higher priority + + // Auto-activate based on current date + bool get shouldActivate { + final now = DateTime.now(); + return now.isAfter(startDate) && + now.isBefore(endDate) && + isActive; + } +} +``` + +**File**: `lib/theme/event_themes/themes/base_event_theme.dart` +```dart +abstract class BaseEventTheme { + EventThemeModel get metadata; + + // Colors that override base theme + Color? get primaryBrandColor; + Color? get secondaryBrandColor; + Color? get accentColor; + Color? get scaffoldBackgroundColor; + Color? get cardBackgroundColor; + Color? get appBarColor; + + // Dark mode variants (optional) + Color? get primaryBrandColorDark; + Color? get secondaryBrandColorDark; + Color? get accentColorDark; + Color? get scaffoldBackgroundColorDark; + Color? get cardBackgroundColorDark; + Color? get appBarColorDark; + + // Special features (optional) + LinearGradient? get backgroundGradient; + String? get celebrationBannerImage; + bool get showConfetti; + bool get enableSpecialAnimations; + + // Method to get color with dark mode support + Color? getColor(String colorKey, bool isDarkMode) { + // Implementation in concrete classes + } +} +``` + +#### Step 1.3: Dynamic Event Theme from Remote Config + +**File**: `lib/theme/event_themes/themes/dynamic_event_theme.dart` (NEW) +```dart +import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/theme/event_themes/models/remote_theme_config.dart'; +import 'package:hmg_patient_app_new/theme/event_themes/themes/base_event_theme.dart'; +import 'package:hmg_patient_app_new/theme/event_themes/models/event_theme_model.dart'; + +/// Dynamic event theme created from Remote Config +class DynamicEventTheme extends BaseEventTheme { + final RemoteThemeConfig config; + + DynamicEventTheme(this.config); + + @override + EventThemeModel get metadata => EventThemeModel( + id: config.id, + type: _parseEventType(config.type), + nameEn: config.nameEn, + nameAr: config.nameAr, + startDate: config.startDate, + endDate: config.endDate, + isActive: config.isActive, + priority: config.priority, + ); + + EventType _parseEventType(String type) { + switch (type.toLowerCase()) { + case 'nationalday': return EventType.nationalDay; + case 'eid': return EventType.eid; + case 'ramadan': return EventType.ramadan; + case 'newyear': return EventType.newYear; + case 'foundingday': return EventType.foundingDay; + case 'motherday': return EventType.motherDay; + default: return EventType.custom; + } + } + + // Color overrides from Remote Config + @override + Color? get primaryBrandColor => config.parseColor(config.primaryColor); + + @override + Color? get primaryBrandColorDark => config.parseColor(config.primaryColorDark); + + @override + Color? get secondaryBrandColor => config.parseColor(config.secondaryColor); + + @override + Color? get secondaryBrandColorDark => config.parseColor(config.secondaryColorDark); + + @override + Color? get accentColor => config.parseColor(config.accentColor); + + @override + Color? get accentColorDark => config.parseColor(config.accentColorDark); + + @override + Color? get scaffoldBackgroundColor => config.parseColor(config.scaffoldBgColor); + + @override + Color? get scaffoldBackgroundColorDark => config.parseColor(config.scaffoldBgColorDark); + + @override + Color? get cardBackgroundColor => config.parseColor(config.cardBgColor); + + @override + Color? get cardBackgroundColorDark => config.parseColor(config.cardBgColorDark); + + @override + Color? get appBarColor => config.parseColor(config.appBarColor); + + @override + Color? get appBarColorDark => config.parseColor(config.appBarColorDark); + + // Special features from Remote Config + @override + LinearGradient? get backgroundGradient => config.gradient; + + @override + String? get celebrationBannerImage => config.bannerImageUrl; + + @override + bool get showConfetti => config.showConfetti; + + @override + bool get enableSpecialAnimations => config.enableAnimations; + + @override + Color? getColor(String colorKey, bool isDarkMode) { + switch (colorKey) { + case 'primaryBrand': + return isDarkMode ? primaryBrandColorDark ?? primaryBrandColor : primaryBrandColor; + case 'secondaryBrand': + return isDarkMode ? secondaryBrandColorDark ?? secondaryBrandColor : secondaryBrandColor; + case 'accent': + return isDarkMode ? accentColorDark ?? accentColor : accentColor; + case 'scaffoldBackground': + return isDarkMode ? scaffoldBackgroundColorDark ?? scaffoldBackgroundColor : scaffoldBackgroundColor; + case 'cardBackground': + return isDarkMode ? cardBackgroundColorDark ?? cardBackgroundColor : cardBackgroundColor; + case 'appBar': + return isDarkMode ? appBarColorDark ?? appBarColor : appBarColor; + default: + return null; + } + } +} +``` + +#### Step 1.4: Create Event Theme Manager (Remote Config Integration) + +**File**: `lib/theme/event_themes/event_theme_manager.dart` +```dart +import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/theme/event_themes/remote_config_service.dart'; +import 'package:hmg_patient_app_new/theme/event_themes/models/remote_theme_config.dart'; +import 'package:hmg_patient_app_new/theme/event_themes/themes/base_event_theme.dart'; +import 'package:hmg_patient_app_new/theme/event_themes/themes/dynamic_event_theme.dart'; +import 'package:hmg_patient_app_new/services/logger_service.dart'; + +class EventThemeManager extends ChangeNotifier { + static EventThemeManager? _instance; + factory EventThemeManager() => _instance ??= EventThemeManager._internal(); + EventThemeManager._internal(); + + final RemoteConfigService _remoteConfigService = RemoteConfigService(); + final LoggerService _logger = LoggerService(); + + BaseEventTheme? _activeEventTheme; + List _availableThemes = []; + bool _isInitialized = false; + + BaseEventTheme? get activeEventTheme => _activeEventTheme; + bool get hasActiveEvent => _activeEventTheme != null; + bool get isInitialized => _isInitialized; + + /// Initialize with Remote Config + Future initialize() async { + try { + // Initialize Remote Config first + await _remoteConfigService.initialize(); + + // Listen to Remote Config updates + _remoteConfigService.addListener(_onRemoteConfigUpdated); + + // Load themes from Remote Config + await _loadThemesFromRemoteConfig(); + + _isInitialized = true; + _logger.logInfo('EventThemeManager initialized successfully'); + } catch (e) { + _logger.logError('EventThemeManager initialization failed: $e'); + _isInitialized = false; + } + } + + /// Load themes from Remote Config + Future _loadThemesFromRemoteConfig() async { + try { + // Check if event themes are globally enabled + if (!_remoteConfigService.areEventThemesEnabled()) { + _logger.logInfo('Event themes are disabled via Remote Config'); + _availableThemes = []; + _activeEventTheme = null; + notifyListeners(); + return; + } + + // Check for debug theme override + final debugTheme = _remoteConfigService.getDebugEventTheme(); + if (debugTheme != null && debugTheme.isNotEmpty) { + _logger.logInfo('Debug theme active: $debugTheme'); + await _activateDebugTheme(debugTheme); + return; + } + + // Get event themes configuration + final themesConfig = _remoteConfigService.getEventThemesConfig(); + + // Convert to RemoteThemeConfig objects + final remoteConfigs = themesConfig + .map((json) => RemoteThemeConfig.fromJson(json)) + .toList(); + + // Create DynamicEventTheme instances + _availableThemes = remoteConfigs + .map((config) => DynamicEventTheme(config)) + .toList(); + + _logger.logInfo('Loaded ${_availableThemes.length} themes from Remote Config'); + + // Check for active event + _checkForActiveEvent(); + + } catch (e) { + _logger.logError('Error loading themes from Remote Config: $e'); + _availableThemes = []; + _activeEventTheme = null; + } + + notifyListeners(); + } + + /// Handle Remote Config updates + void _onRemoteConfigUpdated() { + _logger.logInfo('Remote Config updated - reloading themes'); + _loadThemesFromRemoteConfig(); + } + + /// Check for active events based on date + void _checkForActiveEvent() { + // Filter themes that should be active + final activeThemes = _availableThemes + .where((theme) => theme.metadata.shouldActivate) + .toList() + ..sort((a, b) => b.metadata.priority.compareTo(a.metadata.priority)); + + final previousTheme = _activeEventTheme; + _activeEventTheme = activeThemes.isNotEmpty ? activeThemes.first : null; + + // Log theme change + if (previousTheme?.metadata.id != _activeEventTheme?.metadata.id) { + if (_activeEventTheme != null) { + _logger.logInfo('Event theme activated: ${_activeEventTheme!.metadata.nameEn}'); + } else { + _logger.logInfo('Event theme deactivated'); + } + } + } + + /// Activate debug theme (for testing) + Future _activateDebugTheme(String themeType) async { + final theme = _availableThemes.firstWhere( + (t) => t.metadata.type.toString().contains(themeType), + orElse: () { + // If no matching theme found, create a default one + _logger.logWarning('Debug theme not found: $themeType'); + return _availableThemes.isNotEmpty ? _availableThemes.first : _activeEventTheme!; + }, + ); + + _activeEventTheme = theme; + _logger.logInfo('Debug theme activated: $themeType'); + notifyListeners(); + } + + /// Manual activation (for testing or admin override) + void activateTheme(EventType type) { + _activeEventTheme = _availableThemes.firstWhere( + (theme) => theme.metadata.type == type, + orElse: () => _activeEventTheme!, + ); + _logger.logInfo('Manual theme activation: ${type.toString()}'); + notifyListeners(); + } + + /// Disable event theme (revert to standard) + void deactivateEventTheme() { + _activeEventTheme = null; + _logger.logInfo('Event theme manually deactivated'); + notifyListeners(); + } + + /// Periodic check (call from app lifecycle) + void checkDaily() { + _logger.logInfo('Performing daily theme check'); + _checkForActiveEvent(); + notifyListeners(); + } + + /// Force refresh from Remote Config + Future refresh() async { + _logger.logInfo('Forcing theme refresh from Remote Config'); + await _remoteConfigService.refresh(); + await _loadThemesFromRemoteConfig(); + } + + @override + void dispose() { + _remoteConfigService.removeListener(_onRemoteConfigUpdated); + super.dispose(); + } +} +``` + +--- + +### **Phase 2: Color System Integration (Day 3)** + +#### Step 2.1: Extend AppColors Class + +**File**: `lib/theme/colors.dart` (MODIFICATIONS) + +```dart +class AppColors { + static bool isDarkMode = false; + static EventThemeManager? _themeManager; // NEW + + // NEW: Initialize theme manager + static void initializeEventThemeManager(EventThemeManager manager) { + _themeManager = manager; + } + + // NEW: Check if event theme overrides a color + static Color? _getEventColor(String colorKey) { + if (_themeManager?.hasActiveEvent ?? false) { + return _themeManager!.activeEventTheme!.getColor(colorKey, isDarkMode); + } + return null; + } + + // MODIFIED: Add event override check + static Color get primaryRedColor { + final eventColor = _getEventColor('primaryBrand'); + if (eventColor != null) return eventColor; + return isDarkMode ? dark.primaryRedColor : const Color(0xFFED1C2B); + } + + // MODIFIED: Scaffold background with event support + static Color get scaffoldBgColor { + final eventColor = _getEventColor('scaffoldBackground'); + if (eventColor != null) return eventColor; + return isDarkMode ? dark.scaffoldBgColor : const Color(0xFFF8F8F8); + } + + // NEW: Special event features + static bool get hasEventGradient => + _themeManager?.activeEventTheme?.backgroundGradient != null; + + static LinearGradient? get eventGradient => + _themeManager?.activeEventTheme?.backgroundGradient; + + static String? get eventBannerImage => + _themeManager?.activeEventTheme?.celebrationBannerImage; + + static bool get showConfetti => + _themeManager?.activeEventTheme?.showConfetti ?? false; + + // ... existing code continues +} +``` + +--- + +### **Phase 3: ViewModel Integration (Day 4)** + +#### Step 3.1: Extend ProfileSettingsViewModel + +**File**: `lib/features/profile_settings/profile_settings_view_model.dart` (MODIFICATIONS) + +```dart +class ProfileSettingsViewModel extends ChangeNotifier { + // ... existing code ... + + final EventThemeManager _eventThemeManager; // NEW + + ProfileSettingsViewModel({ + required CacheService cacheService, + required this.profileSettingsRepo, + required this.errorHandlerService, + required EventThemeManager eventThemeManager, // NEW + }) : _cacheService = cacheService, + _eventThemeManager = eventThemeManager { + // Listen to event theme changes + _eventThemeManager.addListener(_onEventThemeChanged); + } + + // NEW: Event theme state + BaseEventTheme? get activeEventTheme => _eventThemeManager.activeEventTheme; + bool get hasActiveEvent => _eventThemeManager.hasActiveEvent; + String get eventName => activeEventTheme?.metadata.nameEn ?? ''; + + // NEW: Handle event theme changes + void _onEventThemeChanged() { + notifyListeners(); // Rebuild UI when event theme changes + } + + // NEW: Manual event theme control (for admin/testing) + void activateEventTheme(EventType type) { + _eventThemeManager.activateTheme(type); + } + + void deactivateEventTheme() { + _eventThemeManager.deactivateEventTheme(); + } + + // EXISTING: Dark mode toggle (unchanged) + void toggleDarkMode(bool value) { + _isDarkMode = value; + AppColors.isDarkMode = value; + _cacheService.saveBool(key: _darkModeKey, value: value); + notifyListeners(); + } + + @override + void dispose() { + _eventThemeManager.removeListener(_onEventThemeChanged); + super.dispose(); + } +} +``` + +--- + +### **Phase 4: App Initialization (Day 5)** + +#### Step 4.1: Update Dependencies + +**File**: `lib/core/dependencies.dart` (MODIFICATIONS) + +```dart +Future addDependencies() async { + // ... existing dependencies ... + + // NEW: Register Event Theme Manager (Firebase-driven) + getIt.registerLazySingleton(() { + return EventThemeManager(); + // Themes are loaded from Firebase Remote Config + // No hardcoded themes needed! + }); + + // MODIFIED: Inject EventThemeManager into ProfileSettingsViewModel + getIt.registerLazySingleton(() => + ProfileSettingsViewModel( + cacheService: getIt(), + profileSettingsRepo: getIt(), + errorHandlerService: getIt(), + eventThemeManager: getIt(), // NEW + ) + ); +} +``` + +#### Step 4.2: Update Main App + +**File**: `lib/main.dart` (MODIFICATIONS) + +```dart +Future callInitializations() async { + // ... existing code ... + + await AppDependencies.addDependencies(); + + // NEW: Initialize event theme system with Firebase Remote Config + final eventThemeManager = getIt.get(); + await eventThemeManager.initialize(); // Fetches from Firebase + AppColors.initializeEventThemeManager(eventThemeManager); + + // Existing: Load dark mode + getIt.get().loadDarkMode(); + + // NEW: Check for active events (will use Firebase data) + eventThemeManager.checkDaily(); +} + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, profileVm, _) { + final isArabic = EasyLocalization.of(context)?.locale.languageCode == "ar"; + + return MaterialApp( + // NEW: Include event theme in key to force rebuild + key: ValueKey('app_theme_${profileVm.isDarkMode}_${profileVm.hasActiveEvent}'), + theme: AppTheme.getTheme(isArabic), + darkTheme: AppTheme.getDarkTheme(isArabic), + themeMode: profileVm.isDarkMode ? ThemeMode.dark : ThemeMode.light, + // ... other properties + ); + }, + ); + } +} +``` + +#### Step 4.3: App Lifecycle Integration + +**File**: `lib/services/app_lifecycle_service.dart` (MODIFICATIONS) + +```dart +class AppLifecycleService with WidgetsBindingObserver { + final EventThemeManager _eventThemeManager; + + AppLifecycleService({ + required EventThemeManager eventThemeManager, + }) : _eventThemeManager = eventThemeManager; + + void initialize() { + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + + // Refresh Remote Config when app comes to foreground + if (state == AppLifecycleState.resumed) { + _eventThemeManager.refresh(); + } + } + + void dispose() { + WidgetsBinding.instance.removeObserver(this); + } +} +``` + +--- + +### **Phase 5: UI Components & Firebase Console Setup (Day 5-6)** + +#### Step 5.1: Event Banner Widget (Optional) + +**File**: `lib/widgets/event_banner.dart` (NEW) + +```dart +class EventBanner extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, vm, _) { + if (!vm.hasActiveEvent) return SizedBox.shrink(); + + final theme = vm.activeEventTheme!; + final isArabic = context.locale.languageCode == 'ar'; + + return Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: theme.backgroundGradient, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + if (theme.celebrationBannerImage != null) + Image.asset(theme.celebrationBannerImage!, height: 40), + SizedBox(width: 12), + Text( + isArabic ? theme.metadata.nameAr : theme.metadata.nameEn, + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + }, + ); + } +} +``` + +#### Step 5.2: Confetti Effect (Optional) + +**File**: `lib/widgets/confetti_overlay.dart` (NEW) + +```dart +class ConfettiOverlay extends StatefulWidget { + final Widget child; + + const ConfettiOverlay({required this.child}); + + @override + State createState() => _ConfettiOverlayState(); +} + +class _ConfettiOverlayState extends State { + late ConfettiController _controller; + + @override + void initState() { + super.initState(); + _controller = ConfettiController(duration: Duration(seconds: 3)); + + if (AppColors.showConfetti) { + _controller.play(); + } + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + widget.child, + if (AppColors.showConfetti) + Align( + alignment: Alignment.topCenter, + child: ConfettiWidget( + confettiController: _controller, + // ... confetti configuration + ), + ), + ], + ); + } +} +``` + +--- + +## 🔄 Migration Strategy + +### For Existing Screens +**No changes required!** All existing code continues to work: + +```dart +// Existing code - automatically gets event colors +Container( + color: AppColors.primaryRedColor, // Will be green on National Day +) +``` + +### For New Features +Use event-aware colors: + +```dart +// Check if event is active +if (context.read().hasActiveEvent) { + // Show event-specific UI +} + +// Use event banner +EventBanner(), + +// Use event gradient +Container( + decoration: BoxDecoration( + gradient: AppColors.eventGradient ?? defaultGradient, + ), +) +``` + +--- + +## 🧪 Testing Strategy + +### Firebase Remote Config Testing + +#### Unit Tests +```dart +// Test Remote Config parsing +test('Should parse National Day theme from Remote Config JSON', () { + final json = { + 'id': 'saudi_national_day_2026', + 'type': 'nationalDay', + 'primaryColor': '#006C35', + // ... more fields + }; + + final config = RemoteThemeConfig.fromJson(json); + expect(config.id, 'saudi_national_day_2026'); + expect(config.parseColor('#006C35'), Color(0xFF006C35)); +}); + +// Test date-based activation +test('Should activate theme when current date is in range', () async { + final manager = EventThemeManager(); + await manager.initialize(); + + // Mock Remote Config to return active theme + expect(manager.hasActiveEvent, true); +}); + +// Test priority system +test('Higher priority theme should win when multiple active', () async { + final manager = EventThemeManager(); + // Remote Config returns: Eid (priority 15) + National Day (priority 10) + await manager.initialize(); + + expect(manager.activeEventTheme?.metadata.priority, 15); +}); + +// Test color override +test('Event color should override base color', () { + AppColors.initializeEventThemeManager(mockManager); + + final color = AppColors.primaryRedColor; + expect(color, isNot(Color(0xFFED1C2B))); // Not default red + expect(color, Color(0xFF006C35)); // National Day green +}); +``` + +#### Integration Tests +```dart +testWidgets('Theme should update when Remote Config changes', (tester) async { + await tester.pumpWidget(MyApp()); + + // Initial state - no event theme + expect(find.byType(EventBanner), findsNothing); + + // Simulate Remote Config update + await mockRemoteConfig.setTheme(nationalDayTheme); + await tester.pumpAndSettle(); + + // Event banner should appear + expect(find.byType(EventBanner), findsOneWidget); + expect(find.text('Saudi National Day'), findsOneWidget); +}); + +testWidgets('Dark mode should work with event theme', (tester) async { + await tester.pumpWidget(MyApp()); + + // Activate National Day theme + final profileVm = tester.read(); + profileVm.toggleDarkMode(true); + await tester.pumpAndSettle(); + + // Should use dark variant of event color + final container = tester.widget(find.byType(Container).first); + expect(container.color, Color(0xFF008A45)); // Dark green, not light green +}); +``` + +### Manual Testing Checklist + +#### Basic Functionality +- [ ] Remote Config fetches successfully on app launch +- [ ] Theme activates when date range matches +- [ ] Theme deactivates when date range ends +- [ ] Multiple themes respect priority system +- [ ] Debug mode forces specific theme +- [ ] Global disable switch works (`enable_event_themes: false`) + +#### Color System +- [ ] Event colors override base colors correctly +- [ ] Dark mode variants display correctly +- [ ] Gradient displays when configured +- [ ] Invalid hex colors don't crash app +- [ ] Missing colors fallback to base theme + +#### Dark/Light Mode Integration +- [ ] Toggle dark mode with event active +- [ ] Toggle dark mode with event inactive +- [ ] Event colors adapt to dark mode +- [ ] Existing screens work in both modes + +#### UI Components +- [ ] Event banner displays in Arabic/English +- [ ] Banner image loads from URL +- [ ] Confetti plays when enabled +- [ ] Animations work smoothly +- [ ] Banner hides when event inactive + +#### Firebase Console +- [ ] JSON validation works in console +- [ ] Publish changes propagate to app +- [ ] Conditional targeting works (region, language) +- [ ] A/B testing configuration works +- [ ] Rollback to previous config works + +#### Performance +- [ ] Remote Config fetch doesn't block UI +- [ ] Theme changes are smooth (no jank) +- [ ] Color resolution is fast +- [ ] App startup time unchanged +- [ ] Memory usage normal + +#### Edge Cases +- [ ] Invalid JSON doesn't crash app +- [ ] Network failure doesn't break app +- [ ] Empty theme array works +- [ ] Overlapping date ranges handled +- [ ] Past event dates ignored +- [ ] Future event dates wait + +--- + +## 📊 Performance Considerations + +### Optimization 1: Remote Config Caching +```dart +class RemoteConfigService { + // Firebase SDK automatically caches for minimumFetchInterval + // Default: 1 hour (production), 0 seconds (debug) + + Future initialize() async { + await _remoteConfig.setConfigSettings(RemoteConfigSettings( + fetchTimeout: const Duration(minutes: 1), + minimumFetchInterval: const Duration(hours: 1), // Cache for 1 hour + )); + } +} +``` + +### Optimization 2: Color Caching +```dart +class DynamicEventTheme { + final Map _colorCache = {}; + + @override + Color? getColor(String colorKey, bool isDarkMode) { + final cacheKey = '${colorKey}_$isDarkMode'; + + if (_colorCache.containsKey(cacheKey)) { + return _colorCache[cacheKey]; + } + + final color = _computeColor(colorKey, isDarkMode); + _colorCache[cacheKey] = color; + return color; + } +} +``` + +### Optimization 3: Lazy Theme Loading +```dart +class EventThemeManager { + // Themes are only created when Remote Config has data + // No hardcoded themes = smaller app bundle + + Future _loadThemesFromRemoteConfig() async { + // Only parse JSON when needed + final themesConfig = _remoteConfigService.getEventThemesConfig(); + + _availableThemes = themesConfig + .map((json) => DynamicEventTheme(RemoteThemeConfig.fromJson(json))) + .toList(); + } +} +``` + +### Optimization 4: App Lifecycle Optimization +```dart +class AppLifecycleService { + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + // Only refresh when app comes to foreground + // Not on every frame + _eventThemeManager.refresh(); + } + } +} +``` + +--- + +## 🔥 Firebase Remote Config Setup Guide + +### Step 1: Firebase Console Configuration + +#### 1.1 Create Remote Config Parameters + +Navigate to Firebase Console → Remote Config → Add parameter + +**Parameter 1: `event_themes_config`** +- **Type**: JSON +- **Description**: Array of event theme configurations +- **Default Value**: `[]` (empty array) + +**Parameter 2: `enable_event_themes`** +- **Type**: Boolean +- **Description**: Global on/off switch for event themes +- **Default Value**: `true` + +**Parameter 3: `debug_event_theme`** +- **Type**: String +- **Description**: Force specific theme for testing (e.g., "nationalDay") +- **Default Value**: `""` (empty string) + +--- + +### Step 2: Event Theme Configuration Examples + +#### Example 1: Saudi National Day Theme + +```json +[ + { + "id": "saudi_national_day_2026", + "type": "nationalDay", + "nameEn": "Saudi National Day", + "nameAr": "اليوم الوطني السعودي", + "startDate": "2026-09-20T00:00:00Z", + "endDate": "2026-09-25T23:59:59Z", + "isActive": true, + "priority": 10, + "primaryColor": "#006C35", + "primaryColorDark": "#008A45", + "secondaryColor": "#FFFFFF", + "secondaryColorDark": "#E8E8E8", + "accentColor": "#FFD700", + "accentColorDark": "#FFC700", + "scaffoldBgColor": "#F5FFF5", + "scaffoldBgColorDark": "#0A2617", + "cardBgColor": "#FFFFFF", + "cardBgColorDark": "#1E3A24", + "appBarColor": "#006C35", + "appBarColorDark": "#008A45", + "gradientColors": ["#006C35", "#008A45", "#00A651"], + "gradientStart": "topLeft", + "gradientEnd": "bottomRight", + "bannerImageUrl": "https://yourdomain.com/images/national_day_banner.png", + "showConfetti": true, + "enableAnimations": true + } +] +``` + +#### Example 2: Ramadan Theme + +```json +[ + { + "id": "ramadan_2026", + "type": "ramadan", + "nameEn": "Ramadan Kareem", + "nameAr": "رمضان كريم", + "startDate": "2026-02-18T00:00:00Z", + "endDate": "2026-03-20T23:59:59Z", + "isActive": true, + "priority": 12, + "primaryColor": "#1C3664", + "primaryColorDark": "#2A4A7F", + "secondaryColor": "#FFD700", + "secondaryColorDark": "#FFC700", + "accentColor": "#F5C842", + "accentColorDark": "#E8B923", + "scaffoldBgColor": "#F0F4FF", + "scaffoldBgColorDark": "#0D1929", + "gradientColors": ["#1C3664", "#2A4A7F", "#3D5FA8"], + "gradientStart": "topCenter", + "gradientEnd": "bottomCenter", + "bannerImageUrl": "https://yourdomain.com/images/ramadan_banner.png", + "showConfetti": false, + "enableAnimations": true + } +] +``` + +#### Example 3: Multiple Active Events (Priority System) + +```json +[ + { + "id": "eid_al_fitr_2026", + "type": "eid", + "nameEn": "Eid Al-Fitr", + "nameAr": "عيد الفطر", + "startDate": "2026-03-21T00:00:00Z", + "endDate": "2026-03-24T23:59:59Z", + "isActive": true, + "priority": 15, + "primaryColor": "#6B4FA0", + "primaryColorDark": "#8A6BB8", + "secondaryColor": "#FFD700", + "accentColor": "#E8B923", + "showConfetti": true, + "enableAnimations": true + }, + { + "id": "saudi_founding_day_2026", + "type": "foundingDay", + "nameEn": "Saudi Founding Day", + "nameAr": "يوم التأسيس السعودي", + "startDate": "2026-02-20T00:00:00Z", + "endDate": "2026-02-24T23:59:59Z", + "isActive": true, + "priority": 8, + "primaryColor": "#8B4513", + "primaryColorDark": "#A0522D", + "accentColor": "#D2691E", + "showConfetti": false + }, + { + "id": "new_year_2027", + "type": "newYear", + "nameEn": "Happy New Year 2027", + "nameAr": "سنة جديدة سعيدة ٢٠٢٧", + "startDate": "2026-12-30T00:00:00Z", + "endDate": "2027-01-02T23:59:59Z", + "isActive": true, + "priority": 5, + "primaryColor": "#0F4C81", + "accentColor": "#C0C0C0", + "showConfetti": true + } +] +``` + +--- + +### Step 3: Testing Configurations + +#### Debug Mode Configuration + +To test a specific theme without waiting for dates: + +**Set `debug_event_theme` to:** +- `"nationalDay"` - Force National Day theme +- `"ramadan"` - Force Ramadan theme +- `"eid"` - Force Eid theme +- `""` (empty) - Disable debug mode + +#### Disable All Event Themes + +**Set `enable_event_themes` to:** `false` + +This will instantly disable all event themes across all users. + +--- + +### Step 4: Conditional Targeting (Advanced) + +Firebase Remote Config supports conditional targeting: + +#### Example: Saudi Arabia Only +``` +Condition: Country/Region == SA +``` + +#### Example: Arabic Language Users Only +``` +Condition: Languages includes ar +``` + +#### Example: Beta Users +``` +Condition: User in audience "beta_testers" +``` + +#### Example: A/B Testing +``` +Condition A (50%): Show National Day theme with confetti +Condition B (50%): Show National Day theme without confetti +``` + +--- + +### Step 5: Rollout Strategy + +#### Gradual Rollout +1. **Day 1**: 10% of users +2. **Day 2**: 25% of users +3. **Day 3**: 50% of users +4. **Day 4**: 100% of users + +#### Instant Activation +- Set date range to current date +- Publish configuration +- Users will see theme on next app foreground (auto-refresh) + +#### Emergency Rollback +- Set `enable_event_themes` to `false` +- OR set theme `isActive` to `false` +- Publish immediately +- Users revert to standard theme within 1 hour (or next app launch) + +--- + +### Step 6: Color Palette Reference + +#### Saudi National Day Colors +``` +Primary Green: #006C35 (Light) / #008A45 (Dark) +Saudi White: #FFFFFF (Light) / #E8E8E8 (Dark) +Gold Accent: #FFD700 +Background: #F5FFF5 (Light) / #0A2617 (Dark) +``` + +#### Ramadan Colors +``` +Night Blue: #1C3664 (Light) / #2A4A7F (Dark) +Crescent Gold: #F5C842 (Light) / #E8B923 (Dark) +Background: #F0F4FF (Light) / #0D1929 (Dark) +``` + +#### Eid Colors +``` +Islamic Purple: #6B4FA0 (Light) / #8A6BB8 (Dark) +Festive Gold: #FFD700 +Light Gold: #E8B923 +``` + +#### New Year Colors +``` +Midnight Blue: #0F4C81 +Silver: #C0C0C0 +Champagne Gold: #F7E7CE +``` + +--- + +### Step 7: Best Practices + +#### ✅ Do's +- Always provide both light and dark color variants +- Use ISO 8601 format for dates: `"2026-09-20T00:00:00Z"` +- Test theme in both English and Arabic +- Start events 1-2 days before actual date for early celebration +- End events 1 day after to allow for celebrations +- Use priority system: Higher numbers = higher priority +- Test with `debug_event_theme` before production release + +#### ❌ Don'ts +- Don't overlap same-priority events (use priority system) +- Don't use invalid hex colors (use # prefix) +- Don't set very long date ranges (affects performance) +- Don't forget to set `isActive: true` +- Don't use gradients with less than 2 colors +- Don't set all themes to priority 999 (defeats purpose) + +--- + +### Step 8: Monitoring & Analytics + +Track these metrics in Firebase Analytics: + +```dart +// Log when theme is activated +GALogger('event_theme_activated', parameters: { + 'theme_id': 'saudi_national_day_2026', + 'theme_type': 'nationalDay', + 'is_dark_mode': isDarkMode, + 'priority': 10, +}); + +// Log user engagement +GALogger('event_theme_viewed', parameters: { + 'theme_id': themeId, + 'session_duration': sessionDuration, + 'confetti_shown': showConfetti, +}); + +// Log errors +GALogger('event_theme_error', parameters: { + 'error_type': 'invalid_color', + 'theme_id': themeId, + 'details': errorDetails, +}); +``` + +--- + +### Step 9: Quick Reference - Firebase Console Steps + +1. **Open Firebase Console** → Your Project +2. **Navigate** to Remote Config (left sidebar) +3. **Click** "Add parameter" +4. **Enter** parameter name: `event_themes_config` +5. **Select** type: JSON +6. **Paste** theme configuration JSON +7. **Click** "Publish changes" +8. **Wait** ~1-5 minutes for propagation +9. **Test** app (pull down to refresh OR restart app) +10. **Verify** theme is active in your app + +--- + +### Step 10: Troubleshooting + +| Issue | Solution | +|-------|----------| +| Theme not appearing | Check date ranges, ensure `isActive: true` | +| Wrong theme active | Check priority values (higher = wins) | +| Colors not changing | Verify hex format (#RRGGBB), check dark mode | +| App crashes | Validate JSON syntax in Firebase Console | +| Gradient not showing | Ensure 2+ colors, valid alignment values | +| Confetti not working | Check `showConfetti: true` in config | +| Debug theme stuck | Set `debug_event_theme` to empty string `""` | +| Remote Config not fetching | Check network, Firebase SDK version | + +--- + +## 🌐 Remote Configuration (Optional - Phase 7) + +### Firebase Remote Config Integration + +**File**: `lib/core/remote_config_service.dart` (NEW) + +```dart +class RemoteConfigService { + final FirebaseRemoteConfig _remoteConfig = FirebaseRemoteConfig.instance; + + Future initialize() async { + await _remoteConfig.setConfigSettings(RemoteConfigSettings( + fetchTimeout: Duration(minutes: 1), + minimumFetchInterval: Duration(hours: 1), + )); + + await _remoteConfig.setDefaults({ + 'active_event_themes': '[]', + 'enable_confetti': false, + }); + + await _remoteConfig.fetchAndActivate(); + } + + List> getActiveEventThemes() { + final json = _remoteConfig.getString('active_event_themes'); + return jsonDecode(json); + } + + bool isConfettiEnabled() { + return _remoteConfig.getBool('enable_confetti'); + } +} +``` + +### Remote Theme Activation +```json +{ + "active_event_themes": [ + { + "type": "nationalDay", + "startDate": "2026-09-20T00:00:00Z", + "endDate": "2026-09-24T23:59:59Z", + "isActive": true, + "priority": 10 + } + ], + "enable_confetti": true +} +``` + +--- + +## 📅 Implementation Timeline + +| Phase | Duration | Deliverables | +|-------|----------|--------------| +| **Phase 1** | 2 days | Base infrastructure, models, manager | +| **Phase 2** | 1 day | Concrete event themes (4 themes) | +| **Phase 3** | 1 day | Color system integration | +| **Phase 4** | 1 day | ViewModel integration | +| **Phase 5** | 1 day | App initialization, DI setup | +| **Phase 6** | 1 day | UI components (banner, confetti) | +| **Phase 7** | 1 day | Remote config (optional) | +| **Testing** | 2 days | Unit tests, manual testing | +| **Total** | **10 days** | Full implementation + testing | + +--- + +## ✅ Benefits Summary + +### 1. **Minimal Code Changes** +- Zero breaking changes to existing screens +- Only 4 files modified, 8 files added +- Existing dark/light mode logic preserved + +### 2. **Scalability** +- Add new event themes by creating single class +- Unlimited simultaneous events (priority-based) +- Easy to extend with new color overrides + +### 3. **Flexibility** +- Auto-activation based on dates +- Manual override for testing/admin +- Remote configuration support +- Per-event dark mode variants + +### 4. **Maintainability** +- Clear separation of concerns +- Type-safe theme definitions +- Centralized event management +- Easy debugging with event state + +### 5. **User Experience** +- Seamless theme transitions +- Festive UI without disruption +- Preserves user's dark mode preference +- Optional celebration effects (confetti, banners) + +--- + +## 🚀 Quick Start After Approval + +1. Create base infrastructure (Phase 1) +2. Implement 1 event theme as POC (National Day) +3. Test with dark mode toggle +4. Get stakeholder approval +5. Implement remaining themes +6. Add UI enhancements +7. Deploy with remote config + +--- + +## 📝 Notes & Considerations + +### A. Hijri Calendar Support +For Islamic events (Ramadan, Eid), integrate Hijri calendar: +```dart +// Use package: hijri +import 'package:hijri/hijri.dart'; + +DateTime getRamadanStartDate(int gregorianYear) { + // Convert Hijri date to Gregorian + final hijri = HijriCalendar.fromDate(DateTime(gregorianYear, 1, 1)); + // Find Ramadan (month 9) + // ... +} +``` + +### B. Asset Management +Store event-specific assets: +``` +assets/ +└── images/ + └── events/ + ├── national_day_banner.png + ├── eid_banner.png + ├── ramadan_crescent.png + └── new_year_fireworks.png +``` + +### C. Analytics Integration +Track event theme engagement: +```dart +GALogger('event_theme_viewed', parameters: { + 'theme_type': 'national_day', + 'is_dark_mode': isDarkMode, +}); +``` + +--- + +## 🎯 Success Criteria + +- [ ] National Day theme activates automatically Sept 20-24 +- [ ] Theme works in both light and dark modes +- [ ] Existing screens show event colors without modification +- [ ] User can toggle dark mode while event is active +- [ ] Multiple events respect priority system +- [ ] Theme deactivates automatically after event +- [ ] Event banner displays correctly in Arabic/English +- [ ] Performance impact < 5ms per frame +- [ ] Remote config can enable/disable themes +- [ ] Zero crashes related to theme switching + +--- + +**Created**: May 15, 2026 +**Author**: AI Architecture Team +**Status**: Ready for Implementation +**Version**: 1.0 + diff --git a/THEME_IMPLEMENTATION_GUIDE.md b/THEME_IMPLEMENTATION_GUIDE.md new file mode 100644 index 00000000..8026431c --- /dev/null +++ b/THEME_IMPLEMENTATION_GUIDE.md @@ -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 callInitializations() async { + // ... Firebase, dependencies setup ... + + // Restore dark mode BEFORE first frame + getIt.get().loadDarkMode(); +} +``` + +### Theme Application in Widget Tree + +```dart +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Consumer( + 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` 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( + 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().toggleDarkMode(true); + +// Check current state +final isDark = context.read().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( + 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 + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 762c964d..0fb36b85 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -41,10 +41,6 @@ android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" tools:node="remove" /> - - - - diff --git a/assets/images/offersanddiscounts/img1.png b/assets/images/offersanddiscounts/img1.png new file mode 100644 index 00000000..e2ac1909 Binary files /dev/null and b/assets/images/offersanddiscounts/img1.png differ diff --git a/assets/images/offersanddiscounts/img2.png b/assets/images/offersanddiscounts/img2.png new file mode 100644 index 00000000..a85f4b8a Binary files /dev/null and b/assets/images/offersanddiscounts/img2.png differ diff --git a/assets/images/offersanddiscounts/img3.png b/assets/images/offersanddiscounts/img3.png new file mode 100644 index 00000000..21d03820 Binary files /dev/null and b/assets/images/offersanddiscounts/img3.png differ diff --git a/assets/images/offersanddiscounts/promo.jpg b/assets/images/offersanddiscounts/promo.jpg new file mode 100644 index 00000000..9275c79c Binary files /dev/null and b/assets/images/offersanddiscounts/promo.jpg differ diff --git a/assets/images/svg/arrow-right-02.svg b/assets/images/svg/arrow-right-02.svg new file mode 100644 index 00000000..1c5ca62a --- /dev/null +++ b/assets/images/svg/arrow-right-02.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/svg/share.svg b/assets/images/svg/share.svg new file mode 100644 index 00000000..c30b9e0f --- /dev/null +++ b/assets/images/svg/share.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/images/svg/shoppingcart.svg b/assets/images/svg/shoppingcart.svg new file mode 100644 index 00000000..d1c36860 --- /dev/null +++ b/assets/images/svg/shoppingcart.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/langs/en-US.json b/assets/langs/en-US.json index 8e1872b9..d9ab9789 100644 --- a/assets/langs/en-US.json +++ b/assets/langs/en-US.json @@ -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", diff --git a/lib/core/api_consts.dart b/lib/core/api_consts.dart index 6a27f641..4a71fa9b 100644 --- a/lib/core/api_consts.dart +++ b/lib/core/api_consts.dart @@ -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'; diff --git a/lib/core/app_assets.dart b/lib/core/app_assets.dart index c4a79aed..37f9c4fd 100644 --- a/lib/core/app_assets.dart +++ b/lib/core/app_assets.dart @@ -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'; diff --git a/lib/core/app_state.dart b/lib/core/app_state.dart index 5eb7a05d..9a4826b0 100644 --- a/lib/core/app_state.dart +++ b/lib/core/app_state.dart @@ -25,6 +25,8 @@ class AppState { _loadProfileImageFromCache(); } + bool isEnabledOffersAndDiscountsCarousel = false; + double userLat = 0.0; set setUserLat(v) => userLat = v; diff --git a/lib/core/cache_consts.dart b/lib/core/cache_consts.dart index 118fed3b..758676b2 100644 --- a/lib/core/cache_consts.dart +++ b/lib/core/cache_consts.dart @@ -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'; diff --git a/lib/core/dependencies.dart b/lib/core/dependencies.dart index 279b61c8..9f8717c1 100644 --- a/lib/core/dependencies.dart +++ b/lib/core/dependencies.dart @@ -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(), localAuth: getIt()), ); + getIt.registerLazySingleton(() => PermissionService()); + // Repositories getIt.registerLazySingleton(() => CommonRepoImp(loggerService: getIt())); getIt.registerLazySingleton(() => AuthenticationRepoImp(loggerService: getIt(), apiClient: getIt())); @@ -184,6 +190,7 @@ class AppDependencies { getIt.registerLazySingleton(() => ServicesPriceListRepoImp(loggerService: getIt(), apiClient: getIt())); getIt.registerLazySingleton(() => ProfileSettingsRepoImp(loggerService: getIt(), apiClient: getIt())); getIt.registerLazySingleton(() => RefundRequestRepoImp(apiClient: getIt(), loggerService: getIt())); + getIt.registerLazySingleton(() => OffersAndDiscountsRepoImp(loggerService: getIt(), apiClient: getIt())); // ViewModels // Global/shared VMs → LazySingleton @@ -256,6 +263,11 @@ class AppDependencies { errorHandlerService: getIt(), )); + getIt.registerLazySingleton(() => ProfilePictureViewModel( + appState: getIt(), + profileSettingsViewModel: getIt(), + )); + getIt.registerLazySingleton(() => DateRangeSelectorRangeViewModel()); getIt.registerLazySingleton(() => DoctorFilterViewModel()); @@ -339,6 +351,11 @@ class AppDependencies { getIt.registerLazySingleton(() => AskDoctorViewModel(askDoctorRepo: getIt(), errorHandlerService: getIt())); + getIt.registerLazySingleton(() => ServicesPriceListViewModel(servicesPriceListRepo: getIt(), errorHandlerService: getIt())); + + getIt.registerLazySingleton(() => OffersAndDiscountsViewModel(offersAndDiscountsRepo: getIt(), errorHandlerService: getIt())); + + getIt.registerLazySingleton(() => DateRangCalenderModel(appState: getIt(), navigationService: getIt(), dialogService: getIt())); getIt.registerLazySingleton(() => RefundRequestViewModel( appState: getIt(), refundRequestRepo: getIt(), diff --git a/lib/features/authentication/authentication_view_model.dart b/lib/features/authentication/authentication_view_model.dart index eaaa6848..c0e55a4d 100644 --- a/lib/features/authentication/authentication_view_model.dart +++ b/lib/features/authentication/authentication_view_model.dart @@ -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 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); }, ); } diff --git a/lib/features/authentication/widgets/otp_verification_screen.dart b/lib/features/authentication/widgets/otp_verification_screen.dart index 41ca087a..00eb768f 100644 --- a/lib/features/authentication/widgets/otp_verification_screen.dart +++ b/lib/features/authentication/widgets/otp_verification_screen.dart @@ -427,12 +427,12 @@ class OTPWidgetState extends State 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 createState() => _OTPVerificationScreenState(); @@ -452,7 +452,7 @@ class _OTPVerificationScreenState extends State { super.initState(); _otpController = TextEditingController(); _startResendTimer(); - if(Platform.isAndroid) { + if (Platform.isAndroid) { checkSignature(); } } @@ -521,7 +521,7 @@ class _OTPVerificationScreenState extends State { }); _otpController.clear(); _startResendTimer(); - widget.onResendOTPPressed(widget.phoneNumber); + widget.onResendOTPPressed(widget.phoneNumber, widget.zipCode); } } @@ -584,12 +584,7 @@ class _OTPVerificationScreenState extends State { 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"), ), ), ), diff --git a/lib/features/hmg_services/hmg_services_view_model.dart b/lib/features/hmg_services/hmg_services_view_model.dart index d76814ec..da1ae80d 100644 --- a/lib/features/hmg_services/hmg_services_view_model.dart +++ b/lib/features/hmg_services/hmg_services_view_model.dart @@ -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, diff --git a/lib/features/medical_file/medical_file_view_model.dart b/lib/features/medical_file/medical_file_view_model.dart index 8b7ce7c3..41a726d9 100644 --- a/lib/features/medical_file/medical_file_view_model.dart +++ b/lib/features/medical_file/medical_file_view_model.dart @@ -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"); } diff --git a/lib/features/my_appointments/my_appointments_view_model.dart b/lib/features/my_appointments/my_appointments_view_model.dart index 38336a5a..0ad21586 100644 --- a/lib/features/my_appointments/my_appointments_view_model.dart +++ b/lib/features/my_appointments/my_appointments_view_model.dart @@ -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 { diff --git a/lib/features/offers_and_discounts/models/offers_and_discounts_response_model.dart b/lib/features/offers_and_discounts/models/offers_and_discounts_response_model.dart new file mode 100644 index 00000000..4a1a250f --- /dev/null +++ b/lib/features/offers_and_discounts/models/offers_and_discounts_response_model.dart @@ -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 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 toJson() { + return { + 'ID': id, + 'Title': title, + 'Description': description, + 'ImageURL': imageUrl, + 'FacilityType': facilityType, + 'StartDate': startDate, + 'EndDate': endDate, + 'Discount': discount, + 'IsActive': isActive, + }; + } +} + diff --git a/lib/features/offers_and_discounts/offers_and_discounts_repo.dart b/lib/features/offers_and_discounts/offers_and_discounts_repo.dart new file mode 100644 index 00000000..53f26a6f --- /dev/null +++ b/lib/features/offers_and_discounts/offers_and_discounts_repo.dart @@ -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>>> getOffersAndDiscounts(); + Future>>> getOffersAndDiscountsHistory(); +} + +class OffersAndDiscountsRepoImp implements OffersAndDiscountsRepo { + final ApiClient apiClient; + final LoggerService loggerService; + + OffersAndDiscountsRepoImp({required this.loggerService, required this.apiClient}); + + @override + Future>>> getOffersAndDiscounts() async { + Map mapDevice = {}; + + try { + GenericApiModel>? 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)).toList().cast(); + + apiResponse = GenericApiModel>( + 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>>> getOffersAndDiscountsHistory() async { + Map mapDevice = {}; + + try { + GenericApiModel>? 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)).toList().cast(); + + apiResponse = GenericApiModel>( + 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())); + } + } +} + diff --git a/lib/features/offers_and_discounts/offers_and_discounts_view_model.dart b/lib/features/offers_and_discounts/offers_and_discounts_view_model.dart new file mode 100644 index 00000000..88e15ce6 --- /dev/null +++ b/lib/features/offers_and_discounts/offers_and_discounts_view_model.dart @@ -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 selectedFacilities = ['All Offers']; // Default to 'All Offers' + String searchQuery = ''; + + OffersAndDiscountsRepo offersAndDiscountsRepo; + ErrorHandlerService errorHandlerService; + + List offersList = []; + List 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 facilities) { + selectedFacilities = facilities; + notifyListeners(); + } + + setSearchQuery(String query) { + searchQuery = query; + notifyListeners(); + } + + Future 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 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 get filteredOffers { + List 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 get filteredHistory { + List 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; + } +} + diff --git a/lib/features/profile_picture/profile_picture_view_model.dart b/lib/features/profile_picture/profile_picture_view_model.dart new file mode 100644 index 00000000..017ca5d8 --- /dev/null +++ b/lib/features/profile_picture/profile_picture_view_model.dart @@ -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 _profileImageVersion = ValueNotifier(0); + + // Getters + File? get selectedImage => _selectedImage; + + Uint8List? get cachedImageBytes => _cachedImageBytes; + + bool get isInitialLoadTriggered => _isInitialLoadTriggered; + + int? get currentPatientId => _currentPatientId; + + ValueNotifier 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 pickImage( + BuildContext context, { + required void Function( + BuildContext context, + bool showFiles, + Function(String, File) onImageSelected, { + required Future Function() checkCameraPermission, + required Future Function() checkGalleryPermission, + }) showImagePicker, + required Future Function(File) compressImage, + required Function(String) onSuccess, + required Function(String) onError, + required String imageSizeTooLargeMessage, + required String failedToProcessImageMessage, + required Future Function(BuildContext) checkCameraPermission, + required Future 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(); + } +} diff --git a/lib/features/profile_settings/profile_settings_view_model.dart b/lib/features/profile_settings/profile_settings_view_model.dart index 64db2481..02cbc10f 100644 --- a/lib/features/profile_settings/profile_settings_view_model.dart +++ b/lib/features/profile_settings/profile_settings_view_model.dart @@ -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().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.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"); } diff --git a/lib/main.dart b/lib/main.dart index 516abfa8..d1e8d102 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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( create: (_) => getIt.get(), ), + ChangeNotifierProvider( + create: (_) => getIt.get(), + ), ChangeNotifierProvider( create: (_) => getIt.get(), ), @@ -256,6 +261,10 @@ void main() async { ChangeNotifierProvider( create: (_) => getIt.get(), ), + ), + ChangeNotifierProvider( + create: (_) => getIt.get(), + ), ChangeNotifierProvider( create: (_) => getIt.get(), ), diff --git a/lib/presentation/appointments/appointment_payment_page.dart b/lib/presentation/appointments/appointment_payment_page.dart index 2c39b43c..d909b73a 100644 --- a/lib/presentation/appointments/appointment_payment_page.dart +++ b/lib/presentation/appointments/appointment_payment_page.dart @@ -862,6 +862,16 @@ class _AppointmentPaymentPageState extends State { }); } }); + }, + onError: (err) { + LoaderBottomSheet.hideLoader(); + showCommonBottomSheetWithoutHeight( + context, + child: Utils.getErrorWidget(loadingText: err.toString()), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); }); }, onError: (err) { showCommonBottomSheetWithoutHeight( diff --git a/lib/presentation/authentication/register_step2.dart b/lib/presentation/authentication/register_step2.dart index 639ebef1..cb96e63d 100644 --- a/lib/presentation/authentication/register_step2.dart +++ b/lib/presentation/authentication/register_step2.dart @@ -42,15 +42,12 @@ class _RegisterNew extends State { 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 { ), 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 { 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 { // 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 { 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 { // 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 { // 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 { ), Divider(height: 1.h, color: AppColors.greyColor), authVM!.isUserFromUAE() - ? Selector? countriesList, NationalityCountries? selectedCountry, bool isArabic})>( + ? Selector? countriesList, NationalityCountries? selectedCountry, bool isArabic})>( selector: (context, authViewModel) { final appState = getIt.get(); return ( @@ -352,9 +347,7 @@ class _RegisterNew extends State { ); }, 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(); return DropdownWidget( @@ -381,16 +374,8 @@ class _RegisterNew extends State { : 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 { // 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 { ), 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, diff --git a/lib/presentation/authentication/saved_login_screen.dart b/lib/presentation/authentication/saved_login_screen.dart index 64afff0b..7f0cdc34 100644 --- a/lib/presentation/authentication/saved_login_screen.dart +++ b/lib/presentation/authentication/saved_login_screen.dart @@ -87,14 +87,12 @@ class _SavedLogin extends State { 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 { 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 { ? 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 { } 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 { 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 { 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 { 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( diff --git a/lib/presentation/book_appointment/book_appointment_page.dart b/lib/presentation/book_appointment/book_appointment_page.dart index 240d2215..00a0e5a2 100644 --- a/lib/presentation/book_appointment/book_appointment_page.dart +++ b/lib/presentation/book_appointment/book_appointment_page.dart @@ -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 { 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 { ], ) : 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 { ], ).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 { 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().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().isArabic() ? AppAssets.arrow_back : AppAssets.arrow_forward, - ) - ], - ).paddingAll(24.w), + ).paddingAll(24.w), ) : SizedBox.shrink(); } @@ -687,13 +693,10 @@ class _BookAppointmentPageState extends State { regionalViewModel.flush(); regionalViewModel.setBottomSheetType(type); // AppointmentViaRegionViewmodel? viewmodel = null; - showCommonBottomSheetWithoutHeight(context, title: "", - titleWidget: Consumer(builder: (_, data, __) => getTitle(data)), - isDismissible: false, + showCommonBottomSheetWithoutHeight(context, title: "", titleWidget: Consumer(builder: (_, data, __) => getTitle(data)), isDismissible: false, child: Consumer(builder: (_, data, __) { - return getRegionalSelectionWidget(data); - }), - callBackFunc: () {}); + return getRegionalSelectionWidget(data); + }), callBackFunc: () {}); } Widget getRegionalSelectionWidget(AppointmentViaRegionViewmodel data) { @@ -749,7 +752,7 @@ class _BookAppointmentPageState extends State { return SizedBox.shrink(); } - void _handleSortByLocationToggle(bool value, AppointmentViaRegionViewmodel regionVM) { + void _handleSortByLocationToggle(bool value, AppointmentViaRegionViewmodel regionVM) { if (value) { final locationUtils = getIt.get(); locationUtils.getLocation( @@ -817,6 +820,7 @@ class _BookAppointmentPageState extends State { } } } + bookAppointmentsViewModel.addListener(listener); bookAppointmentsViewModel.getRegionMappedProjectList(); } @@ -927,52 +931,58 @@ class _BookAppointmentPageState extends State { ).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: () {}, + ); + } } } diff --git a/lib/presentation/book_appointment/review_appointment_page.dart b/lib/presentation/book_appointment/review_appointment_page.dart index 1a40c21b..13a964a6 100644 --- a/lib/presentation/book_appointment/review_appointment_page.dart +++ b/lib/presentation/book_appointment/review_appointment_page.dart @@ -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 { late MyAppointmentsViewModel myAppointmentsViewModel; late SymptomsCheckerViewModel symptomsCheckerViewModel; + Uint8List? _cachedImageBytes; + String? _cachedImageDataHash; + @override Widget build(BuildContext context) { bookAppointmentsViewModel = Provider.of(context, listen: false); @@ -156,11 +163,14 @@ class _ReviewAppointmentPageState extends State { 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(builder: (context, profileVm, _) { + return _buildProfileImage(profileVm); + }), SizedBox(width: 8.h), Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -260,6 +270,79 @@ class _ReviewAppointmentPageState extends State { ); } + 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().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) { diff --git a/lib/presentation/home/landing_page.dart b/lib/presentation/home/landing_page.dart index 5ec7e3f7..89f738b1 100644 --- a/lib/presentation/home/landing_page.dart +++ b/lib/presentation/home/landing_page.dart @@ -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 { ); }, name: ('${appState.getAuthenticatedUser()!.firstName!} ${appState.getAuthenticatedUser()!.lastName!}'), - imageWidget: UserAvatarWidget( - width: 42.w, - height: 42.h, - fit: BoxFit.cover, - isCircular: true, + imageWidget: Selector( + 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 { // }), !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 { ), ).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: [ diff --git a/lib/presentation/medical_file/medical_file_page.dart b/lib/presentation/medical_file/medical_file_page.dart index 6984212c..6bbd5074 100644 --- a/lib/presentation/medical_file/medical_file_page.dart +++ b/lib/presentation/medical_file/medical_file_page.dart @@ -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 { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - UserAvatarWidget( - width: 56.h, - height: 56.h, - fit: BoxFit.cover, - isCircular: true, + Selector( + 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 { 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 { 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 { 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 { 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 { ? 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 { ? 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 { 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 { // 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 { 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 { 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 { ); }), SizedBox(height: 16.h), - Selector listRequest, List listReady})>( - selector: (context, vm) => ( - isLoading: vm.isPatientMedicalReportsListLoading, - listRequest: vm.patientMedicalReportRequestedList, - listReady: vm.patientMedicalReportReadyList - ), + Selector listRequest, List 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 { 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 { weight: FontWeight.w600, ), ), - Utils.buildSvgWithAssets( - icon: getIt.get().isArabic() ? AppAssets.arrow_back : AppAssets.arrow_forward, width: 18.w, height: 18.h), + Utils.buildSvgWithAssets(icon: getIt.get().isArabic() ? AppAssets.arrow_back : AppAssets.arrow_forward, width: 18.w, height: 18.h), ], ), Spacer(), diff --git a/lib/presentation/my_family/my_family.dart b/lib/presentation/my_family/my_family.dart index d25154c7..4eebc75c 100644 --- a/lib/presentation/my_family/my_family.dart +++ b/lib/presentation/my_family/my_family.dart @@ -92,7 +92,10 @@ class _FamilyMedicalScreenState extends State { Selector patientFiles, List 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), ), diff --git a/lib/presentation/my_family/widget/family_cards.dart b/lib/presentation/my_family/widget/family_cards.dart index d2706732..2f48bcbe 100644 --- a/lib/presentation/my_family/widget/family_cards.dart +++ b/lib/presentation/my_family/widget/family_cards.dart @@ -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 { AppState appState = getIt(); + final PermissionService _permissionService = getIt(); late InsuranceViewModel insuranceViewModel; late ProfileSettingsViewModel profileSettingsViewModel; - File? _selectedImage; - bool _isUploadingImage = false; @override void initState() { @@ -77,300 +75,27 @@ class _FamilyCardsState extends State { } 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 _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(); - 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 _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 { 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( - AppColors.whiteColor, - ), - ).paddingAll(4.w), - ) - : Icon( - Icons.camera_alt, + child: Consumer( + 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( + AppColors.whiteColor, + ), + ).paddingAll(4.w), + ) + : Icon( + Icons.camera_alt, + color: AppColors.whiteColor, + size: 10.w, + ), + ), + ); + }, ), ), ], diff --git a/lib/presentation/offers_and_discounts/offer_and_discounts_full_screen_swiper_page.dart b/lib/presentation/offers_and_discounts/offer_and_discounts_full_screen_swiper_page.dart new file mode 100644 index 00000000..8e7a53cd --- /dev/null +++ b/lib/presentation/offers_and_discounts/offer_and_discounts_full_screen_swiper_page.dart @@ -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? images; + final int initialIndex; + + const OfferAndDiscountsFullScreenSwiperPage({ + super.key, + this.images, + this.initialIndex = 0, + }); + + @override + State createState() => _OfferAndDiscountsFullScreenSwiperPageState(); +} + +class _OfferAndDiscountsFullScreenSwiperPageState extends State { + late PageController _pageController; + late int _currentPage; + late List _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().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, + ), + ), + ), + ); + } +} diff --git a/lib/presentation/offers_and_discounts/offers_and_discounts_detailed_page.dart b/lib/presentation/offers_and_discounts/offers_and_discounts_detailed_page.dart new file mode 100644 index 00000000..86dedbcd --- /dev/null +++ b/lib/presentation/offers_and_discounts/offers_and_discounts_detailed_page.dart @@ -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), + ), + ); + } +} diff --git a/lib/presentation/offers_and_discounts/offers_and_discounts_history_page.dart b/lib/presentation/offers_and_discounts/offers_and_discounts_history_page.dart new file mode 100644 index 00000000..74ef2426 --- /dev/null +++ b/lib/presentation/offers_and_discounts/offers_and_discounts_history_page.dart @@ -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 createState() => _OffersAndDiscountsHistoryPageState(); +} + +class _OffersAndDiscountsHistoryPageState extends State { + 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(); + offersAndDiscountsViewModel = Provider.of(context, listen: false); + + return CollapsingListView( + title: '${LocaleKeys.order.tr(context: context)} ${LocaleKeys.history.tr(context: context)}', + child: SingleChildScrollView( + child: Consumer(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), + ], + ); + }), + ), + ); + } +} diff --git a/lib/presentation/offers_and_discounts/offers_and_discounts_page.dart b/lib/presentation/offers_and_discounts/offers_and_discounts_page.dart new file mode 100644 index 00000000..a38c7935 --- /dev/null +++ b/lib/presentation/offers_and_discounts/offers_and_discounts_page.dart @@ -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 createState() => _OffersAndDiscountsPageState(); +} + +class _OffersAndDiscountsPageState extends State { + 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(); + offersAndDiscountsViewModel = Provider.of(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(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); + }), + ), + ); + } +} diff --git a/lib/presentation/offers_and_discounts/widgets/offers_and_discount_type_selection_widget.dart b/lib/presentation/offers_and_discounts/widgets/offers_and_discount_type_selection_widget.dart new file mode 100644 index 00000000..11713003 --- /dev/null +++ b/lib/presentation/offers_and_discounts/widgets/offers_and_discount_type_selection_widget.dart @@ -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 selectedOffers; + final Function(List) 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 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); + }); + } +} diff --git a/lib/presentation/offers_and_discounts/widgets/offers_and_discounts.dart b/lib/presentation/offers_and_discounts/widgets/offers_and_discounts.dart new file mode 100644 index 00000000..cfd6a86e --- /dev/null +++ b/lib/presentation/offers_and_discounts/widgets/offers_and_discounts.dart @@ -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 createState() => _OffersAndDiscountsCarouselState(); +} + +class _OffersAndDiscountsCarouselState extends State { + final ScrollController _scrollController = ScrollController(); + Timer? _autoScrollTimer; + + // List of offer images - you can add more images here + final List 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, + ), + ); + }, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/profile_settings/widgets/profile_picture_widget.dart b/lib/presentation/profile_settings/widgets/profile_picture_widget.dart index 6b30c0fc..06cf3a8f 100644 --- a/lib/presentation/profile_settings/widgets/profile_picture_widget.dart +++ b/lib/presentation/profile_settings/widgets/profile_picture_widget.dart @@ -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 { final AppState _appState = getIt.get(); - 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(); @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.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(); + 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(); - - 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(); - 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().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(); - // 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 _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 _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(); - 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 { } // 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 { ); } - // Use cached decoded bytes — update cache if source data changed - final String? imageData = GetIt.instance().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 { @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( - builder: (context, profileVm, _) { + return Consumer2( + builder: (context, profilePictureViewModel, profileVm, _) { // If we already have cached bytes, show the image even while "loading" // to prevent the shimmer→image 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( + 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 diff --git a/lib/services/navigation_service.dart b/lib/services/navigation_service.dart index 3f0fb2a1..a83d4e3d 100644 --- a/lib/services/navigation_service.dart +++ b/lib/services/navigation_service.dart @@ -58,10 +58,10 @@ class NavigationService { } Future pushToOtpScreen( - {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)), ); } diff --git a/lib/services/permission_service.dart b/lib/services/permission_service.dart index 62872340..5f749af8 100644 --- a/lib/services/permission_service.dart +++ b/lib/services/permission_service.dart @@ -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 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 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) { diff --git a/lib/widgets/dropdown/dropdown_widget.dart b/lib/widgets/dropdown/dropdown_widget.dart index 7f76d8a6..32f6b3f1 100644 --- a/lib/widgets/dropdown/dropdown_widget.dart +++ b/lib/widgets/dropdown/dropdown_widget.dart @@ -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( - context: context, - position: RelativeRect.fromLTRB( - offset.dx, - offset.dy + renderBox.size.height, - offset.dx + renderBox.size.width, - 0, - ), - items: dropdownItems - .map( - (e) => PopupMenuItem( - 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( + 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, ), ), diff --git a/lib/widgets/image_picker.dart b/lib/widgets/image_picker.dart index 68d65bde..4d87929b 100644 --- a/lib/widgets/image_picker.dart +++ b/lib/widgets/image_picker.dart @@ -79,7 +79,7 @@ class ImageOptions { } }, onFilesTap: () async { - FilePickerResult? result = await FilePicker.platform.pickFiles( + FilePickerResult? result = await FilePicker.pickFiles( type: FileType.custom, allowedExtensions: [ 'jpg', diff --git a/lib/widgets/user_avatar_widget.dart b/lib/widgets/user_avatar_widget.dart index 3271adf4..84499efa 100644 --- a/lib/widgets/user_avatar_widget.dart +++ b/lib/widgets/user_avatar_widget.dart @@ -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(