added faiz_dev_tools_imp
parent
818a1f8205
commit
11c2ef71c3
@ -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<ApiLogScreen> createState() => _ApiLogScreenState();
|
||||
}
|
||||
|
||||
class _ApiLogScreenState extends State<ApiLogScreen> {
|
||||
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<bool>(
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<String, String> headers;
|
||||
final Map<String, dynamic>? 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<ApiLogEntry> _logs = [];
|
||||
|
||||
List<ApiLogEntry> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<AppEnvironmentPreset> 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<void> 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<void> 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<void> 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<String> getCurrentBaseUrl() async {
|
||||
return (await _prefs.getString(ENV_BASE_URL_KEY)) ?? BASE_URL;
|
||||
}
|
||||
|
||||
Future<String> getCurrentRcUrl() async {
|
||||
return (await _prefs.getString(ENV_RC_BASE_URL_KEY)) ?? RC_BASE_URL;
|
||||
}
|
||||
|
||||
Future<String> getCurrentPharmacyUrl() async {
|
||||
return (await _prefs.getString(ENV_PHARMACY_BASE_URL_KEY)) ?? PHARMACY_BASE_URL;
|
||||
}
|
||||
|
||||
Future<String> getCurrentExaCartUrl() async {
|
||||
return (await _prefs.getString(ENV_EXA_CART_BASE_URL_KEY)) ?? EXA_CART_API_BASE_URL;
|
||||
}
|
||||
|
||||
Future<String> getCurrentPayfortMode() async {
|
||||
return (await _prefs.getString(ENV_PAYFORT_KEY)) ?? 'production';
|
||||
}
|
||||
|
||||
/// Find preset whose baseUrl matches the currently saved one.
|
||||
Future<AppEnvironmentPreset?> getCurrentPreset() async {
|
||||
final storedBase = await getCurrentBaseUrl();
|
||||
try {
|
||||
return kEnvironmentPresets.firstWhere((p) => p.baseUrl == storedBase);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<EnvironmentConfigSheet> createState() => _EnvironmentConfigSheetState();
|
||||
}
|
||||
|
||||
/// Convenience function — opens the sheet and returns true if app restart needed.
|
||||
Future<bool> showEnvironmentConfigSheet(BuildContext context) async {
|
||||
final result = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => const EnvironmentConfigSheet(),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
class _EnvironmentConfigSheetState extends State<EnvironmentConfigSheet> {
|
||||
final _envService = EnvironmentConfigService.instance;
|
||||
|
||||
AppEnvironmentPreset? _selectedPreset;
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCurrent();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<void> _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))),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue