diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 16ac97f2..7d30d437 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -26,7 +26,7 @@ var userNotificationCenterDelegate:UNUserNotificationCenterDelegate? = nil func initializePlatformChannels(){ - if let mainViewController = window.rootViewController as? MainFlutterVC{ // platform initialization suppose to be in foreground + if let mainViewController = window?.rootViewController as? MainFlutterVC{ // platform initialization suppose to be in foreground flutterViewController = mainViewController HMGPlatformBridge.initialize(flutterViewController: flutterViewController) OpenTokPlatformBridge.initialize(flutterViewController: flutterViewController, registrar: self.registrar(forPlugin: "open-tok")) diff --git a/lib/config/config.dart b/lib/config/config.dart index a920351b..dd1bdcda 100644 --- a/lib/config/config.dart +++ b/lib/config/config.dart @@ -5,6 +5,14 @@ import 'package:hmg_patient_app/models/Request.dart'; import 'package:hmg_patient_app/uitl/app_shared_preferences.dart'; var MAX_SMALL_SCREEN = 660; + +/// ─── Dev Tools ─────────────────────────────────────────────────────────────── +/// Set to [true] → API logs are saved, drawer shows "API Logs" & "Environment +/// Config", environment switching is active. +/// Set to [false] → All of the above is completely disabled with zero overhead. +const bool ENABLE_DEV_TOOLS = false; +// ───────────────────────────────────────────────────────────────────────────── + final OPENTOK_API_KEY = '46209962'; // final OPENTOK_API_KEY = '47464241'; @@ -357,7 +365,7 @@ var CAN_PAY_FOR_FOR_WALKIN_APPOINTMENT = 'Services/Doctors.svc/REST/CanPayForWal var CHANNEL = 3; var GENERAL_ID = 'Cs2020@2016\$2958'; var IP_ADDRESS = '10.20.10.20'; -var VERSION_ID = 18.7; +var VERSION_ID = 21.9; var SETUP_ID = '91877'; var LANGUAGE = 2; // var PATIENT_OUT_SA = 0; @@ -580,7 +588,8 @@ var GET_CUSTOMER_INFO = "VerifyCustomer"; //Pharmacy -var GET_PHARMACY_CATEGORISE = 'categories?fields=id,name,namen,description,image,localized_names,display_order,parent_category_id,is_leaf&parent_id=0'; +var GET_PHARMACY_CATEGORISE = + 'categories?fields=id,name,namen,description,image,localized_names,display_order,parent_category_id,is_leaf&parent_id=0'; var GET_OFFERS_CATEGORISE = 'discountcategories'; var GET_OFFERS_PRODUCTS = 'offerproducts/'; var GET_CATEGORISE_PARENT = 'categories?fields=id,name,namen,description,image,localized_names,display_order,parent_category_id,is_leaf&parent_id='; diff --git a/lib/core/service/client/base_app_client.dart b/lib/core/service/client/base_app_client.dart index 732e4bc1..0396c0e3 100644 --- a/lib/core/service/client/base_app_client.dart +++ b/lib/core/service/client/base_app_client.dart @@ -8,6 +8,7 @@ import 'package:hmg_patient_app/config/shared_pref_kay.dart'; import 'package:hmg_patient_app/core/service/medical/vital_sign_service.dart'; import 'package:hmg_patient_app/core/service/packages_offers/PackagesOffersServices.dart'; import 'package:hmg_patient_app/core/viewModels/project_view_model.dart'; +import 'package:hmg_patient_app/dev_tools/api_logger_service.dart'; import 'package:hmg_patient_app/models/Appointments/toDoCountProviderModel.dart'; import 'package:hmg_patient_app/pages/appUpdatePage/app_update_page.dart'; import 'package:hmg_patient_app/services/authentication/auth_provider.dart'; @@ -80,18 +81,22 @@ class BaseAppClient { if (body.containsKey('LanguageID')) { if (body['LanguageID'] != null) { - //change this line because language issue happened on dental body['LanguageID'] = body['LanguageID'] == 'ar' ? 1 : body['LanguageID'] == 'en' ? 2 : body['LanguageID']; } else { - body['LanguageID'] = Provider.of(AppGlobal.context, listen: false).isArabic ? 1 : 2; + bool _isArabic = AppGlobal.context != null + ? Provider.of(AppGlobal.context, listen: false).isArabic + : false; + body['LanguageID'] = _isArabic ? 1 : 2; } } else { - // body['LanguageID'] = (languageID.toString().toLowerCase() == 'ar' ? 1 : 2); - body['LanguageID'] = Provider.of(AppGlobal.context, listen: false).isArabic ? 1 : 2; + bool _isArabic = AppGlobal.context != null + ? Provider.of(AppGlobal.context, listen: false).isArabic + : false; + body['LanguageID'] = _isArabic ? 1 : 2; } // } else { // // body['LanguageID'] = (languageID.toString().toLowerCase() == 'ar' ? 1 : 2); @@ -209,19 +214,34 @@ class BaseAppClient { // return; // if (await Utils.checkConnection(bypassConnectionCheck: bypassConnectionCheck)) { if (await Utils.checkConnection(bypassConnectionCheck: true)) { + final _postStart = DateTime.now(); final response = await http.post(Uri.parse(url.trim()), body: json.encode(body), headers: headers); + final _postDuration = DateTime.now().difference(_postStart); final int statusCode = response.statusCode; + + // ── Log every call regardless of success/failure ── + dynamic _loggedBody; + try { _loggedBody = json.decode(utf8.decode(response.bodyBytes)); } catch (_) { _loggedBody = response.body; } + if (ENABLE_DEV_TOOLS) ApiLoggerService.instance.addLog(ApiLogEntry( + id: '${_postStart.millisecondsSinceEpoch}', + timestamp: _postStart, + method: 'POST', + url: url, + endpoint: endPoint, + environment: ApiLogEntry.envFromUrl(url), + headers: headers, + requestBody: Map.from(body), + responseBody: _loggedBody, + statusCode: statusCode, + isSuccess: statusCode >= 200 && statusCode < 400, + duration: _postDuration, + )); + if (statusCode < 200 || statusCode >= 400 || json == null) { onFailure('Error While Fetching data', statusCode); logApiEndpointError(endPoint, 'Error While Fetching data', statusCode); } else { - // var decoded = utf8.decode(response.bodyBytes); - var parsed; - // if (url.contains('Services/NHIC.svc/REST/GetPatientInfo')) { - // parsed = json.decode(sampleNHICResponse); - // } else { - parsed = json.decode(utf8.decode(response.bodyBytes)); - // } + var parsed = _loggedBody; // print("Response: $parsed"); @@ -302,8 +322,20 @@ class BaseAppClient { } } catch (e) { print(e); - print(e); - + // Log failed call so it still appears in API Logs + if (ENABLE_DEV_TOOLS) ApiLoggerService.instance.addLog(ApiLogEntry( + id: '${DateTime.now().millisecondsSinceEpoch}', + timestamp: DateTime.now(), + method: 'POST', + url: url, + endpoint: endPoint, + environment: ApiLogEntry.envFromUrl(url), + headers: const {}, + requestBody: null, + responseBody: {'error': e.toString()}, + statusCode: 0, + isSuccess: false, + )); if (e.toString().contains("ClientException")) { onFailure('Something went wrong, plase try again', -1); _analytics.errorTracking.log("internet_connectivity", error: "no internet available"); @@ -400,9 +432,27 @@ class BaseAppClient { print("Headers : ${json.encode(headers)}"); if (await Utils.checkConnection()) { + final _ppStartTime = DateTime.now(); final response = await http.post(Uri.parse(url.trim()), body: json.encode(body), headers: headers); + final _ppDuration = DateTime.now().difference(_ppStartTime); final int statusCode = response.statusCode; - // print("statusCode :$statusCode"); + // Log the API call + dynamic _ppParsedForLog; + try { _ppParsedForLog = json.decode(utf8.decode(response.bodyBytes)); } catch (_) { _ppParsedForLog = response.body; } + if (ENABLE_DEV_TOOLS) ApiLoggerService.instance.addLog(ApiLogEntry( + id: '${DateTime.now().millisecondsSinceEpoch}', + timestamp: _ppStartTime, + method: 'POST', + url: url, + endpoint: endPoint, + environment: ApiLogEntry.envFromUrl(url), + headers: headers, + requestBody: body != null ? Map.from(body) : null, + responseBody: _ppParsedForLog, + statusCode: statusCode, + isSuccess: statusCode >= 200 && statusCode < 400, + duration: _ppDuration, + ));; if (statusCode < 200 || statusCode >= 400 || json == null) { onFailure!('Error While Fetching data', statusCode); logApiEndpointError(endPoint, 'Error While Fetching data', statusCode); @@ -513,15 +563,33 @@ class BaseAppClient { } debugPrint("URL : $url"); - // print("Body : ${json.encode(body)}"); if (await Utils.checkConnection()) { + final _getHeaders = {'Content-Type': 'application/json', 'Accept': 'application/json'}; + final _getStart = DateTime.now(); final response = await http.get( Uri.parse(url.trim()), - headers: {'Content-Type': 'application/json', 'Accept': 'application/json'}, + headers: _getHeaders, ); + final _getDuration = DateTime.now().difference(_getStart); final int statusCode = response.statusCode; - // print("statusCode :$statusCode"); + + dynamic _getParsedForLog; + try { _getParsedForLog = json.decode(utf8.decode(response.bodyBytes)); } catch (_) { _getParsedForLog = response.body; } + if (ENABLE_DEV_TOOLS) ApiLoggerService.instance.addLog(ApiLogEntry( + id: '${DateTime.now().millisecondsSinceEpoch}', + timestamp: _getStart, + method: 'GET', + url: url, + endpoint: endPoint, + environment: ApiLogEntry.envFromUrl(url), + headers: _getHeaders, + requestBody: queryParams, + responseBody: _getParsedForLog, + statusCode: statusCode, + isSuccess: statusCode >= 200 && statusCode < 400, + duration: _getDuration, + ));; if (statusCode < 200 || statusCode >= 400 || json == null) { onFailure!('Error While Fetching data', statusCode); @@ -557,20 +625,37 @@ class BaseAppClient { } print("URL : $url"); - var ss = json.encode(queryParams); if (await Utils.checkConnection()) { - final response = await http.get(Uri.parse(url.trim()), headers: { + final Map _gpHeaders = { 'Content-Type': 'text/html; charset=utf-8', 'Accept': 'application/json', 'Authorization': token ?? '', 'Mobilenumber': user != null ? Utils.getPhoneNumberWithoutZero(user['MobileNumber'].toString()) : "", 'Statictoken': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9', 'Username': user != null ? user['PatientID'].toString() : "", - // 'Host': "mdlaboratories.com", - }); + }; + final _gpStart = DateTime.now(); + final response = await http.get(Uri.parse(url.trim()), headers: _gpHeaders); + final _gpDuration = DateTime.now().difference(_gpStart); final int statusCode = response.statusCode; - // print("statusCode :$statusCode"); + + dynamic _gpParsedForLog; + try { _gpParsedForLog = json.decode(utf8.decode(response.bodyBytes)); } catch (_) { _gpParsedForLog = response.body; } + if (ENABLE_DEV_TOOLS) ApiLoggerService.instance.addLog(ApiLogEntry( + id: '${DateTime.now().millisecondsSinceEpoch}', + timestamp: _gpStart, + method: 'GET', + url: url, + endpoint: endPoint, + environment: ApiLogEntry.envFromUrl(url), + headers: _gpHeaders, + requestBody: queryParams, + responseBody: _gpParsedForLog, + statusCode: statusCode, + isSuccess: statusCode >= 200 && statusCode < 400, + duration: _gpDuration, + ));; if (statusCode < 200 || statusCode >= 400 || json == null) { if (statusCode == 401) { @@ -607,13 +692,32 @@ class BaseAppClient { if (await Utils.checkConnection()) { headers!.addAll({'Content-Type': 'application/json', 'Accept': 'application/json'}); + final _spStart = DateTime.now(); final response = await http.post( Uri.parse(url.trim()), body: json.encode(body), headers: headers, ); + final _spDuration = DateTime.now().difference(_spStart); final int statusCode = response.statusCode; - // print("statusCode :$statusCode"); + + dynamic _spLogBody; + try { _spLogBody = json.decode(response.body); } catch (_) { _spLogBody = response.body; } + if (ENABLE_DEV_TOOLS) ApiLoggerService.instance.addLog(ApiLogEntry( + id: '${_spStart.millisecondsSinceEpoch}', + timestamp: _spStart, + method: 'POST', + url: fullUrl, + endpoint: fullUrl, + environment: ApiLogEntry.envFromUrl(fullUrl), + headers: headers, + requestBody: body.map((k, v) => MapEntry(k.toString(), v)), + responseBody: _spLogBody, + statusCode: statusCode, + isSuccess: statusCode >= 200 && statusCode < 400, + duration: _spDuration, + )); + if (await handleUnauthorized(statusCode, forUrl: fullUrl)) simplePost(fullUrl, onFailure: onFailure, onSuccess: onSuccess, body: body, headers: headers); // print(response.body.toString()); @@ -644,13 +748,31 @@ class BaseAppClient { if (await Utils.checkConnection()) { headers.addAll({'Content-Type': 'application/json', 'Accept': 'application/json'}); + final _sgStart = DateTime.now(); final response = await http.get( Uri.parse(url.trim()), headers: headers, ); - + final _sgDuration = DateTime.now().difference(_sgStart); final int statusCode = response.statusCode; - // print("statusCode :$statusCode"); + + dynamic _sgLogBody; + try { _sgLogBody = json.decode(response.body); } catch (_) { _sgLogBody = response.body; } + if (ENABLE_DEV_TOOLS) ApiLoggerService.instance.addLog(ApiLogEntry( + id: '${_sgStart.millisecondsSinceEpoch}', + timestamp: _sgStart, + method: 'GET', + url: url, + endpoint: fullUrl, + environment: ApiLogEntry.envFromUrl(url), + headers: headers, + requestBody: queryParams, + responseBody: _sgLogBody, + statusCode: statusCode, + isSuccess: statusCode >= 200 && statusCode < 400, + duration: _sgDuration, + )); + if (await handleUnauthorized(statusCode, forUrl: fullUrl)) simpleGet(fullUrl, onFailure: onFailure, onSuccess: onSuccess, headers: headers, queryParams: queryParams); if (statusCode < 200 || statusCode >= 400 || json == null) { diff --git a/lib/dev_tools/api_log_screen.dart b/lib/dev_tools/api_log_screen.dart new file mode 100644 index 00000000..7282fb65 --- /dev/null +++ b/lib/dev_tools/api_log_screen.dart @@ -0,0 +1,383 @@ +// filepath: lib/pages/dev_tools/api_log_screen.dart +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hmg_patient_app/dev_tools/api_logger_service.dart'; +import 'package:hmg_patient_app/widgets/others/app_scaffold_widget.dart'; + +class ApiLogScreen extends StatefulWidget { + const ApiLogScreen({Key? key}) : super(key: key); + + @override + State createState() => _ApiLogScreenState(); +} + +class _ApiLogScreenState extends State { + final ApiLoggerService _logger = ApiLoggerService.instance; + String _searchQuery = ''; + + @override + Widget build(BuildContext context) { + return AppScaffold( + showNewAppBar: true, + showNewAppBarTitle: true, + isShowDecPage: false, + appBarTitle: 'API Logs', + appBarIcons: [ + IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.black), + tooltip: 'Clear all logs', + onPressed: () async { + final confirm = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Clear Logs'), + content: const Text('Are you sure you want to clear all API logs?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Clear', style: TextStyle(color: Colors.red))), + ], + ), + ); + if (confirm == true) _logger.clear(); + }, + ), + ], + body: AnimatedBuilder( + animation: _logger, + builder: (context, _) { + final logs = _searchQuery.isEmpty + ? _logger.logs + : _logger.logs + .where((l) => + l.endpoint.toLowerCase().contains(_searchQuery.toLowerCase()) || l.url.toLowerCase().contains(_searchQuery.toLowerCase())) + .toList(); + + return Column( + children: [ + // Search Bar + Container( + color: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + decoration: InputDecoration( + hintText: 'Search endpoint or URL…', + prefixIcon: const Icon(Icons.search, size: 20), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.grey.shade300)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.grey.shade300)), + filled: true, + fillColor: Colors.grey.shade50, + ), + onChanged: (v) => setState(() => _searchQuery = v), + ), + ), + + // Stats bar + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + color: Colors.grey.shade100, + child: Row( + children: [ + Text('${logs.length} calls', style: const TextStyle(fontSize: 12, color: Colors.grey)), + const Spacer(), + Text( + '${logs.where((l) => l.isSuccess).length} success ' + '${logs.where((l) => !l.isSuccess).length} failed', + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + ), + ), + + Expanded( + child: logs.isEmpty + ? const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.api_outlined, size: 60, color: Colors.grey), + SizedBox(height: 12), + Text('No API calls recorded yet.', style: TextStyle(color: Colors.grey)), + ], + ), + ) + : ListView.separated( + padding: const EdgeInsets.only(bottom: 80), + itemCount: logs.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final entry = logs[index]; + return _ApiLogTile(entry: entry); + }, + ), + ), + ], + ); + }, + ), + ); + } +} + +// ─── Individual expandable log tile ─────────────────────────────────────────── + +class _ApiLogTile extends StatefulWidget { + final ApiLogEntry entry; + + const _ApiLogTile({required this.entry}); + + @override + State<_ApiLogTile> createState() => _ApiLogTileState(); +} + +class _ApiLogTileState extends State<_ApiLogTile> { + bool _expanded = false; + int _tabIndex = 0; // 0=Request, 1=Response, 2=Headers + + @override + Widget build(BuildContext context) { + final e = widget.entry; + final statusColor = e.isSuccess ? const Color(0xFF27AE60) : const Color(0xFFE74C3C); + final methodColor = e.method == 'GET' ? const Color(0xFF2980B9) : const Color(0xFF8E44AD); + + return Container( + color: Colors.white, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Collapsed header ─────────────────────────────────────── + InkWell( + onTap: () => setState(() => _expanded = !_expanded), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // Method badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: methodColor.withAlpha(25), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: methodColor.withAlpha(100))), + child: Text(e.method, style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: methodColor)), + ), + const SizedBox(width: 8), + // Status badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration(color: statusColor.withAlpha(25), borderRadius: BorderRadius.circular(4)), + child: Text(e.statusCode == 0 ? 'ERR' : e.statusCode.toString(), + style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: statusColor)), + ), + const SizedBox(width: 6), + // Environment badge + if (e.environment.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF6C5CE7).withAlpha(20), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: const Color(0xFF6C5CE7).withAlpha(80))), + child: Text(e.environment, style: const TextStyle(fontSize: 9, fontWeight: FontWeight.w700, color: Color(0xFF6C5CE7))), + ), + const SizedBox(width: 6), + // Endpoint + Expanded( + child: Text( + e.endpoint, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: Color(0xFF2B353E)), + overflow: TextOverflow.ellipsis, + ), + ), + // Time + Text( + '${e.timestamp.hour.toString().padLeft(2, '0')}:${e.timestamp.minute.toString().padLeft(2, '0')}:${e.timestamp.second.toString().padLeft(2, '0')}', + style: const TextStyle(fontSize: 10, color: Colors.grey), + ), + const SizedBox(width: 4), + Icon(_expanded ? Icons.expand_less : Icons.expand_more, size: 18, color: Colors.grey), + ], + ), + ), + ), + + // ── Expanded detail ──────────────────────────────────────── + if (_expanded) ...[ + // Full URL + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + color: Colors.grey.shade50, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.link, size: 14, color: Colors.grey), + const SizedBox(width: 4), + Expanded( + child: Text(e.url, style: const TextStyle(fontSize: 11, color: Colors.grey), overflow: TextOverflow.ellipsis, maxLines: 2), + ), + IconButton( + constraints: const BoxConstraints(), + padding: EdgeInsets.zero, + icon: const Icon(Icons.copy, size: 14, color: Colors.grey), + onPressed: () { + Clipboard.setData(ClipboardData(text: e.url)); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('URL copied'), duration: Duration(seconds: 1))); + }, + ), + ], + ), + if (e.duration != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text('Duration: ${e.duration!.inMilliseconds}ms', style: const TextStyle(fontSize: 11, color: Colors.grey)), + ), + ], + ), + ), + + // Tab selector + Container( + color: Colors.white, + child: Row( + children: [ + _TabChip(label: 'Request', selected: _tabIndex == 0, onTap: () => setState(() => _tabIndex = 0)), + _TabChip(label: 'Response', selected: _tabIndex == 1, onTap: () => setState(() => _tabIndex = 1)), + _TabChip(label: 'Headers', selected: _tabIndex == 2, onTap: () => setState(() => _tabIndex = 2)), + ], + ), + ), + + // Content area + Container( + width: double.infinity, + constraints: const BoxConstraints(maxHeight: 280), + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFF1E1E1E), + borderRadius: BorderRadius.circular(8), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.all(12), + child: SelectableText( + _tabIndex == 0 + ? e.prettyRequestBody + : _tabIndex == 1 + ? e.prettyResponseBody + : e.headers.entries.map((h) => '${h.key}: ${h.value}').join('\n'), + style: const TextStyle(fontFamily: 'monospace', fontSize: 11, color: Color(0xFFD4D4D4)), + ), + ), + ), + + // Export buttons + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: Row( + children: [ + _ExportButton( + label: 'Copy Formatted', + icon: Icons.text_snippet_outlined, + onTap: () { + Clipboard.setData(ClipboardData(text: e.toFormattedText())); + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Copied as formatted text'), duration: Duration(seconds: 1))); + }, + ), + const SizedBox(width: 8), + _ExportButton( + label: 'Copy as cURL', + icon: Icons.terminal, + onTap: () { + Clipboard.setData(ClipboardData(text: e.toCurl())); + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Copied as cURL command'), duration: Duration(seconds: 1))); + }, + ), + const SizedBox(width: 8), + _ExportButton( + label: 'Copy JSON', + icon: Icons.data_object, + onTap: () { + final payload = { + 'method': e.method, + 'url': e.url, + 'headers': e.headers, + 'request': e.requestBody, + 'response': e.responseBody, + 'statusCode': e.statusCode, + 'timestamp': e.timestamp.toIso8601String(), + }; + Clipboard.setData(ClipboardData(text: const JsonEncoder.withIndent(' ').convert(payload))); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Copied as JSON'), duration: Duration(seconds: 1))); + }, + ), + ], + ), + ), + ], + ], + ), + ); + } +} + +class _TabChip extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback onTap; + + const _TabChip({required this.label, required this.selected, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(left: 12, bottom: 6, top: 6), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: selected ? const Color(0xFF2B353E) : Colors.grey.shade200, + borderRadius: BorderRadius.circular(20), + ), + child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: selected ? Colors.white : Colors.black54)), + ), + ); + } +} + +class _ExportButton extends StatelessWidget { + final String label; + final IconData icon; + final VoidCallback onTap; + + const _ExportButton({required this.label, required this.icon, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: const Color(0xFF2B353E)), + const SizedBox(height: 2), + Text(label, style: const TextStyle(fontSize: 9, color: Color(0xFF2B353E)), textAlign: TextAlign.center), + ], + ), + ), + ), + ); + } +} diff --git a/lib/dev_tools/api_logger_service.dart b/lib/dev_tools/api_logger_service.dart new file mode 100644 index 00000000..f4b4fbc3 --- /dev/null +++ b/lib/dev_tools/api_logger_service.dart @@ -0,0 +1,133 @@ +// filepath: lib/core/service/client/api_logger_service.dart +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +/// 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('orash')) return 'Orash'; + 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('hmgwebservices.com')) return 'Production'; + // External / other + 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 (headers + request + response sectioned). + 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(); + } +} + +/// 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 last 500 entries to avoid huge memory usage + if (_logs.length > 500) _logs.removeAt(0); + notifyListeners(); + } + + void clear() { + _logs.clear(); + notifyListeners(); + } +} + diff --git a/lib/dev_tools/environment_config.dart b/lib/dev_tools/environment_config.dart new file mode 100644 index 00000000..a224a66b --- /dev/null +++ b/lib/dev_tools/environment_config.dart @@ -0,0 +1,168 @@ +// filepath: lib/config/environment_config.dart +import 'package:hmg_patient_app/config/config.dart'; +import 'package:hmg_patient_app/uitl/app_shared_preferences.dart'; + +// ─── Keys for SharedPreferences ─────────────────────────────────────────────── +const ENV_BASE_URL_KEY = 'env_base_url'; +const ENV_RC_BASE_URL_KEY = 'env_rc_base_url'; +const ENV_PHARMACY_BASE_URL_KEY = 'env_pharmacy_base_url'; +const ENV_EXA_CART_BASE_URL_KEY = 'env_exa_cart_base_url'; +const ENV_PAYFORT_KEY = 'env_payfort'; + +// ─── Preset environments ─────────────────────────────────────────────────────── +class AppEnvironmentPreset { + final String label; + final String baseUrl; + final String rcBaseUrl; + final String pharmacyBaseUrl; + final String exaCartBaseUrl; + final String payfortMode; // 'production' | 'sandbox' + + const AppEnvironmentPreset({ + required this.label, + required this.baseUrl, + required this.rcBaseUrl, + required this.pharmacyBaseUrl, + required this.exaCartBaseUrl, + required this.payfortMode, + }); +} + +const List kEnvironmentPresets = [ + AppEnvironmentPreset( + label: 'Production', + baseUrl: 'https://hmgwebservices.com/', + rcBaseUrl: 'https://rc.hmg.com/', + pharmacyBaseUrl: 'https://mdlaboratories.com/exacartapi/api/', + exaCartBaseUrl: 'https://mdlaboratories.com/offersdiscounts', + payfortMode: 'production', + ), + AppEnvironmentPreset( + label: 'UAT', + baseUrl: 'https://uat.hmgwebservices.com/', + rcBaseUrl: 'https://rc.hmg.com/test/', + pharmacyBaseUrl: 'https://uat.hmgwebservices.com/epharmacy/api/', + exaCartBaseUrl: 'https://mdlaboratories.com/offersdiscounts', + payfortMode: 'sandbox', + ), + AppEnvironmentPreset( + label: 'VidaPlus UAT', + baseUrl: 'https://vidauat.cloudsolutions.com.sa/', + rcBaseUrl: 'https://rc.hmg.com/', + pharmacyBaseUrl: 'https://mdlaboratories.com/exacartapitest/api/', + exaCartBaseUrl: 'https://mdlaboratories.com/offersdiscounts', + payfortMode: 'sandbox', + ), + AppEnvironmentPreset( + label: 'VidaMerge UAT', + baseUrl: 'https://vidamergeuat.cloudsolutions.com.sa/', + rcBaseUrl: 'https://rc.hmg.com/', + pharmacyBaseUrl: 'https://mdlaboratories.com/exacartapitest/api/', + exaCartBaseUrl: 'https://mdlaboratories.com/offersdiscounts', + payfortMode: 'sandbox', + ), + AppEnvironmentPreset( + label: 'Orash UAT', + baseUrl: 'https://orash.cloudsolutions.com.sa/', + rcBaseUrl: 'https://rc.hmg.com/', + pharmacyBaseUrl: 'https://mdlaboratories.com/exacartapi/api/', + exaCartBaseUrl: 'https://mdlaboratories.com/offersdiscounts', + payfortMode: 'sandbox', + ), + AppEnvironmentPreset( + label: 'MS HMG RC', + baseUrl: 'https://hmgwebservices.com/', + rcBaseUrl: 'https://ms.hmg.com/rc/', + pharmacyBaseUrl: 'https://mdlaboratories.com/exacartapi/api/', + exaCartBaseUrl: 'https://mdlaboratories.com/offersdiscounts', + payfortMode: 'production', + ), +]; + +// ─── Service ─────────────────────────────────────────────────────────────────── + +class EnvironmentConfigService { + static final EnvironmentConfigService instance = EnvironmentConfigService._internal(); + EnvironmentConfigService._internal(); + + final AppSharedPreferences _prefs = AppSharedPreferences(); + + /// Apply stored environment or fall back to production defaults. + Future applyStoredEnvironment() async { + final storedBase = await _prefs.getString(ENV_BASE_URL_KEY); + final storedRc = await _prefs.getString(ENV_RC_BASE_URL_KEY); + final storedPharmacy = await _prefs.getString(ENV_PHARMACY_BASE_URL_KEY); + final storedExaCart = await _prefs.getString(ENV_EXA_CART_BASE_URL_KEY); + + if (storedBase != null && storedBase.isNotEmpty) BASE_URL = storedBase; + if (storedRc != null && storedRc.isNotEmpty) RC_BASE_URL = storedRc; + if (storedPharmacy != null && storedPharmacy.isNotEmpty) { + BASE_PHARMACY_URL = storedPharmacy; + PHARMACY_BASE_URL = storedPharmacy; + } + if (storedExaCart != null && storedExaCart.isNotEmpty) EXA_CART_API_BASE_URL = storedExaCart; + } + + /// Save an environment preset and apply it immediately to the global config vars. + Future applyPreset(AppEnvironmentPreset preset) async { + await _prefs.setString(ENV_BASE_URL_KEY, preset.baseUrl); + await _prefs.setString(ENV_RC_BASE_URL_KEY, preset.rcBaseUrl); + await _prefs.setString(ENV_PHARMACY_BASE_URL_KEY, preset.pharmacyBaseUrl); + await _prefs.setString(ENV_EXA_CART_BASE_URL_KEY, preset.exaCartBaseUrl); + await _prefs.setString(ENV_PAYFORT_KEY, preset.payfortMode); + + BASE_URL = preset.baseUrl; + RC_BASE_URL = preset.rcBaseUrl; + BASE_PHARMACY_URL = preset.pharmacyBaseUrl; + PHARMACY_BASE_URL = preset.pharmacyBaseUrl; + EXA_CART_API_BASE_URL = preset.exaCartBaseUrl; + } + + /// Clears all saved environment prefs so the app falls back to the + /// hardcoded production URLs defined in config.dart. + Future resetToDefault() async { + await _prefs.remove(ENV_BASE_URL_KEY); + await _prefs.remove(ENV_RC_BASE_URL_KEY); + await _prefs.remove(ENV_PHARMACY_BASE_URL_KEY); + await _prefs.remove(ENV_EXA_CART_BASE_URL_KEY); + await _prefs.remove(ENV_PAYFORT_KEY); + + // Reset runtime vars back to the values compiled in config.dart + BASE_URL = 'https://hmgwebservices.com/'; + RC_BASE_URL = 'https://rc.hmg.com/'; + BASE_PHARMACY_URL = 'https://mdlaboratories.com/exacartapi/api/'; + PHARMACY_BASE_URL = 'https://mdlaboratories.com/exacartapi/api/'; + EXA_CART_API_BASE_URL = 'https://mdlaboratories.com/offersdiscounts'; + } + + Future getCurrentBaseUrl() async { + return (await _prefs.getString(ENV_BASE_URL_KEY)) ?? BASE_URL; + } + + Future getCurrentRcUrl() async { + return (await _prefs.getString(ENV_RC_BASE_URL_KEY)) ?? RC_BASE_URL; + } + + Future getCurrentPharmacyUrl() async { + return (await _prefs.getString(ENV_PHARMACY_BASE_URL_KEY)) ?? PHARMACY_BASE_URL; + } + + Future getCurrentExaCartUrl() async { + return (await _prefs.getString(ENV_EXA_CART_BASE_URL_KEY)) ?? EXA_CART_API_BASE_URL; + } + + Future getCurrentPayfortMode() async { + return (await _prefs.getString(ENV_PAYFORT_KEY)) ?? 'production'; + } + + /// Find preset whose baseUrl matches the currently saved one. + Future getCurrentPreset() async { + final storedBase = await getCurrentBaseUrl(); + try { + return kEnvironmentPresets.firstWhere((p) => p.baseUrl == storedBase); + } catch (_) { + return null; + } + } +} + diff --git a/lib/dev_tools/environment_config_sheet.dart b/lib/dev_tools/environment_config_sheet.dart new file mode 100644 index 00000000..3bf45303 --- /dev/null +++ b/lib/dev_tools/environment_config_sheet.dart @@ -0,0 +1,350 @@ +import 'package:flutter/material.dart'; +import 'package:hmg_patient_app/dev_tools/environment_config.dart'; + +/// Public class — can be used directly with showModalBottomSheet. +class EnvironmentConfigSheet extends StatefulWidget { + const EnvironmentConfigSheet({Key? key}) : super(key: key); + + @override + State createState() => _EnvironmentConfigSheetState(); +} + +/// Convenience function — opens the sheet and returns true if app restart needed. +Future showEnvironmentConfigSheet(BuildContext context) async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => const EnvironmentConfigSheet(), + ); + return result ?? false; +} + +class _EnvironmentConfigSheetState extends State { + final _envService = EnvironmentConfigService.instance; + + AppEnvironmentPreset? _selectedPreset; + bool _isSaving = false; + + @override + void initState() { + super.initState(); + _loadCurrent(); + } + + Future _loadCurrent() async { + final preset = await _envService.getCurrentPreset(); + if (!mounted) return; + setState(() { + // If no preset is stored, fall back to production (first preset) + _selectedPreset = preset ?? kEnvironmentPresets.first; + }); + } + + void _selectPreset(AppEnvironmentPreset p) { + setState(() => _selectedPreset = p); + } + + Future _save() async { + if (_selectedPreset == null) return; + setState(() => _isSaving = true); + try { + await _envService.applyPreset(_selectedPreset!); + if (mounted) Navigator.of(context).pop(true); + } catch (e) { + setState(() => _isSaving = false); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Failed to save: $e'))); + } + } + + Future _resetToDefault() async { + setState(() => _isSaving = true); + try { + await _envService.resetToDefault(); + if (mounted) Navigator.of(context).pop(true); + } catch (e) { + setState(() => _isSaving = false); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Failed to reset: $e'))); + } + } + + @override + Widget build(BuildContext context) { + final mq = MediaQuery.of(context); + final p = _selectedPreset; + return Container( + height: mq.size.height * 0.85, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + // Handle bar + Container( + margin: const EdgeInsets.only(top: 12, bottom: 4), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2)), + ), + // Title row + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 4), + child: Row( + children: [ + const Icon(Icons.tune, color: Color(0xFF2B353E)), + const SizedBox(width: 10), + const Expanded( + child: Text('Environment Configuration', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Color(0xFF2B353E))), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(false)), + ], + ), + ), + const Divider(height: 1), + + // Scrollable body + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Preset chips ────────────────────────────────────── + const _SectionLabel('Select Environment'), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: kEnvironmentPresets + .map((preset) => _Chip( + label: preset.label, + selected: _selectedPreset?.label == preset.label, + onTap: () => _selectPreset(preset), + )) + .toList(), + ), + + const SizedBox(height: 24), + + // ── URL preview (read-only) ──────────────────────────── + if (p != null) ...[ + const _SectionLabel('URLs for selected environment'), + const SizedBox(height: 10), + _UrlDisplay(label: 'Main Base URL', value: p.baseUrl), + const SizedBox(height: 8), + _UrlDisplay(label: 'RC Base URL', value: p.rcBaseUrl), + const SizedBox(height: 8), + _UrlDisplay(label: 'Pharmacy Base URL', value: p.pharmacyBaseUrl), + const SizedBox(height: 8), + _UrlDisplay(label: 'ExaCart (Offers) URL', value: p.exaCartBaseUrl), + const SizedBox(height: 8), + _UrlDisplay(label: 'PayFort Mode', value: p.payfortMode), + const SizedBox(height: 24), + ], + + // ── Reset to Default ────────────────────────────────── + InkWell( + onTap: _isSaving ? null : _resetToDefault, + borderRadius: BorderRadius.circular(10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 14), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE74C3C)), + borderRadius: BorderRadius.circular(10), + color: const Color(0xFFFFF5F5)), + child: Row( + children: const [ + Icon(Icons.restore, + color: Color(0xFFE74C3C), size: 20), + SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Reset to Default', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Color(0xFFE74C3C))), + SizedBox(height: 2), + Text( + 'Clears saved config and uses the URLs defined in code (Production)', + style: TextStyle( + fontSize: 11, + color: Color(0xFFE74C3C))), + ], + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 20), + + // ── Warning ─────────────────────────────────────────── + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF3CD), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFFFD700))), + child: const Row(children: [ + Icon(Icons.warning_amber_rounded, + color: Color(0xFFE6A817), size: 18), + SizedBox(width: 8), + Expanded( + child: Text( + 'After saving, the app will restart to apply the new environment.', + style: TextStyle( + fontSize: 12, color: Color(0xFF856404)), + ), + ), + ]), + ), + const SizedBox(height: 80), + ], + ), + ), + ), + + // ── Save button ─────────────────────────────────────────────── + Container( + padding: + EdgeInsets.fromLTRB(20, 12, 20, 12 + mq.padding.bottom), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(15), + blurRadius: 8, + offset: const Offset(0, -2)) + ], + ), + child: SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2B353E), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + onPressed: _isSaving ? null : _save, + child: _isSaving + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + color: Colors.white, strokeWidth: 2)) + : const Text('Save & Restart App', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.white)), + ), + ), + ), + ], + ), + ); + } +} + +// ─── Small private helpers ──────────────────────────────────────────────────── + +class _SectionLabel extends StatelessWidget { + final String text; + const _SectionLabel(this.text); + @override + Widget build(BuildContext context) => Text(text, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Color(0xFF2B353E))); +} + +class _Chip extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback onTap; + const _Chip( + {required this.label, required this.selected, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: selected ? const Color(0xFF2B353E) : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: selected + ? const Color(0xFF2B353E) + : Colors.grey.shade300, + width: selected ? 2 : 1), + ), + child: Text(label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: selected ? Colors.white : Colors.black87)), + ), + ); + } +} + +class _UrlDisplay extends StatelessWidget { + final String label; + final String value; + const _UrlDisplay({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.grey)), + const SizedBox(height: 3), + Container( + width: double.infinity, + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade200), + ), + child: Text(value, + style: const TextStyle( + fontSize: 12, color: Color(0xFF2B353E))), + ), + ], + ); + } +} + + + + + diff --git a/lib/main.dart b/lib/main.dart index 45efd593..e673fdaf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,6 +23,7 @@ import 'package:localstorage/localstorage.dart'; import 'package:provider/provider.dart'; import 'config/size_config.dart'; +import 'dev_tools/environment_config.dart'; import 'core/viewModels/auth_provider_view_model.dart'; import 'core/viewModels/pharmacyModule/OrderPreviewViewModel.dart'; import 'core/viewModels/project_view_model.dart'; @@ -44,6 +45,7 @@ void main() async { // }; setupLocator(); + if (ENABLE_DEV_TOOLS) await EnvironmentConfigService.instance.applyStoredEnvironment(); HttpOverrides.global = MyHttpOverrides(); runApp(MyApp()); diff --git a/lib/widgets/drawer/app_drawer_widget.dart b/lib/widgets/drawer/app_drawer_widget.dart index fbfbb205..cde9a438 100644 --- a/lib/widgets/drawer/app_drawer_widget.dart +++ b/lib/widgets/drawer/app_drawer_widget.dart @@ -1,15 +1,30 @@ import 'dart:io' show Platform; + import 'package:auto_size_text/auto_size_text.dart'; import 'package:barcode_scan2/barcode_scan2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +// import 'package:flutter_amazonpaymentservices/environment_type.dart'; +// import 'package:flutter_amazonpaymentservices/flutter_amazonpaymentservices.dart'; + +// import 'package:flutter_amazonpaymentservices/environment_type.dart'; +// import 'package:flutter_amazonpaymentservices/flutter_amazonpaymentservices.dart'; + +// import 'package:flutter_amazonpaymentservices/flutter_amazonpaymentservices.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:hmg_patient_app/analytics/google-analytics.dart'; import 'package:hmg_patient_app/config/config.dart'; import 'package:hmg_patient_app/config/shared_pref_kay.dart'; +import 'package:hmg_patient_app/config/size_config.dart'; import 'package:hmg_patient_app/core/service/AuthenticatedUserObject.dart'; import 'package:hmg_patient_app/core/service/medical/vital_sign_service.dart'; import 'package:hmg_patient_app/core/service/privilege_service.dart'; import 'package:hmg_patient_app/core/viewModels/appointment_rate_view_model.dart'; import 'package:hmg_patient_app/core/viewModels/pharmacyModule/pharmacy_module_view_model.dart'; import 'package:hmg_patient_app/core/viewModels/project_view_model.dart'; +import 'package:hmg_patient_app/dev_tools/api_log_screen.dart'; +import 'package:hmg_patient_app/dev_tools/environment_config_sheet.dart'; +import 'package:hmg_patient_app/locator.dart'; import 'package:hmg_patient_app/models/Appointments/toDoCountProviderModel.dart'; import 'package:hmg_patient_app/models/Authentication/authenticated_user.dart'; import 'package:hmg_patient_app/models/Authentication/check_activation_code_response.dart'; @@ -37,29 +52,17 @@ import 'package:hmg_patient_app/uitl/LocalNotification.dart'; import 'package:hmg_patient_app/uitl/app_shared_preferences.dart'; import 'package:hmg_patient_app/uitl/app_toast.dart'; import 'package:hmg_patient_app/uitl/gif_loader_dialog_utils.dart'; +import 'package:hmg_patient_app/uitl/navigation_service.dart'; import 'package:hmg_patient_app/uitl/translations_delegate_base.dart'; import 'package:hmg_patient_app/uitl/utils.dart'; import 'package:hmg_patient_app/uitl/utils_new.dart'; import 'package:hmg_patient_app/widgets/dialogs/confirm_dialog.dart'; import 'package:hmg_patient_app/widgets/text/app_texts_widget.dart'; import 'package:hmg_patient_app/widgets/transitions/fade_page.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -// import 'package:flutter_amazonpaymentservices/environment_type.dart'; -// import 'package:flutter_amazonpaymentservices/flutter_amazonpaymentservices.dart'; - -// import 'package:flutter_amazonpaymentservices/environment_type.dart'; -// import 'package:flutter_amazonpaymentservices/flutter_amazonpaymentservices.dart'; - -// import 'package:flutter_amazonpaymentservices/flutter_amazonpaymentservices.dart'; -import 'package:flutter_svg/flutter_svg.dart'; import 'package:in_app_review/in_app_review.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../../config/size_config.dart'; -import '../../locator.dart'; import 'drawer_item_widget.dart'; class AppDrawer extends StatefulWidget { @@ -144,8 +147,15 @@ class _AppDrawerState extends State { mainAxisAlignment: MainAxisAlignment.start, children: [ Text( - (user != null && projectProvider!.isLogin) ? user!.firstName! + ' ' + user!.lastName! : TranslationBase.of(context).cantSeeProfile, - style: TextStyle(color: Color(0xff2E303A), fontWeight: FontWeight.bold, fontSize: 20, letterSpacing: -1.2, height: 35 / 20), + (user != null && projectProvider!.isLogin) + ? user!.firstName! + ' ' + user!.lastName! + : TranslationBase.of(context).cantSeeProfile, + style: TextStyle( + color: Color(0xff2E303A), + fontWeight: FontWeight.bold, + fontSize: 20, + letterSpacing: -1.2, + height: 35 / 20), ), AutoSizeText( (user != null && projectProvider!.isLogin) @@ -173,7 +183,11 @@ class _AppDrawerState extends State { // ), mHeight(20.0), (user != null && projectProvider!.isLogin) - ? Image.network("https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=${user!.patientID.toString()}", fit: BoxFit.fill, height: 70, width: 70) + ? Image.network( + "https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=${user!.patientID.toString()}", + fit: BoxFit.fill, + height: 70, + width: 70) : Container(), ], ), @@ -216,7 +230,8 @@ class _AppDrawerState extends State { children: [ (user!.isFamily == null || user!.isFamily == false) ? InkWell( - child: DrawerItem(TranslationBase.of(context).family, SvgPicture.asset("assets/images/new/family_files.svg"), + child: DrawerItem( + TranslationBase.of(context).family, SvgPicture.asset("assets/images/new/family_files.svg"), isImageIcon: true, bottomLine: false, textColor: CustomColors.black, @@ -306,7 +321,10 @@ class _AppDrawerState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - child: Icon(Icons.person, color: result.responseID == user!.patientID ? Colors.transparent : Colors.transparent), + child: Icon(Icons.person, + color: result.responseID == user!.patientID + ? Colors.transparent + : Colors.transparent), ), Expanded( flex: 7, @@ -317,14 +335,20 @@ class _AppDrawerState extends State { children: [ AppText( result.patientName!, - color: result.responseID == user!.patientID ? Color(0xffC5272D) : Color(0xff2B353E), + color: result.responseID == user!.patientID + ? Color(0xffC5272D) + : Color(0xff2B353E), fontWeight: FontWeight.w600, fontSize: 11, letterSpacing: -0.33, ), AppText( - TranslationBase.of(context).fileno + ": " + result.responseID.toString(), - color: result.responseID == user!.patientID ? Color(0xffC5272D) : Color(0xff989898), + TranslationBase.of(context).fileno + + ": " + + result.responseID.toString(), + color: result.responseID == user!.patientID + ? Color(0xffC5272D) + : Color(0xff989898), fontSize: 11, letterSpacing: -0.33, ), @@ -348,8 +372,11 @@ class _AppDrawerState extends State { ), mHeight(20), InkWell( - child: DrawerItem(TranslationBase.of(context).arabicChange, - Padding(child: Image.asset('assets/images/lang.png'), padding: EdgeInsets.only(left: 3, right: 3, top: 3, bottom: projectProvider!.isArabic ? 3 : 0)), + child: DrawerItem( + TranslationBase.of(context).arabicChange, + Padding( + child: Image.asset('assets/images/lang.png'), + padding: EdgeInsets.only(left: 3, right: 3, top: 3, bottom: projectProvider!.isArabic ? 3 : 0)), isImageIcon: true, bottomLine: false, letterSpacing: -0.84, @@ -429,8 +456,13 @@ class _AppDrawerState extends State { ), if (projectProvider!.havePrivilege(3)) InkWell( - child: DrawerItem(TranslationBase.of(context).appsetting, SvgPicture.asset("assets/images/new/app_setting.svg"), - isImageIcon: true, bottomLine: false, letterSpacing: -0.84, fontSize: 14, projectProvider: projectProvider), + child: DrawerItem( + TranslationBase.of(context).appsetting, SvgPicture.asset("assets/images/new/app_setting.svg"), + isImageIcon: true, + bottomLine: false, + letterSpacing: -0.84, + fontSize: 14, + projectProvider: projectProvider), onTap: () { Navigator.of(context).pushNamed( SETTINGS, @@ -439,7 +471,8 @@ class _AppDrawerState extends State { }, ), InkWell( - child: DrawerItem(TranslationBase.of(context).rateApp, Icons.star, bottomLine: false, letterSpacing: -0.84, fontSize: 14, projectProvider: projectProvider), + child: DrawerItem(TranslationBase.of(context).rateApp, Icons.star, + bottomLine: false, letterSpacing: -0.84, fontSize: 14, projectProvider: projectProvider), onTap: () { openAppReviewDialog(); locator().hamburgerMenu.logMenuItemClick('rate our app'); @@ -452,7 +485,11 @@ class _AppDrawerState extends State { ), InkWell( child: DrawerItem(TranslationBase.of(context).logout, SvgPicture.asset("assets/images/new/logout.svg"), - isImageIcon: true, bottomLine: false, letterSpacing: -0.84, fontSize: 14, projectProvider: projectProvider), + isImageIcon: true, + bottomLine: false, + letterSpacing: -0.84, + fontSize: 14, + projectProvider: projectProvider), onTap: () { logout(); }, @@ -466,8 +503,46 @@ class _AppDrawerState extends State { login(); }, ), + // ── Dev Tools ───────────────────────────────── + if (ENABLE_DEV_TOOLS) InkWell( + child: DrawerItem('API Logs', Icons.api_outlined, + letterSpacing: -0.84, fontSize: 14, bottomLine: false, projectProvider: projectProvider), + onTap: () { + Navigator.of(context).pop(); + Future.delayed(const Duration(milliseconds: 300), () { + locator() + .navigatorKey + .currentState! + .push(MaterialPageRoute(builder: (_) => const ApiLogScreen())); + }); + }, + ), + if (ENABLE_DEV_TOOLS) InkWell( + child: DrawerItem('Environment Config', Icons.tune, + letterSpacing: -0.84, fontSize: 14, bottomLine: false, projectProvider: projectProvider), + onTap: () async { + Navigator.of(context).pop(); + // Wait for drawer close animation before showing sheet + await Future.delayed(const Duration(milliseconds: 300)); + final navState = locator().navigatorKey.currentState; + if (navState == null) return; + final shouldRestart = await showModalBottomSheet( + context: navState.context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => const EnvironmentConfigSheet(), + ); + if (shouldRestart == true) { + // Delay to let the sheet fully dismiss before navigating + await Future.delayed(const Duration(milliseconds: 300)); + locator().navigatorKey.currentState!.pushNamedAndRemoveUntil(SPLASH, (route) => false); + } + }, + ), + // ───────────────────────────────────────────── InkWell( - child: DrawerItem(TranslationBase.of(context).privacyPolicy, Icons.web, letterSpacing: -0.84, fontSize: 14, bottomLine: false), + child: DrawerItem(TranslationBase.of(context).privacyPolicy, Icons.web, + letterSpacing: -0.84, fontSize: 14, bottomLine: false), onTap: () { if (projectProvider!.isArabic) launch("https://hmg.com/ar/Pages/Privacy.aspx"); @@ -476,7 +551,8 @@ class _AppDrawerState extends State { }, ), InkWell( - child: DrawerItem(TranslationBase.of(context).termsConditions, Icons.web, letterSpacing: -0.84, fontSize: 14, bottomLine: false), + child: DrawerItem(TranslationBase.of(context).termsConditions, Icons.web, + letterSpacing: -0.84, fontSize: 14, bottomLine: false), onTap: () { Navigator.of(context).push(FadePage(page: UserAgreementPage())); }, @@ -650,11 +726,9 @@ class _AppDrawerState extends State { SelectDeviceIMEIRES savedData = SelectDeviceIMEIRES.fromJson(data); Navigator.of(context).pushReplacement( MaterialPageRoute( - builder: (BuildContext context) => SavedLogin(savedData), + builder: (BuildContext context) => SavedLogin(savedData), ), ); - - } else { GifLoaderDialogUtils.showMyDialog(context); authService.selectDeviceImei(DEVICE_TOKEN).then((value) async { @@ -665,7 +739,7 @@ class _AppDrawerState extends State { setUserValues(value); Navigator.of(context).pushReplacement( MaterialPageRoute( - builder: (BuildContext context) => SavedLogin(savedData), + builder: (BuildContext context) => SavedLogin(savedData), ), ); } else { @@ -735,15 +809,13 @@ class _AppDrawerState extends State { GifLoaderDialogUtils.showMyDialog(context); sharedPref.remove(BLOOD_TYPE); this.familyFileProvider.silentLoggin(user is AuthenticatedUser ? null : user, languageID, mainUser: user is AuthenticatedUser).then((value) { - // GifLoaderDialogUtils.hideDialog(context); - // Navigator.of(context).pop(); loginAfter(value, context, user is AuthenticatedUser); locator().hamburgerMenu.logMenuItemClick('switch to my family account'); }).catchError((err) { GifLoaderDialogUtils.hideDialog(context); print(err); - // AppToast.showErrorToast(message: err.toString()); Navigator.of(context).pop(); + return; }); } @@ -852,8 +924,7 @@ class _AppDrawerState extends State { }) .catchError((err) { print(err); - //Utils.hideProgressDialog(); - // GifLoaderDialogUtils.hideDialog(context); + return {}; }); }