From 17c45e16564d76b9603ae9fc3e4426161740d534 Mon Sep 17 00:00:00 2001 From: faizatflutter Date: Wed, 15 Jul 2026 12:52:50 +0300 Subject: [PATCH] Added dev tools feature --- assets/langs/ar-SA.json | 60 +- assets/langs/en-US.json | 60 +- lib/core/api/http_client_manager.dart | 154 ++++- lib/core/api_consts.dart | 3 + .../dev_tools/api_logger_service.dart | 31 + .../dev_tools/environment_config_service.dart | 186 ++++++ .../dev_tools/models/api_log_entry.dart | 108 ++++ lib/generated/codegen_loader.g.dart | 50 ++ lib/generated/locale_keys.g.dart | 43 +- lib/main.dart | 7 + .../dev_tools/api_log_screen.dart | 565 ++++++++++++++++++ .../dev_tools/environment_config_sheet.dart | 336 +++++++++++ .../profile_settings/profile_settings.dart | 23 + lib/theme/app_theme.dart | 20 +- pubspec.yaml | 6 +- 15 files changed, 1605 insertions(+), 47 deletions(-) create mode 100644 lib/features/dev_tools/api_logger_service.dart create mode 100644 lib/features/dev_tools/environment_config_service.dart create mode 100644 lib/features/dev_tools/models/api_log_entry.dart create mode 100644 lib/presentation/dev_tools/api_log_screen.dart create mode 100644 lib/presentation/dev_tools/environment_config_sheet.dart diff --git a/assets/langs/ar-SA.json b/assets/langs/ar-SA.json index ed554e97..ef54fb02 100644 --- a/assets/langs/ar-SA.json +++ b/assets/langs/ar-SA.json @@ -1960,12 +1960,56 @@ "refund": "استرداد", "parkingQR": "رمز الاستجابة السريعة لوقوف السيارات", "parkingQRDesc": "امسح رمز الاستجابة السريعة (QR Code) أدناه عند بوابة الخروج من موقف السيارات:", - "livecareUpdateInsuranceSent": "تم استلام طلب تحديث بيانات التأمين الخاص بك، ويجري العمل عليه حالياً. سنقوم بإشعارك فور اكتمال التحديث.", - "livecareUpdateInsurance": "سيتم تحويل طلب تحديث بيانات التأمين إلى فريق LiveCare المختص لاستكمال الإجراءات. هل تود المتابعة؟", - "schedule": "جدول", - "doctorSchedule": "جدول الطبيب", - "weeklyShift": "الفترات الأسبوعية", - "dayOff": "إجازة", - "notAvailable": "غير متاح", - "hide": "إخفاء" + "livecareUpdateInsuranceSent": "تم استلام طلب تحديث بيانات التأمين الخاص بك، ويجري العمل عليه حالياً. سنقوم بإشعارك فور اكتمال التحديث.", + "livecareUpdateInsurance": "سيتم تحويل طلب تحديث بيانات التأمين إلى فريق LiveCare المختص لاستكمال الإجراءات. هل تود المتابعة؟", + "schedule": "جدول", + "doctorSchedule": "جدول الطبيب", + "weeklyShift": "الفترات الأسبوعية", + "dayOff": "إجازة", + "notAvailable": "غير متاح", + "hide": "إخفاء", + "environmentConfig": "إعدادات البيئة", + "apiLogs": "Api سجلات", + "searchByUrl": "البحث حسب عنوان URL أو النقطة النهائية أو الطريقة أو البيئة...", + "total": "المجموع", + "success": "نجح", + "failed": "فشل", + "clearAll": "مسح الكل", + "exportAll": "تصدير الكل", + "showingLogs": "عرض {count} من {total} سجلات", + "noApiLogsYet": "لا توجد سجلات API حتى الآن", + "noLogsMatch": "لا توجد سجلات مطابقة لبحثك", + "apiCallsWillAppear": "ستظهر مكالمات API هنا بمجرد إجرائها", + "clearAllLogs": "مسح جميع السجلات", + "clearLogsConfirm": "هل أنت متأكد أنك تريد مسح جميع سجلات API؟ لا يمكن التراجع عن هذا الإجراء.", + "allLogsCleared": "تم مسح جميع السجلات بنجاح", + "allLogsCopied": "تم نسخ جميع السجلات إلى الحافظة", + "request": "الطلب", + "response": "الاستجابة", + "headers": "الرؤوس", + "copyAsText": "نسخ كنص", + "copiedAsText": "تم النسخ كنص", + "copyAsCurl": "نسخ كـ cURL", + "copiedAsCurl": "تم النسخ كـ cURL", + "copyJson": "نسخ JSON", + "responseJsonCopied": "تم نسخ JSON الاستجابة", + "currentEnvironment": "البيئة الحالية", + "production": "الإنتاج", + "selectEnvironment": "اختر البيئة", + "environmentDetails": "تفاصيل البيئة", + "baseUrl": "عنوان URL الأساسي", + "rcUrl": "عنوان RC URL", + "pharmacyApi": "واجهة برمجة الصيدلية", + "payfortMode": "وضع Payfort", + "sandbox": "بيئة الاختبار", + "applePayMerchant": "معرف تاجر Apple Pay", + "important": "مهم", + "envChangeWarning": "سيؤدي تغيير البيئة إلى إعادة تشغيل التطبيق ومسح جميع البيانات المخزنة مؤقتًا. تأكد من حفظ أي عمل غير محفوظ.", + "resetToDefault": "إعادة تعيين إلى الافتراضي", + "saveAndRestart": "حفظ وإعادة التشغيل", + "resetEnvironment": "إعادة تعيين البيئة", + "resetEnvConfirm": "هل أنت متأكد أنك تريد إعادة التعيين إلى بيئة الإنتاج الافتراضية؟ سيؤدي هذا إلى إعادة تشغيل التطبيق.", + "envResetSuccess": "تم إعادة تعيين البيئة بنجاح", + "envUpdateSuccess": "تم تحديث البيئة بنجاح", + "envUpdateFailed": "فشل تحديث البيئة" } diff --git a/assets/langs/en-US.json b/assets/langs/en-US.json index ae344b01..1b8d12b6 100644 --- a/assets/langs/en-US.json +++ b/assets/langs/en-US.json @@ -1949,14 +1949,58 @@ "invoiceHistory": "Invoice History", "parkingQR": "Parking QR Code", "parkingQRDesc": "Scan the below QR Code at the exit parking gate:", - "livecareUpdateInsuranceSent": "Your insurance update request is being processed. You will be notified once the update is completed.", - "livecareUpdateInsurance": "Please note that your insurance update request will be sent to the our LiveCare team for update. Would you like to proceed?", - "schedule": "Schedule", - "doctorSchedule": "Doctor Schedule", - "weeklyShift": "Weekly Shift", - "dayOff": "Off", - "notAvailable": "Not Available", - "hide": "Hide" + "livecareUpdateInsuranceSent": "Your insurance update request is being processed. You will be notified once the update is completed.", + "livecareUpdateInsurance": "Please note that your insurance update request will be sent to the our LiveCare team for update. Would you like to proceed?", + "schedule": "Schedule", + "doctorSchedule": "Doctor Schedule", + "weeklyShift": "Weekly Shift", + "dayOff": "Off", + "notAvailable": "Not Available", + "hide": "Hide", + "apiLogs": "Api Logs", + "environmentConfig": "Environment Config", + "searchByUrl": "Search by URL, endpoint, method, or environment...", + "total": "Total", + "success": "Success", + "failed": "Failed", + "clearAll": "Clear All", + "exportAll": "Export All", + "showingLogs": "Showing {count} of {total} logs", + "noApiLogsYet": "No API Logs Yet", + "noLogsMatch": "No logs match your search", + "apiCallsWillAppear": "API calls will appear here once made", + "clearAllLogs": "Clear All Logs", + "clearLogsConfirm": "Are you sure you want to clear all API logs? This action cannot be undone.", + "allLogsCleared": "All logs cleared successfully", + "allLogsCopied": "All logs copied to clipboard", + "request": "Request", + "response": "Response", + "headers": "Headers", + "copyAsText": "Copy as Text", + "copiedAsText": "Copied as text", + "copyAsCurl": "Copy as cURL", + "copiedAsCurl": "Copied as cURL", + "copyJson": "Copy JSON", + "responseJsonCopied": "Response JSON copied", + "currentEnvironment": "Current Environment", + "production": "Production", + "selectEnvironment": "Select Environment", + "environmentDetails": "Environment Details", + "baseUrl": "Base URL", + "rcUrl": "RC URL", + "pharmacyApi": "Pharmacy API", + "payfortMode": "Payfort Mode", + "sandbox": "Sandbox", + "applePayMerchant": "Apple Pay Merchant", + "important": "Important", + "envChangeWarning": "Changing the environment will restart the app and clear all cached data. Make sure you have saved any unsaved work.", + "resetToDefault": "Reset to Default", + "saveAndRestart": "Save & Restart", + "resetEnvironment": "Reset Environment", + "resetEnvConfirm": "Are you sure you want to reset to the default production environment? This will restart the app.", + "envResetSuccess": "Environment reset successfully", + "envUpdateSuccess": "Environment updated successfully", + "envUpdateFailed": "Failed to update environment" } diff --git a/lib/core/api/http_client_manager.dart b/lib/core/api/http_client_manager.dart index d264283d..c3736669 100644 --- a/lib/core/api/http_client_manager.dart +++ b/lib/core/api/http_client_manager.dart @@ -1,7 +1,11 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/core/api_consts.dart'; import 'package:hmg_patient_app_new/core/utils/utils.dart'; +import 'package:hmg_patient_app_new/features/dev_tools/api_logger_service.dart'; +import 'package:hmg_patient_app_new/features/dev_tools/models/api_log_entry.dart'; import 'package:hmg_patient_app_new/services/app_lifecycle_service.dart'; import 'package:http/http.dart' as http; @@ -28,13 +32,44 @@ class HttpClientManager { Duration? timeout, int maxRetries = _maxRetries, }) async { - return _executeWithRetry( - () => _client - .post(uri, headers: headers, body: body) - .timeout(timeout ?? _defaultTimeout), - endpoint: uri.pathSegments.isNotEmpty ? uri.pathSegments.last : uri.toString(), - maxRetries: maxRetries, - ); + final startTime = DateTime.now(); + + try { + final response = await _executeWithRetry( + () => _client + .post(uri, headers: headers, body: body) + .timeout(timeout ?? _defaultTimeout), + endpoint: uri.pathSegments.isNotEmpty ? uri.pathSegments.last : uri.toString(), + maxRetries: maxRetries, + ); + + // Log successful API call + if (ApiConsts.enableDevTools) { + _logApiCall( + method: 'POST', + uri: uri, + headers: headers, + requestBody: body, + response: response, + startTime: startTime, + ); + } + + return response; + } catch (e) { + // Log failed API call + if (ApiConsts.enableDevTools) { + _logApiCall( + method: 'POST', + uri: uri, + headers: headers, + requestBody: body, + error: e, + startTime: startTime, + ); + } + rethrow; + } } /// Execute GET request with retry logic @@ -44,13 +79,42 @@ class HttpClientManager { Duration? timeout, int maxRetries = _maxRetries, }) async { - return _executeWithRetry( - () => _client - .get(uri, headers: headers) - .timeout(timeout ?? _defaultTimeout), - endpoint: uri.pathSegments.isNotEmpty ? uri.pathSegments.last : uri.toString(), - maxRetries: maxRetries, - ); + final startTime = DateTime.now(); + + try { + final response = await _executeWithRetry( + () => _client + .get(uri, headers: headers) + .timeout(timeout ?? _defaultTimeout), + endpoint: uri.pathSegments.isNotEmpty ? uri.pathSegments.last : uri.toString(), + maxRetries: maxRetries, + ); + + // Log successful API call + if (ApiConsts.enableDevTools) { + _logApiCall( + method: 'GET', + uri: uri, + headers: headers, + response: response, + startTime: startTime, + ); + } + + return response; + } catch (e) { + // Log failed API call + if (ApiConsts.enableDevTools) { + _logApiCall( + method: 'GET', + uri: uri, + headers: headers, + error: e, + startTime: startTime, + ); + } + rethrow; + } } /// Core retry logic for HTTP requests @@ -212,6 +276,68 @@ class HttpClientManager { await Future.delayed(delay); } + /// Log API call to DevTools + void _logApiCall({ + required String method, + required Uri uri, + Map? headers, + Object? requestBody, + http.Response? response, + Object? error, + required DateTime startTime, + }) { + final duration = DateTime.now().difference(startTime); + final endpoint = uri.pathSegments.isNotEmpty ? uri.pathSegments.last : uri.path; + + Map? parsedRequestBody; + if (requestBody != null) { + try { + if (requestBody is String) { + parsedRequestBody = json.decode(requestBody); + } else { + parsedRequestBody = {'body': requestBody.toString()}; + } + } catch (_) { + parsedRequestBody = {'raw': requestBody.toString()}; + } + } + + dynamic parsedResponseBody; + int statusCode = 0; + bool isSuccess = false; + + if (response != null) { + statusCode = response.statusCode; + isSuccess = statusCode >= 200 && statusCode < 300; + try { + parsedResponseBody = json.decode(response.body); + } catch (_) { + parsedResponseBody = response.body; + } + } else if (error != null) { + parsedResponseBody = error.toString(); + statusCode = 0; + isSuccess = false; + } + + final logEntry = ApiLogEntry( + id: DateTime.now().millisecondsSinceEpoch.toString(), + timestamp: startTime, + method: method, + url: uri.toString(), + endpoint: endpoint, + environment: ApiLogEntry.envFromUrl(uri.toString()), + headers: headers ?? {}, + requestBody: parsedRequestBody, + responseBody: parsedResponseBody, + statusCode: statusCode, + isSuccess: isSuccess, + duration: duration, + ); + + ApiLoggerService.instance.addLog(logEntry); + } + /// Close the HTTP client void dispose() { _client.close(); diff --git a/lib/core/api_consts.dart b/lib/core/api_consts.dart index 2c64b13e..5fd71e7c 100644 --- a/lib/core/api_consts.dart +++ b/lib/core/api_consts.dart @@ -5,6 +5,9 @@ import 'package:hmg_patient_app_new/core/enums.dart'; class ApiConsts { static const maxSmallScreen = 660; + // DevTools Configuration + static const bool enableDevTools = true; // Set to false for production builds + static AppEnvironmentTypeEnum appEnvironmentType = AppEnvironmentTypeEnum.prod; // static String baseUrl = 'https://uat.hmgwebservices.com/'; // HIS API URL UAT diff --git a/lib/features/dev_tools/api_logger_service.dart b/lib/features/dev_tools/api_logger_service.dart new file mode 100644 index 00000000..7b773ba7 --- /dev/null +++ b/lib/features/dev_tools/api_logger_service.dart @@ -0,0 +1,31 @@ +import 'package:flutter/foundation.dart'; +import 'package:hmg_patient_app_new/features/dev_tools/models/api_log_entry.dart'; + +/// Singleton service that collects API call logs in-memory. +class ApiLoggerService extends ChangeNotifier { + ApiLoggerService._internal(); + static final ApiLoggerService instance = ApiLoggerService._internal(); + + final List _logs = []; + + List get logs => List.unmodifiable(_logs.reversed.toList()); + + void addLog(ApiLogEntry entry) { + _logs.add(entry); + // Keep only last 500 logs to prevent memory issues + if (_logs.length > 500) _logs.removeAt(0); + notifyListeners(); + } + + void clear() { + _logs.clear(); + notifyListeners(); + } + + int get totalCalls => _logs.length; + + int get successCalls => _logs.where((log) => log.isSuccess).length; + + int get failedCalls => _logs.where((log) => !log.isSuccess).length; +} + diff --git a/lib/features/dev_tools/environment_config_service.dart b/lib/features/dev_tools/environment_config_service.dart new file mode 100644 index 00000000..8483162a --- /dev/null +++ b/lib/features/dev_tools/environment_config_service.dart @@ -0,0 +1,186 @@ +import 'package:amazon_payfort/amazon_payfort.dart'; +import 'package:flutter_amazonpaymentservices/environment_type.dart'; +import 'package:hmg_patient_app_new/core/api_consts.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/services/cache_service.dart'; + +/// Preset environment configuration +class AppEnvironmentPreset { + final String label; + final AppEnvironmentTypeEnum environmentType; + final String baseUrl; + final String rcBaseUrl; + final String hmgPharmacyApiBaseUrl; + final String tamaraUrl; + final String getTamaraInstallmentsUrl; + final String getTamaraPaymentStatus; + final String qlineUrl; + final String chatUrl; + final String serviceUrl; + final FortEnvironment payFortEnvironment; + final EnvironmentType payFortEnvironmentType; + final String applePayMerchantId; + final String symptomsCheckerUsername; + final String symptomsCheckerPassword; + + const AppEnvironmentPreset({ + required this.label, + required this.environmentType, + required this.baseUrl, + required this.rcBaseUrl, + required this.hmgPharmacyApiBaseUrl, + required this.tamaraUrl, + required this.getTamaraInstallmentsUrl, + required this.getTamaraPaymentStatus, + required this.qlineUrl, + required this.chatUrl, + required this.serviceUrl, + required this.payFortEnvironment, + required this.payFortEnvironmentType, + required this.applePayMerchantId, + required this.symptomsCheckerUsername, + required this.symptomsCheckerPassword, + }); +} + +/// Predefined environment presets +const List kEnvironmentPresets = [ + AppEnvironmentPreset( + label: 'Production', + environmentType: AppEnvironmentTypeEnum.prod, + baseUrl: 'https://hmgwebservices.com/', + rcBaseUrl: 'https://rc.hmg.com/', + hmgPharmacyApiBaseUrl: 'https://hmgpharmacyapi.hmg.com/', + tamaraUrl: 'https://mdlaboratories.com/tamaralive/Home/Checkout', + getTamaraInstallmentsUrl: 'https://mdlaboratories.com/tamaralive/Home/GetInstallments', + getTamaraPaymentStatus: 'https://mdlaboratories.com/tamaralive/api/OnlineTamara/order_status?orderid=', + qlineUrl: 'https://qline.hmg.com/api/PatientCall/PatientInQueue_Detail', + chatUrl: 'https://chat.hmg.com/geneysChat/Index.aspx?RequestedId=', + serviceUrl: 'https://hmgwebservices.com/PayFortWebLive/pages/SendPayFortRequest.aspx', + payFortEnvironment: FortEnvironment.production, + payFortEnvironmentType: EnvironmentType.production, + applePayMerchantId: 'merchant.com.hmgwebservices', + symptomsCheckerUsername: 'mobile_user', + symptomsCheckerPassword: '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92', + ), + AppEnvironmentPreset( + label: 'UAT', + environmentType: AppEnvironmentTypeEnum.uat, + baseUrl: 'https://uat.hmgwebservices.com/', + rcBaseUrl: 'https://rc.hmg.com/uat/', + hmgPharmacyApiBaseUrl: 'https://hmgpharmacyapi.hmg.com/', + tamaraUrl: 'https://epharmacy.hmg.com/tamara/Home/Checkout', + getTamaraInstallmentsUrl: 'https://epharmacy.hmg.com/tamara/Home/getinstallments', + getTamaraPaymentStatus: 'https://epharmacy.hmg.com/tamara/api/OnlineTamara/order_status?orderid=', + qlineUrl: 'https://ms.hmg.com/nscapi/api/PatientCall/PatientInQueue_Detail', + chatUrl: 'https://chat.hmg.com/geneysChat/Index.aspx?RequestedId=', + serviceUrl: 'https://uat.hmgwebservices.com/HMGPayment/pages/SendPayFortRequest.aspx', + payFortEnvironment: FortEnvironment.test, + payFortEnvironmentType: EnvironmentType.sandbox, + applePayMerchantId: 'merchant.com.hmgwebservices.uat', + symptomsCheckerUsername: 'guest_user', + symptomsCheckerPassword: '123456', + ), + AppEnvironmentPreset( + label: 'Dev', + environmentType: AppEnvironmentTypeEnum.dev, + baseUrl: 'https://uat.hmgwebservices.com/', + rcBaseUrl: 'https://rc.hmg.com/uat/', + hmgPharmacyApiBaseUrl: 'https://hmgpharmacyapi.hmg.com/', + tamaraUrl: 'https://epharmacy.hmg.com/tamara/Home/Checkout', + getTamaraInstallmentsUrl: 'https://epharmacy.hmg.com/tamara/Home/getinstallments', + getTamaraPaymentStatus: 'https://epharmacy.hmg.com/tamara/api/OnlineTamara/order_status?orderid=', + qlineUrl: 'https://ms.hmg.com/nscapi/api/PatientCall/PatientInQueue_Detail', + chatUrl: 'https://chat.hmg.com/geneysChat/Index.aspx?RequestedId=', + serviceUrl: 'https://uat.hmgwebservices.com/HMGPayment/pages/SendPayFortRequest.aspx', + payFortEnvironment: FortEnvironment.test, + payFortEnvironmentType: EnvironmentType.sandbox, + applePayMerchantId: 'merchant.com.hmgwebservices.uat', + symptomsCheckerUsername: 'guest_user', + symptomsCheckerPassword: '123456', + ), +]; + +// Keys for SharedPreferences +const String envStoredEnvironmentKey = 'dev_tools_stored_environment'; + +/// Service to manage environment configuration +class EnvironmentConfigService { + static final EnvironmentConfigService instance = EnvironmentConfigService._internal(); + EnvironmentConfigService._internal(); + + final CacheService _cacheService = getIt.get(); + + /// Apply stored environment or fall back to production defaults + Future applyStoredEnvironment() async { + final storedEnvLabel = _cacheService.getString(key: envStoredEnvironmentKey); + + if (storedEnvLabel != null && storedEnvLabel.isNotEmpty) { + try { + final preset = kEnvironmentPresets.firstWhere((p) => p.label == storedEnvLabel); + _applyPresetToApiConsts(preset); + } catch (_) { + // If stored environment not found, use default + _applyDefaultEnvironment(); + } + } else { + _applyDefaultEnvironment(); + } + } + + /// Save and apply preset environment + Future applyPreset(AppEnvironmentPreset preset) async { + await _cacheService.saveString(key: envStoredEnvironmentKey, value: preset.label); + _applyPresetToApiConsts(preset); + } + + /// Reset to default production environment + Future resetToDefault() async { + await _cacheService.remove(key: envStoredEnvironmentKey); + _applyDefaultEnvironment(); + } + + /// Get currently stored environment + String? getCurrentEnvironment() { + return _cacheService.getString(key: envStoredEnvironmentKey); + } + + /// Get current preset if matches + AppEnvironmentPreset? getCurrentPreset() { + final storedEnv = getCurrentEnvironment(); + if (storedEnv == null) return kEnvironmentPresets[0]; // Default to production + + try { + return kEnvironmentPresets.firstWhere((p) => p.label == storedEnv); + } catch (_) { + return null; + } + } + + void _applyPresetToApiConsts(AppEnvironmentPreset preset) { + ApiConsts.appEnvironmentType = preset.environmentType; + ApiConsts.baseUrl = preset.baseUrl; + ApiConsts.rcBaseUrl = preset.rcBaseUrl; + ApiConsts.hmgPharmacyApiBaseUrl = preset.hmgPharmacyApiBaseUrl; + ApiConsts.symptomsCheckerApi = '${preset.hmgPharmacyApiBaseUrl}symptomsapi_live/api/SymptomChecker'; + ApiConsts.symptomsCheckerScheduleAppointment = '${ApiConsts.symptomsCheckerApi}/ScheduleAppointment'; + ApiConsts.TAMARA_URL = preset.tamaraUrl; + ApiConsts.GET_TAMARA_INSTALLMENTS_URL = preset.getTamaraInstallmentsUrl; + ApiConsts.GET_TAMARA_PAYMENT_STATUS = preset.getTamaraPaymentStatus; + ApiConsts.QLINE_URL = preset.qlineUrl; + ApiConsts.CHAT_URL = preset.chatUrl; + ApiConsts.SERVICE_URL = preset.serviceUrl; + ApiConsts.payFortEnvironment = preset.payFortEnvironment; + ApiConsts.payFortEnvironmentType = preset.payFortEnvironmentType; + ApiConsts.applePayMerchantId = preset.applePayMerchantId; + ApiConsts.symptomsCheckerUsername = preset.symptomsCheckerUsername; + ApiConsts.symptomsCheckerPassword = preset.symptomsCheckerPassword; + } + + void _applyDefaultEnvironment() { + // Apply production as default + _applyPresetToApiConsts(kEnvironmentPresets[0]); + } +} + diff --git a/lib/features/dev_tools/models/api_log_entry.dart b/lib/features/dev_tools/models/api_log_entry.dart new file mode 100644 index 00000000..cb781bf2 --- /dev/null +++ b/lib/features/dev_tools/models/api_log_entry.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; + +/// Holds the details of a single API call. +class ApiLogEntry { + final String id; + final DateTime timestamp; + final String method; + final String url; + final String endpoint; + final String environment; + final Map headers; + final Map? requestBody; + final dynamic responseBody; + final int statusCode; + final bool isSuccess; + final Duration? duration; + + ApiLogEntry({ + required this.id, + required this.timestamp, + required this.method, + required this.url, + required this.endpoint, + this.environment = '', + required this.headers, + this.requestBody, + this.responseBody, + required this.statusCode, + required this.isSuccess, + this.duration, + }); + + /// Derives a short environment label from a full URL string. + static String envFromUrl(String url) { + if (url.contains('vidamergeuat')) return 'VidaMerge'; + if (url.contains('vidauat')) return 'VidaPlus'; + if (url.contains('webservices.hmg.com')) return 'PreProd'; + if (url.contains('uat.hmgwebservices')) return 'UAT'; + if (url.contains('rc.hmg.com') && url.contains('/test')) return 'RC-Test'; + if (url.contains('ms.hmg.com/rc')) return 'MS-RC'; + if (url.contains('rc.hmg.com')) return 'RC'; + if (url.contains('mdlaboratories.com/exacartapitest')) return 'Pharmacy-Test'; + if (url.contains('mdlaboratories.com')) return 'Pharmacy'; + if (url.contains('webservices.hmg.com/ClientService')) return 'RSG'; + if (url.contains('hmgwebservices.com')) return 'Production'; + final uri = Uri.tryParse(url); + return uri?.host.isNotEmpty == true ? uri!.host.split('.').first : 'External'; + } + + /// Returns a pretty-printed JSON string of the request body. + String get prettyRequestBody { + if (requestBody == null) return '(none)'; + try { + return const JsonEncoder.withIndent(' ').convert(requestBody); + } catch (_) { + return requestBody.toString(); + } + } + + /// Returns a pretty-printed JSON string of the response body. + String get prettyResponseBody { + if (responseBody == null) return '(none)'; + try { + if (responseBody is String) { + final decoded = json.decode(responseBody as String); + return const JsonEncoder.withIndent(' ').convert(decoded); + } + return const JsonEncoder.withIndent(' ').convert(responseBody); + } catch (_) { + return responseBody.toString(); + } + } + + /// Returns formatted text report. + String toFormattedText() { + final buf = StringBuffer(); + buf.writeln('═══════════════════════════════════'); + buf.writeln('[$method] $url'); + buf.writeln('Status : $statusCode | ${isSuccess ? '✅ Success' : '❌ Failed'}'); + if (environment.isNotEmpty) buf.writeln('Env : $environment'); + buf.writeln('Time : ${timestamp.toIso8601String()}'); + if (duration != null) buf.writeln('Duration: ${duration!.inMilliseconds}ms'); + buf.writeln('───────────────── HEADERS ─────────────────'); + headers.forEach((k, v) => buf.writeln('$k: $v')); + buf.writeln('───────────────── REQUEST ─────────────────'); + buf.writeln(prettyRequestBody); + buf.writeln('──────────────── RESPONSE ─────────────────'); + buf.writeln(prettyResponseBody); + buf.writeln('═══════════════════════════════════'); + return buf.toString(); + } + + /// Returns a cURL command equivalent. + String toCurl() { + final buf = StringBuffer(); + buf.write("curl -X $method \\\n"); + buf.write(" '${url.trim()}' \\\n"); + headers.forEach((k, v) { + buf.write(" -H '$k: $v' \\\n"); + }); + if (requestBody != null && method != 'GET') { + final bodyStr = json.encode(requestBody).replaceAll("'", "\\'"); + buf.write(" -d '$bodyStr'"); + } + return buf.toString(); + } +} + diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index 71fb165d..629e801d 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -1884,4 +1884,54 @@ abstract class LocaleKeys { static const advanceNumber = 'advanceNumber'; static const amountOnly = 'amountOnly'; static const invoiceHistory = 'invoiceHistory'; + static const refund = 'refund'; + static const parkingQR = 'parkingQR'; + static const parkingQRDesc = 'parkingQRDesc'; + static const livecareUpdateInsuranceSent = 'livecareUpdateInsuranceSent'; + static const livecareUpdateInsurance = 'livecareUpdateInsurance'; + static const schedule = 'schedule'; + static const doctorSchedule = 'doctorSchedule'; + static const weeklyShift = 'weeklyShift'; + static const dayOff = 'dayOff'; + static const hide = 'hide'; + static const apiLogs = 'apiLogs'; + static const environmentConfig = 'environmentConfig'; + static const searchByUrl = 'searchByUrl'; + static const clearAll = 'clearAll'; + static const exportAll = 'exportAll'; + static const showingLogs = 'showingLogs'; + static const noApiLogsYet = 'noApiLogsYet'; + static const noLogsMatch = 'noLogsMatch'; + static const apiCallsWillAppear = 'apiCallsWillAppear'; + static const clearAllLogs = 'clearAllLogs'; + static const clearLogsConfirm = 'clearLogsConfirm'; + static const allLogsCleared = 'allLogsCleared'; + static const allLogsCopied = 'allLogsCopied'; + static const response = 'response'; + static const headers = 'headers'; + static const copyAsText = 'copyAsText'; + static const copiedAsText = 'copiedAsText'; + static const copyAsCurl = 'copyAsCurl'; + static const copiedAsCurl = 'copiedAsCurl'; + static const copyJson = 'copyJson'; + static const responseJsonCopied = 'responseJsonCopied'; + static const currentEnvironment = 'currentEnvironment'; + static const production = 'production'; + static const selectEnvironment = 'selectEnvironment'; + static const environmentDetails = 'environmentDetails'; + static const baseUrl = 'baseUrl'; + static const rcUrl = 'rcUrl'; + static const pharmacyApi = 'pharmacyApi'; + static const payfortMode = 'payfortMode'; + static const sandbox = 'sandbox'; + static const applePayMerchant = 'applePayMerchant'; + static const important = 'important'; + static const envChangeWarning = 'envChangeWarning'; + static const resetToDefault = 'resetToDefault'; + static const saveAndRestart = 'saveAndRestart'; + static const resetEnvironment = 'resetEnvironment'; + static const resetEnvConfirm = 'resetEnvConfirm'; + static const envResetSuccess = 'envResetSuccess'; + static const envUpdateSuccess = 'envUpdateSuccess'; + static const envUpdateFailed = 'envUpdateFailed'; } diff --git a/lib/generated/locale_keys.g.dart b/lib/generated/locale_keys.g.dart index 80cea8b4..39d8b308 100644 --- a/lib/generated/locale_keys.g.dart +++ b/lib/generated/locale_keys.g.dart @@ -2,7 +2,7 @@ // ignore_for_file: constant_identifier_names -abstract class LocaleKeys { +abstract class LocaleKeys { static const english = 'english'; static const arabic = 'arabic'; static const login = 'login'; @@ -1948,5 +1948,44 @@ abstract class LocaleKeys { static const weeklyShift = 'weeklyShift'; static const dayOff = 'dayOff'; static const hide = 'hide'; - + static const environmentConfig = 'environmentConfig'; + static const apiLogs = 'apiLogs'; + static const searchByUrl = 'searchByUrl'; + static const clearAll = 'clearAll'; + static const exportAll = 'exportAll'; + static const showingLogs = 'showingLogs'; + static const noApiLogsYet = 'noApiLogsYet'; + static const noLogsMatch = 'noLogsMatch'; + static const apiCallsWillAppear = 'apiCallsWillAppear'; + static const clearAllLogs = 'clearAllLogs'; + static const clearLogsConfirm = 'clearLogsConfirm'; + static const allLogsCleared = 'allLogsCleared'; + static const allLogsCopied = 'allLogsCopied'; + static const response = 'response'; + static const headers = 'headers'; + static const copyAsText = 'copyAsText'; + static const copiedAsText = 'copiedAsText'; + static const copyAsCurl = 'copyAsCurl'; + static const copiedAsCurl = 'copiedAsCurl'; + static const copyJson = 'copyJson'; + static const responseJsonCopied = 'responseJsonCopied'; + static const currentEnvironment = 'currentEnvironment'; + static const production = 'production'; + static const selectEnvironment = 'selectEnvironment'; + static const environmentDetails = 'environmentDetails'; + static const baseUrl = 'baseUrl'; + static const rcUrl = 'rcUrl'; + static const pharmacyApi = 'pharmacyApi'; + static const payfortMode = 'payfortMode'; + static const sandbox = 'sandbox'; + static const applePayMerchant = 'applePayMerchant'; + static const important = 'important'; + static const envChangeWarning = 'envChangeWarning'; + static const resetToDefault = 'resetToDefault'; + static const saveAndRestart = 'saveAndRestart'; + static const resetEnvironment = 'resetEnvironment'; + static const resetEnvConfirm = 'resetEnvConfirm'; + static const envResetSuccess = 'envResetSuccess'; + static const envUpdateSuccess = 'envUpdateSuccess'; + static const envUpdateFailed = 'envUpdateFailed'; } diff --git a/lib/main.dart b/lib/main.dart index aa266871..8ae0dedb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:hmg_patient_app_new/core/api_consts.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/utils.dart'; @@ -16,6 +17,7 @@ import 'package:hmg_patient_app_new/features/authentication/authentication_view_ import 'package:hmg_patient_app_new/features/blood_donation/blood_donation_view_model.dart'; import 'package:hmg_patient_app_new/features/book_appointments/book_appointments_view_model.dart'; import 'package:hmg_patient_app_new/features/contact_us/contact_us_view_model.dart'; +import 'package:hmg_patient_app_new/features/dev_tools/environment_config_service.dart'; import 'package:hmg_patient_app_new/features/doctor_filter/doctor_filter_view_model.dart'; import 'package:hmg_patient_app_new/features/emergency_services/emergency_services_view_model.dart'; import 'package:hmg_patient_app_new/features/habib_wallet/habib_wallet_view_model.dart'; @@ -128,6 +130,11 @@ Future callInitializations() async { // Initialize App Lifecycle Service to monitor background/foreground transitions getIt.get().initialize(); + // Load DevTools environment configuration + if (ApiConsts.enableDevTools) { + await EnvironmentConfigService.instance.applyStoredEnvironment(); + } + // Restore persisted dark-mode preference before the first frame. getIt.get().loadDarkMode(); diff --git a/lib/presentation/dev_tools/api_log_screen.dart b/lib/presentation/dev_tools/api_log_screen.dart new file mode 100644 index 00000000..ab9afead --- /dev/null +++ b/lib/presentation/dev_tools/api_log_screen.dart @@ -0,0 +1,565 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.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/features/dev_tools/api_logger_service.dart'; +import 'package:hmg_patient_app_new/features/dev_tools/models/api_log_entry.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/common_bottom_sheet.dart'; + +class ApiLogScreen extends StatefulWidget { + const ApiLogScreen({super.key}); + + @override + State createState() => _ApiLogScreenState(); +} + +class _ApiLogScreenState extends State { + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + List _filterLogs(List logs) { + if (_searchQuery.isEmpty) return logs; + return logs.where((log) { + final query = _searchQuery.toLowerCase(); + return log.url.toLowerCase().contains(query) || + log.endpoint.toLowerCase().contains(query) || + log.method.toLowerCase().contains(query) || + log.environment.toLowerCase().contains(query); + }).toList(); + } + + @override + Widget build(BuildContext context) { + return CollapsingListView( + title: LocaleKeys.apiLogs.tr(context: context), + isClose: true, + child: ListenableBuilder( + listenable: ApiLoggerService.instance, + builder: (context, _) { + final allLogs = ApiLoggerService.instance.logs; + final filteredLogs = _filterLogs(allLogs); + final totalCalls = ApiLoggerService.instance.totalCalls; + final successCalls = ApiLoggerService.instance.successCalls; + final failedCalls = ApiLoggerService.instance.failedCalls; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Search Bar + Container( + margin: EdgeInsets.only(left: 24.w, right: 24.w, top: 16.h, bottom: 16.h), + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 4.h), + decoration: BoxDecoration( + color: AppColors.whiteColor, + borderRadius: BorderRadius.circular(16.r), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: TextField( + controller: _searchController, + onChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + decoration: InputDecoration( + hintText: LocaleKeys.searchByUrl.tr(context: context), + border: InputBorder.none, + icon: Icon(Icons.search, color: AppColors.greyInfoTextColor), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: Icon(Icons.clear, color: AppColors.greyInfoTextColor), + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + }); + }, + ) + : null, + ), + ), + ), + + // Statistics Bar + Container( + margin: EdgeInsets.only(left: 24.w, right: 24.w, bottom: 16.h), + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: AppColors.whiteColor, + borderRadius: BorderRadius.circular(16.r), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStatItem(LocaleKeys.total.tr(context: context), totalCalls.toString(), AppColors.primaryRedColor), + Container(width: 1, height: 40.h, color: AppColors.greyInfoTextColor), + _buildStatItem(LocaleKeys.success.tr(context: context), successCalls.toString(), AppColors.successColor), + Container(width: 1, height: 40.h, color: AppColors.greyInfoTextColor), + _buildStatItem(LocaleKeys.failed.tr(context: context), failedCalls.toString(), AppColors.errorColor), + ], + ), + ), + + // Action Buttons + Padding( + padding: EdgeInsets.only(left: 24.w, right: 24.w, bottom: 16.h), + child: Row( + children: [ + Expanded( + child: _buildActionButton( + LocaleKeys.clearAll.tr(context: context), + Icons.delete_outline, + AppColors.errorColor, + () => _showClearConfirmation(), + ), + ), + SizedBox(width: 12.w), + Expanded( + child: _buildActionButton( + LocaleKeys.exportAll.tr(context: context), + Icons.file_download_outlined, + AppColors.infoColor, + filteredLogs.isEmpty ? null : () => _exportAllLogs(filteredLogs), + ), + ), + ], + ), + ), + + // Results Count + if (filteredLogs.isNotEmpty) + Padding( + padding: EdgeInsets.only(left: 24.w, right: 24.w, bottom: 12.h), + child: Text( + LocaleKeys.showingLogs + .tr(context: context) + .replaceAll('{count}', filteredLogs.length.toString()) + .replaceAll('{total}', totalCalls.toString()), + style: TextStyle(fontSize: 14.f, color: AppColors.greyInfoTextColor), + ), + ), + + // Logs List + if (filteredLogs.isEmpty) + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(height: 60.h), + Icon(Icons.api_outlined, size: 80.w, color: AppColors.greyInfoTextColor), + SizedBox(height: 16.h), + Text( + _searchQuery.isEmpty ? LocaleKeys.noApiLogsYet.tr(context: context) : LocaleKeys.noLogsMatch.tr(context: context), + style: TextStyle(fontSize: 16.f, color: AppColors.greyInfoTextColor), + ), + if (_searchQuery.isEmpty) ...[ + SizedBox(height: 8.h), + Text( + LocaleKeys.apiCallsWillAppear.tr(context: context), + style: TextStyle(fontSize: 14.f, color: AppColors.greyInfoTextColor), + ), + ], + ], + ), + ) + else + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.only(left: 24.w, right: 24.w, bottom: 24.h), + itemCount: filteredLogs.length, + itemBuilder: (context, index) { + return _ApiLogTile(log: filteredLogs[index]); + }, + ), + ], + ); + }, + ), + ); + } + + Widget _buildStatItem(String label, String value, Color color) { + return Column( + children: [ + Text(value, style: TextStyle(fontSize: 20.f, fontWeight: FontWeight.bold, color: color)), + SizedBox(height: 4.h), + Text(label, style: TextStyle(fontSize: 12.f, color: AppColors.greyInfoTextColor)), + ], + ); + } + + Widget _buildActionButton(String label, IconData icon, Color color, VoidCallback? onTap) { + final isEnabled = onTap != null; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12.r), + child: Container( + padding: EdgeInsets.symmetric(vertical: 12.h, horizontal: 16.w), + decoration: BoxDecoration( + color: isEnabled ? color.withValues(alpha: 0.1) : AppColors.greyInfoTextColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12.r), + border: Border.all( + color: isEnabled ? color.withValues(alpha: 0.3) : AppColors.greyInfoTextColor.withValues(alpha: 0.3), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: isEnabled ? color : AppColors.greyInfoTextColor, size: 20.w), + SizedBox(width: 8.w), + Text(label, style: TextStyle(fontSize: 14.f, color: isEnabled ? color : AppColors.greyInfoTextColor, fontWeight: FontWeight.bold)), + ], + ), + ), + ); + } + + void _showClearConfirmation() { + showCommonBottomSheetWithoutHeight( + context, + title: LocaleKeys.clearAllLogs.tr(context: context), + child: Utils.getWarningWidget( + loadingText: LocaleKeys.clearLogsConfirm.tr(context: context), + isShowActionButtons: true, + onCancelTap: () => Navigator.pop(context), + onConfirmTap: () { + ApiLoggerService.instance.clear(); + Navigator.pop(context); + Utils.showToast(LocaleKeys.allLogsCleared.tr(context: context)); + }, + ), + callBackFunc: () {}, + isFullScreen: false, + ); + } + + void _exportAllLogs(List logs) { + final buffer = StringBuffer(); + buffer.writeln('═════════════════════════════════════════════'); + buffer.writeln('API LOGS EXPORT'); + buffer.writeln('Generated: ${DateTime.now()}'); + buffer.writeln('Total Logs: ${logs.length}'); + buffer.writeln('═════════════════════════════════════════════\n'); + + for (var log in logs) { + buffer.writeln(log.toFormattedText()); + buffer.writeln('\n'); + } + + Clipboard.setData(ClipboardData(text: buffer.toString())); + Utils.showToast(LocaleKeys.allLogsCopied.tr(context: context)); + } +} + +class _ApiLogTile extends StatefulWidget { + final ApiLogEntry log; + + const _ApiLogTile({required this.log}); + + @override + State<_ApiLogTile> createState() => _ApiLogTileState(); +} + +class _ApiLogTileState extends State<_ApiLogTile> { + bool _isExpanded = false; + int _selectedTabIndex = 0; + + @override + Widget build(BuildContext context) { + final log = widget.log; + final statusColor = log.isSuccess ? AppColors.successColor : AppColors.errorColor; + + return Container( + margin: EdgeInsets.only(bottom: 12.h), + decoration: BoxDecoration( + color: AppColors.whiteColor, + borderRadius: BorderRadius.circular(16.r), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + InkWell( + onTap: () { + setState(() { + _isExpanded = !_isExpanded; + }); + }, + borderRadius: BorderRadius.circular(16.r), + child: Padding( + padding: EdgeInsets.all(16.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + // Method Badge + Container( + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h), + decoration: BoxDecoration( + color: _getMethodColor(log.method).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6.r), + ), + child: Text( + log.method, + style: TextStyle(fontSize: 12.f, color: _getMethodColor(log.method), fontWeight: FontWeight.bold), + ), + ), + SizedBox(width: 8.w), + // Status Code + Container( + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6.r), + ), + child: Text( + log.statusCode.toString(), + style: TextStyle(fontSize: 12.f, color: statusColor, fontWeight: FontWeight.bold), + ), + ), + const Spacer(), + // Duration + if (log.duration != null) + Text('${log.duration!.inMilliseconds}ms', style: TextStyle(fontSize: 12.f, color: AppColors.greyInfoTextColor)), + SizedBox(width: 8.w), + Icon( + _isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, + color: AppColors.greyInfoTextColor, + ), + ], + ), + SizedBox(height: 8.h), + // URL + Text( + log.url, + style: TextStyle(fontSize: 14.f), + maxLines: _isExpanded ? null : 2, + overflow: _isExpanded ? null : TextOverflow.ellipsis, + ), + SizedBox(height: 4.h), + // Environment & Timestamp + Row( + children: [ + if (log.environment.isNotEmpty) ...[ + Icon(Icons.label_outline, size: 14.w, color: AppColors.greyInfoTextColor), + SizedBox(width: 4.w), + Text(log.environment, style: TextStyle(fontSize: 12.f, color: AppColors.greyInfoTextColor)), + SizedBox(width: 12.w), + ], + Icon(Icons.access_time, size: 14.w, color: AppColors.greyInfoTextColor), + SizedBox(width: 4.w), + Text( + DateFormat('HH:mm:ss').format(log.timestamp), + style: TextStyle(fontSize: 12.f, color: AppColors.greyInfoTextColor), + ), + ], + ), + ], + ), + ), + ), + if (_isExpanded) ...[ + Divider(height: 1, color: AppColors.greyInfoTextColor.withValues(alpha: 0.2)), + // Tabs + Container( + decoration: BoxDecoration( + color: AppColors.greyInfoTextColor.withValues(alpha: 0.05), + ), + child: Row( + children: [ + _buildTab(LocaleKeys.request.tr(context: context), 0), + _buildTab(LocaleKeys.response.tr(context: context), 1), + _buildTab(LocaleKeys.headers.tr(context: context), 2), + ], + ), + ), + // Tab Content + Container( + padding: EdgeInsets.all(16.w), + constraints: BoxConstraints(maxHeight: 300.h), + child: SingleChildScrollView( + child: _buildTabContent(), + ), + ), + // Action Buttons + Container( + padding: EdgeInsets.all(12.w), + decoration: BoxDecoration( + color: AppColors.greyInfoTextColor.withValues(alpha: 0.05), + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(16.r), + bottomRight: Radius.circular(16.r), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildActionIcon( + Icons.content_copy, + LocaleKeys.copyAsText.tr(context: context), + () { + Clipboard.setData(ClipboardData(text: log.toFormattedText())); + Utils.showToast(LocaleKeys.copiedAsText.tr(context: context)); + }, + ), + _buildActionIcon( + Icons.code, + LocaleKeys.copyAsCurl.tr(context: context), + () { + Clipboard.setData(ClipboardData(text: log.toCurl())); + Utils.showToast(LocaleKeys.copiedAsCurl.tr(context: context)); + }, + ), + _buildActionIcon( + Icons.data_object, + LocaleKeys.copyJson.tr(context: context), + () { + Clipboard.setData(ClipboardData(text: log.prettyResponseBody)); + Utils.showToast(LocaleKeys.responseJsonCopied.tr(context: context)); + }, + ), + ], + ), + ), + ], + ], + ), + ); + } + + Widget _buildTab(String title, int index) { + final isSelected = _selectedTabIndex == index; + return Expanded( + child: InkWell( + onTap: () { + setState(() { + _selectedTabIndex = index; + }); + }, + child: Container( + padding: EdgeInsets.symmetric(vertical: 12.h), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isSelected ? AppColors.primaryRedColor : Colors.transparent, + width: 2, + ), + ), + ), + child: Center( + child: Text( + title, + style: TextStyle( + fontSize: 14.f, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected ? AppColors.primaryRedColor : AppColors.greyInfoTextColor, + ), + ), + ), + ), + ), + ); + } + + Widget _buildTabContent() { + switch (_selectedTabIndex) { + case 0: + return SelectableText( + widget.log.prettyRequestBody, + style: TextStyle(fontSize: 12.f, fontFamily: 'Courier', color: AppColors.blackBgColor), + ); + case 1: + return SelectableText( + widget.log.prettyResponseBody, + style: TextStyle(fontSize: 12.f, fontFamily: 'Courier', color: AppColors.blackBgColor), + ); + case 2: + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: widget.log.headers.entries.map((entry) { + return Padding( + padding: EdgeInsets.only(bottom: 8.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${entry.key}: ', style: TextStyle(fontSize: 12.f, fontWeight: FontWeight.bold, color: AppColors.blackBgColor)), + Expanded( + child: SelectableText( + entry.value, + style: TextStyle(fontSize: 12.f, color: AppColors.greyInfoTextColor), + ), + ), + ], + ), + ); + }).toList(), + ); + default: + return const SizedBox.shrink(); + } + } + + Widget _buildActionIcon(IconData icon, String label, VoidCallback onTap) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8.r), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), + child: Column( + children: [ + Icon(icon, size: 20.w, color: AppColors.primaryRedColor), + SizedBox(height: 4.h), + Text(label, style: TextStyle(fontSize: 10.f, color: AppColors.greyInfoTextColor)), + ], + ), + ), + ); + } + + Color _getMethodColor(String method) { + switch (method.toUpperCase()) { + case 'GET': + return Colors.blue; + case 'POST': + return Colors.green; + case 'PUT': + return Colors.orange; + case 'DELETE': + return Colors.red; + case 'PATCH': + return Colors.purple; + default: + return AppColors.greyInfoTextColor; + } + } +} diff --git a/lib/presentation/dev_tools/environment_config_sheet.dart b/lib/presentation/dev_tools/environment_config_sheet.dart new file mode 100644 index 00000000..2b7ea10e --- /dev/null +++ b/lib/presentation/dev_tools/environment_config_sheet.dart @@ -0,0 +1,336 @@ +import 'package:amazon_payfort/amazon_payfort.dart'; +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/core/utils/utils.dart'; +import 'package:hmg_patient_app_new/features/dev_tools/environment_config_service.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/buttons/custom_button.dart'; +import 'package:hmg_patient_app_new/widgets/common_bottom_sheet.dart'; + +class EnvironmentConfigSheet extends StatefulWidget { + const EnvironmentConfigSheet({super.key}); + + @override + State createState() => _EnvironmentConfigSheetState(); +} + +class _EnvironmentConfigSheetState extends State { + AppEnvironmentPreset? _selectedPreset; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadCurrentEnvironment(); + } + + Future _loadCurrentEnvironment() async { + final currentPreset = EnvironmentConfigService.instance.getCurrentPreset(); + setState(() { + _selectedPreset = currentPreset; + _isLoading = false; + }); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.scaffoldBgColor, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(24.r), + topRight: Radius.circular(24.r), + ), + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + margin: EdgeInsets.only(top: 12.h, bottom: 8.h), + width: 40.w, + height: 4.h, + decoration: BoxDecoration( + color: AppColors.greyInfoTextColor.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2.r), + ), + ), + + // Title + Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 16.h), + child: Row( + children: [ + Expanded( + child: Text( + LocaleKeys.environmentConfig.tr(context: context), + style: TextStyle(fontSize: 20.f, fontWeight: FontWeight.bold), + ), + ), + IconButton( + icon: Icon(Icons.close, color: AppColors.blackBgColor), + onPressed: () => Navigator.pop(context, false), + ), + ], + ), + ), + + Divider(height: 1, color: AppColors.greyInfoTextColor.withValues(alpha: 0.2)), + + if (_isLoading) + Padding( + padding: EdgeInsets.all(40.h), + child: const CircularProgressIndicator(), + ) + else + SingleChildScrollView( + padding: EdgeInsets.all(24.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Current Environment Info + Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: AppColors.infoColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12.r), + border: Border.all( + color: AppColors.infoColor.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Icon(Icons.info_outline, color: AppColors.infoColor, size: 24.w), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.currentEnvironment.tr(context: context), + style: TextStyle(fontSize: 14.f, fontWeight: FontWeight.bold, color: AppColors.infoColor), + ), + SizedBox(height: 4.h), + Text( + _selectedPreset?.label ?? LocaleKeys.production.tr(context: context), + style: TextStyle(fontSize: 12.f, color: AppColors.blackBgColor), + ), + ], + ), + ), + ], + ), + ), + + SizedBox(height: 24.h), + + Text(LocaleKeys.selectEnvironment.tr(context: context), style: TextStyle(fontSize: 16.f, fontWeight: FontWeight.bold)), + SizedBox(height: 12.h), + + // Environment Chips + Wrap( + spacing: 8.w, + runSpacing: 8.h, + children: kEnvironmentPresets.map((preset) { + final isSelected = _selectedPreset?.label == preset.label; + return InkWell( + onTap: () { + setState(() { + _selectedPreset = preset; + }); + }, + borderRadius: BorderRadius.circular(20.r), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), + decoration: BoxDecoration( + color: isSelected ? AppColors.primaryRedColor : AppColors.whiteColor, + borderRadius: BorderRadius.circular(20.r), + border: Border.all( + color: isSelected ? AppColors.primaryRedColor : AppColors.greyInfoTextColor.withValues(alpha: 0.3), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isSelected) + Icon( + Icons.check_circle, + size: 18.w, + color: Colors.white, + ), + if (isSelected) SizedBox(width: 6.w), + Text( + preset.label, + style: TextStyle( + fontSize: 14.f, + color: isSelected ? Colors.white : AppColors.blackBgColor, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ], + ), + ), + ); + }).toList(), + ), + + SizedBox(height: 24.h), + + // Environment Details + if (_selectedPreset != null) ...[ + Text(LocaleKeys.environmentDetails.tr(context: context), style: TextStyle(fontSize: 16.f, fontWeight: FontWeight.bold)), + SizedBox(height: 12.h), + Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: AppColors.whiteColor, + borderRadius: BorderRadius.circular(12.r), + border: Border.all( + color: AppColors.greyInfoTextColor.withValues(alpha: 0.2), + ), + ), + child: Column( + children: [ + _buildDetailRow(LocaleKeys.baseUrl.tr(context: context), _selectedPreset!.baseUrl), + Divider(height: 20.h), + _buildDetailRow(LocaleKeys.rcUrl.tr(context: context), _selectedPreset!.rcBaseUrl), + Divider(height: 20.h), + _buildDetailRow(LocaleKeys.pharmacyApi.tr(context: context), _selectedPreset!.hmgPharmacyApiBaseUrl), + Divider(height: 20.h), + _buildDetailRow( + LocaleKeys.payfortMode.tr(context: context), + _selectedPreset!.payFortEnvironment == FortEnvironment.production + ? LocaleKeys.production.tr(context: context) + : LocaleKeys.sandbox.tr(context: context)), + Divider(height: 20.h), + _buildDetailRow(LocaleKeys.applePayMerchant.tr(context: context), _selectedPreset!.applePayMerchantId), + ], + ), + ), + ], + + SizedBox(height: 24.h), + + // Warning + Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: AppColors.warningColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12.r), + border: Border.all( + color: AppColors.warningColor.withValues(alpha: 0.3), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.warning_amber_rounded, color: AppColors.warningColor, size: 24.w), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(LocaleKeys.important.tr(context: context), + style: TextStyle(fontSize: 14.f, fontWeight: FontWeight.bold, color: AppColors.warningColor)), + SizedBox(height: 4.h), + Text( + LocaleKeys.envChangeWarning.tr(context: context), + style: TextStyle(fontSize: 12.f, color: AppColors.blackBgColor), + ), + ], + ), + ), + ], + ), + ), + + SizedBox(height: 24.h), + + // Action Buttons + Row( + children: [ + Expanded( + child: CustomButton( + height: 50.h, + text: LocaleKeys.resetToDefault.tr(context: context), + icon: AppAssets.cancel, + iconColor: AppColors.errorColor, + iconSize: 20.w, + textColor: AppColors.errorColor, + borderColor: AppColors.errorColor.withValues(alpha: 0.3), + backgroundColor: AppColors.errorColor.withValues(alpha: 0.1), + onPressed: () => _showResetConfirmation(), + ), + ), + SizedBox(width: 12.w), + Expanded( + child: CustomButton( + height: 50.h, + text: LocaleKeys.saveAndRestart.tr(context: context), + icon: AppAssets.confirm, + iconSize: 20.w, + onPressed: _selectedPreset != null ? _saveAndRestart : null, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildDetailRow(String label, String value) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100.w, + child: Text(label, style: TextStyle(fontSize: 12.f, color: AppColors.greyInfoTextColor)), + ), + Expanded( + child: Text(value, style: TextStyle(fontSize: 12.f, fontWeight: FontWeight.bold), maxLines: 3), + ), + ], + ); + } + + void _showResetConfirmation() { + showCommonBottomSheetWithoutHeight( + context, + title: LocaleKeys.resetEnvironment.tr(context: context), + child: Utils.getWarningWidget( + loadingText: LocaleKeys.resetEnvConfirm.tr(context: context), + isShowActionButtons: true, + onCancelTap: () => Navigator.pop(context), + onConfirmTap: () async { + Navigator.pop(context); // Close confirmation dialog + await EnvironmentConfigService.instance.resetToDefault(); + Navigator.pop(context, true); // Close config sheet and trigger restart + Utils.showToast(LocaleKeys.envResetSuccess.tr(context: context)); + }, + ), + callBackFunc: () {}, + isFullScreen: false, + ); + } + + Future _saveAndRestart() async { + if (_selectedPreset == null) return; + + try { + await EnvironmentConfigService.instance.applyPreset(_selectedPreset!); + Navigator.pop(context, true); // Return true to indicate app should restart + Utils.showToast(LocaleKeys.envUpdateSuccess.tr(context: context)); + } catch (e) { + Utils.showToast('${LocaleKeys.envUpdateFailed.tr(context: context)}: $e'); + } + } +} diff --git a/lib/presentation/profile_settings/profile_settings.dart b/lib/presentation/profile_settings/profile_settings.dart index 2e6b60b6..17f485bd 100644 --- a/lib/presentation/profile_settings/profile_settings.dart +++ b/lib/presentation/profile_settings/profile_settings.dart @@ -22,11 +22,14 @@ import 'package:hmg_patient_app_new/features/medical_file/models/family_file_res 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/contact_us/feedback_page.dart'; +import 'package:hmg_patient_app_new/presentation/dev_tools/api_log_screen.dart'; +import 'package:hmg_patient_app_new/presentation/dev_tools/environment_config_sheet.dart'; import 'package:hmg_patient_app_new/presentation/insurance/widgets/insurance_update_details_card.dart'; import 'package:hmg_patient_app_new/presentation/profile_settings/accessibility_page.dart'; import 'package:hmg_patient_app_new/presentation/profile_settings/widgets/profile_picture_widget.dart'; import 'package:hmg_patient_app_new/presentation/profile_settings/widgets/update_email_widget.dart'; import 'package:hmg_patient_app_new/presentation/profile_settings/widgets/update_emergency_contact_widget.dart'; +import 'package:hmg_patient_app_new/routes/app_routes.dart'; import 'package:hmg_patient_app_new/theme/colors.dart'; import 'package:hmg_patient_app_new/widgets/app_language_change.dart'; import 'package:hmg_patient_app_new/widgets/appbar/collapsing_list_view.dart'; @@ -310,6 +313,26 @@ class ProfileSettingsState extends State { decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.r, hasShadow: false), child: Column( children: [ + // DevTools Menu Items (only shown if enabled) + if (ApiConsts.enableDevTools) ...[ + actionItem(AppAssets.document, LocaleKeys.apiLogs.tr(context: context), () { + Navigator.of(context).push(CustomPageRoute(page: const ApiLogScreen())); + }), + 1.divider, + actionItem(AppAssets.ic_filters, LocaleKeys.environmentConfig.tr(context: context), () async { + final shouldRestart = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => const EnvironmentConfigSheet(), + ); + if (shouldRestart == true && mounted) { + // Restart app by navigating to splash screen + Navigator.pushNamedAndRemoveUntil(context, AppRoutes.initialRoute, (route) => false); + } + }), + 1.divider, + ], actionItem(AppAssets.call_fill, LocaleKeys.contactUs.tr(context: context), () { launchUrl(Uri.parse("tel://" + "+966 92 006 6666")); }, trailingLabel: "92 006 6666", isEnglishOnly: true), diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index c676819f..52364393 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -1,3 +1,4 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hmg_patient_app_new/theme/colors.dart'; @@ -15,14 +16,12 @@ class AppTheme { visualDensity: VisualDensity.adaptivePlatformDensity, brightness: Brightness.light, pageTransitionsTheme: const PageTransitionsTheme( - builders: { - TargetPlatform.android: ZoomPageTransitionsBuilder(), - TargetPlatform.iOS: CupertinoPageTransitionsBuilder() - }, + builders: {TargetPlatform.android: ZoomPageTransitionsBuilder(), TargetPlatform.iOS: CupertinoPageTransitionsBuilder()}, ), hintColor: Colors.grey[400], disabledColor: Colors.grey[300], - textSelectionTheme: const TextSelectionThemeData(cursorColor: Colors.grey, selectionColor: Color.fromRGBO(80, 100, 253, 0.5), selectionHandleColor: Colors.grey), + textSelectionTheme: const TextSelectionThemeData( + cursorColor: Colors.grey, selectionColor: Color.fromRGBO(80, 100, 253, 0.5), selectionHandleColor: Colors.grey), canvasColor: Colors.white, highlightColor: Colors.grey[100]!.withOpacity(0.4), splashColor: Colors.transparent, @@ -51,20 +50,17 @@ class AppTheme { visualDensity: VisualDensity.adaptivePlatformDensity, brightness: Brightness.dark, pageTransitionsTheme: const PageTransitionsTheme( - builders: { - TargetPlatform.android: ZoomPageTransitionsBuilder(), - TargetPlatform.iOS: CupertinoPageTransitionsBuilder() - }, + builders: {TargetPlatform.android: ZoomPageTransitionsBuilder(), TargetPlatform.iOS: CupertinoPageTransitionsBuilder()}, ), hintColor: Colors.grey[600], disabledColor: Colors.grey[700], - textSelectionTheme: const TextSelectionThemeData(cursorColor: Colors.grey, selectionColor: Color.fromRGBO(80, 100, 253, 0.5), selectionHandleColor: Colors.grey), + textSelectionTheme: const TextSelectionThemeData( + cursorColor: Colors.grey, selectionColor: Color.fromRGBO(80, 100, 253, 0.5), selectionHandleColor: Colors.grey), canvasColor: AppColors.dark.scaffoldBgColor, scaffoldBackgroundColor: AppColors.dark.scaffoldBgColor, highlightColor: Colors.grey[800]!.withOpacity(0.4), splashColor: Colors.transparent, - bottomSheetTheme: BottomSheetThemeData( - backgroundColor: Colors.black.withOpacity(0)), + bottomSheetTheme: BottomSheetThemeData(backgroundColor: Colors.black.withOpacity(0)), floatingActionButtonTheme: const FloatingActionButtonThemeData(highlightElevation: 2, disabledElevation: 0, elevation: 2), appBarTheme: AppBarTheme( color: AppColors.dark.scaffoldBgColor, diff --git a/pubspec.yaml b/pubspec.yaml index bdce8130..f18708f0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,8 +71,8 @@ dependencies: firebase_analytics: ^11.5.1 firebase_crashlytics: ^4.3.8 jiffy: ^6.4.3 -# hijri_gregorian_calendar: -# path: /Users/aamir/StudioProjects/hijri_gregorian_calendar + # hijri_gregorian_calendar: + # path: /Users/aamir/StudioProjects/hijri_gregorian_calendar sms_otp_auto_verify: ^2.2.0 web: any flutter_staggered_animations: ^1.1.1 @@ -89,7 +89,7 @@ dependencies: path: package/flutter_swiper_view flutter_callkit_incoming: 3.0.0 in_app_update: ^4.2.5 -# device_preview: ^1.3.1 + # device_preview: ^1.3.1 image_picker_android: ^0.8.13+16 image_picker_platform_interface: ^2.10.0