// 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), ], ), ), ), ); } }