diff --git a/lib/screens/terminal/terminal_page.dart b/lib/screens/terminal/terminal_page.dart index c775bf55..57373778 100644 --- a/lib/screens/terminal/terminal_page.dart +++ b/lib/screens/terminal/terminal_page.dart @@ -3,49 +3,146 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/terminal/models.dart'; import '../../consts.dart'; import '../../providers/terminal_providers.dart'; +import '../../widgets/button_copy.dart'; +import '../../widgets/field_search.dart'; +import '../../widgets/terminal_tiles.dart'; +import '../../widgets/empty_message.dart'; +import 'package:apidash_design_system/apidash_design_system.dart'; -class TerminalPage extends ConsumerWidget { +class TerminalPage extends ConsumerStatefulWidget { const TerminalPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _TerminalPageState(); +} + +class _TerminalPageState extends ConsumerState { + final TextEditingController _searchCtrl = TextEditingController(); + + // Initially all levels will be selected + final Set _selectedLevels = { + TerminalLevel.debug, + TerminalLevel.info, + TerminalLevel.warn, + TerminalLevel.error, + }; + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { final state = ref.watch(terminalStateProvider); - final entries = state.entries; + final allEntries = state.entries; + final filtered = _applyFilters(allEntries); return Scaffold( - appBar: AppBar(title: const Text('Terminal')), - body: ListView.separated( - // reverse: true, - itemCount: entries.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (ctx, i) { - final e = entries[entries.length - 1 - i]; - final title = _titleFor(e); - final subtitle = _subtitleFor(e); - final icon = _iconFor(e); - return ListTile( - leading: Icon(icon), - title: SelectableText(title, maxLines: 1), - subtitle: SelectableText( - subtitle ?? '', - maxLines: 2, - ), - dense: true, - ); - }, + body: Column( + children: [ + _buildToolbar(context, allEntries), + const Divider(height: 1), + Expanded( + child: filtered.isEmpty + ? const Center( + child: EmptyMessage( + title: 'No logs yet', + subtitle: + 'Send a request to see details here in the console', + ), + ) + : ListView.separated( + itemCount: filtered.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (ctx, i) { + final e = filtered[filtered.length - 1 - i]; + switch (e.source) { + case TerminalSource.js: + return JsLogTile(entry: e); + case TerminalSource.network: + return NetworkLogTile(entry: e); + case TerminalSource.system: + return SystemLogTile(entry: e); + } + }, + ), + ), + ], ), ); } - IconData _iconFor(TerminalEntry e) { - switch (e.source) { - case TerminalSource.network: - return Icons.language; - case TerminalSource.js: - return Icons.javascript; - case TerminalSource.system: - return Icons.info_outline; + Widget _buildToolbar(BuildContext context, List allEntries) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + child: Row( + children: [ + Expanded( + child: SearchField( + controller: _searchCtrl, + hintText: 'Search logs', + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(width: 8), + // Filter button + _FilterMenu( + selected: _selectedLevels, + onChanged: (set) => setState(() { + _selectedLevels + ..clear() + ..addAll(set); + }), + ), + + const Spacer(), + // Clear button + ADIconButton( + tooltip: 'Clear logs', + icon: Icons.delete_outline, + iconSize: 22, + onPressed: () { + ref.read(terminalStateProvider.notifier).clear(); + }, + ), + const SizedBox(width: 4), + // Copy all button + CopyButton( + showLabel: false, + toCopy: _serializeAllLogs(allEntries), + ), + ], + ), + ); + } + + List _applyFilters(List entries) { + final q = _searchCtrl.text.trim().toLowerCase(); + bool matches(TerminalEntry e) { + if (!_selectedLevels.contains(e.level)) return false; + if (q.isEmpty) return true; + final title = _titleFor(e).toLowerCase(); + final sub = (_subtitleFor(e) ?? '').toLowerCase(); + return title.contains(q) || sub.contains(q); } + + return entries.where(matches).toList(growable: false); + } + + String _serializeAllLogs(List entries) { + final buf = StringBuffer(); + for (final e in entries) { + final time = e.ts.toIso8601String(); + final title = _titleFor(e); + final sub = _subtitleFor(e); + buf.writeln('[$time] ${e.level.name.toUpperCase()} - $title'); + if (sub != null && sub.isNotEmpty) { + buf.writeln(' $sub'); + } + } + return buf.toString(); } String _titleFor(TerminalEntry e) { @@ -77,3 +174,121 @@ class TerminalPage extends ConsumerWidget { } } } + +class _FilterMenu extends StatelessWidget { + const _FilterMenu({ + required this.selected, + required this.onChanged, + }); + + final Set selected; + final ValueChanged> onChanged; + + void _toggleLevel(TerminalLevel level) { + final next = selected.contains(level) + ? (selected.toSet()..remove(level)) + : (selected.toSet()..add(level)); + onChanged(next); + } + + @override + Widget build(BuildContext context) { + final all = TerminalLevel.values.toSet(); + return PopupMenuButton<_MenuAction>( + tooltip: 'Filter', + icon: const Icon(Icons.filter_alt), + onSelected: (action) { + switch (action) { + case _MenuAction.toggleDebug: + _toggleLevel(TerminalLevel.debug); + break; + case _MenuAction.toggleInfo: + _toggleLevel(TerminalLevel.info); + break; + case _MenuAction.toggleWarn: + _toggleLevel(TerminalLevel.warn); + break; + case _MenuAction.toggleError: + _toggleLevel(TerminalLevel.error); + break; + case _MenuAction.selectAll: + onChanged(all); + break; + case _MenuAction.clearAll: + onChanged({}); + break; + } + }, + itemBuilder: (context) => [ + CheckedPopupMenuItem<_MenuAction>( + value: _MenuAction.toggleError, + checked: selected.contains(TerminalLevel.error), + child: const ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.error_outline_rounded, color: Colors.red), + title: Text('Errors'), + ), + ), + CheckedPopupMenuItem<_MenuAction>( + value: _MenuAction.toggleWarn, + checked: selected.contains(TerminalLevel.warn), + child: const ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.warning_amber_outlined, color: Colors.amber), + title: Text('Warnings'), + ), + ), + CheckedPopupMenuItem<_MenuAction>( + value: _MenuAction.toggleInfo, + checked: selected.contains(TerminalLevel.info), + child: const ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.info_outline, color: Colors.blue), + title: Text('Info'), + ), + ), + CheckedPopupMenuItem<_MenuAction>( + value: _MenuAction.toggleDebug, + checked: selected.contains(TerminalLevel.debug), + child: const ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.bug_report_outlined), + title: Text('Debug'), + ), + ), + const PopupMenuDivider(), + PopupMenuItem<_MenuAction>( + value: _MenuAction.selectAll, + child: const ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.select_all), + title: Text('Select all'), + ), + ), + PopupMenuItem<_MenuAction>( + value: _MenuAction.clearAll, + child: const ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.clear_all), + title: Text('Clear selection'), + ), + ), + ], + ); + } +} + +enum _MenuAction { + toggleDebug, + toggleInfo, + toggleWarn, + toggleError, + selectAll, + clearAll, +} diff --git a/lib/widgets/empty_message.dart b/lib/widgets/empty_message.dart new file mode 100644 index 00000000..cdcb3c34 --- /dev/null +++ b/lib/widgets/empty_message.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +class EmptyMessage extends StatelessWidget { + const EmptyMessage({ + super.key, + this.title = 'No logs yet', + this.subtitle = 'Send a request to view its details in the console.', + this.icon, + }); + + final String title; + final String subtitle; + final IconData? icon; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 36, color: theme.colorScheme.outline), + const SizedBox(height: 8), + ], + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text( + subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.outline, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/expandable_section.dart b/lib/widgets/expandable_section.dart new file mode 100644 index 00000000..c8022e94 --- /dev/null +++ b/lib/widgets/expandable_section.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +class ExpandableSection extends StatefulWidget { + const ExpandableSection( + {super.key, required this.title, required this.child}); + + final String title; + final Widget child; + + @override + State createState() => _ExpandableSectionState(); +} + +class _ExpandableSectionState extends State { + bool _open = false; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + enableFeedback: false, + borderRadius: BorderRadius.circular(3), + onTap: () => setState(() => _open = !_open), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + children: [ + Expanded( + child: Text( + widget.title, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + Icon( + _open ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, + ), + ], + ), + ), + ), + if (_open) + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: widget.child, + ), + ], + ); + } +} diff --git a/lib/widgets/field_search.dart b/lib/widgets/field_search.dart new file mode 100644 index 00000000..f03cf3e9 --- /dev/null +++ b/lib/widgets/field_search.dart @@ -0,0 +1,46 @@ +import 'package:apidash_design_system/apidash_design_system.dart'; +import 'package:flutter/material.dart'; + +class SearchField extends StatelessWidget { + const SearchField({ + super.key, + this.controller, + this.onChanged, + this.hintText = 'Search', + this.height = 36, + }); + + final TextEditingController? controller; + final ValueChanged? onChanged; + final String hintText; + final double height; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + height: height, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.surfaceContainerHighest, + ), + ), + child: Row( + children: [ + const Icon(Icons.search, size: 18), + const SizedBox(width: 6), + Expanded( + child: ADRawTextField( + controller: controller, + hintText: hintText, + onChanged: onChanged, + style: theme.textTheme.bodyMedium, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/terminal_tiles.dart b/lib/widgets/terminal_tiles.dart new file mode 100644 index 00000000..35f73e62 --- /dev/null +++ b/lib/widgets/terminal_tiles.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; +import '../consts.dart'; +import '../models/terminal/models.dart'; +import 'expandable_section.dart'; + +class SystemLogTile extends StatelessWidget { + const SystemLogTile({super.key, required this.entry}); + final TerminalEntry entry; + @override + Widget build(BuildContext context) { + assert(entry.system != null, 'System tile requires SystemLogData'); + final s = entry.system!; + final cs = Theme.of(context).colorScheme; + IconData icon; + Color? iconColor; + switch (entry.level) { + case TerminalLevel.error: + icon = Icons.error_outline_rounded; + iconColor = cs.error; + break; + case TerminalLevel.warn: + icon = Icons.warning_amber_rounded; + iconColor = Colors.amber[800]; + break; + case TerminalLevel.info: + case TerminalLevel.debug: + icon = Icons.info_outline; + iconColor = cs.primary; + break; + } + final titleStyle = Theme.of(context).textTheme.bodyMedium; + final subStyle = Theme.of(context).textTheme.bodySmall; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2, right: 8), + child: Icon(icon, size: 18, color: iconColor), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('[${s.category}] ${s.message}', style: titleStyle), + if (s.stack != null && s.stack!.isNotEmpty) ...[ + const SizedBox(height: 4), + SelectableText(s.stack!, style: subStyle), + ], + ], + ), + ), + ], + ), + ); + } +} + +class JsLogTile extends StatelessWidget { + const JsLogTile({super.key, required this.entry}); + final TerminalEntry entry; + @override + Widget build(BuildContext context) { + assert(entry.js != null, 'JS tile requires JsLogData'); + final cs = Theme.of(context).colorScheme; + final j = entry.js!; + IconData? icon; + Color? iconColor; + Color? bg; + switch (entry.level) { + case TerminalLevel.error: + icon = Icons.error_outline_rounded; + iconColor = cs.error; + bg = Colors.redAccent.shade200.withValues(alpha: 0.2); + break; + case TerminalLevel.warn: + icon = Icons.warning_amber_rounded; + iconColor = Colors.amber[800]; + bg = Colors.amberAccent.shade200.withValues(alpha: 0.25); + break; + case TerminalLevel.info: + case TerminalLevel.debug: + break; + } + return Container( + color: bg, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (icon != null) ...[ + Padding( + padding: const EdgeInsets.only(top: 2, right: 8), + child: Icon(icon, size: 18, color: iconColor), + ), + ], + Expanded( + child: SelectableText( + j.args.join(' '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ); + } +} + +class NetworkLogTile extends StatefulWidget { + const NetworkLogTile({super.key, required this.entry}); + final TerminalEntry entry; + @override + State createState() => _NetworkLogTileState(); +} + +class _NetworkLogTileState extends State { + bool _expanded = false; + @override + Widget build(BuildContext context) { + final n = widget.entry.network!; + final methodUrl = '${n.method.name.toUpperCase()} ${n.url}'; + final status = n.responseStatus != null ? '${n.responseStatus}' : null; + final duration = + n.duration != null ? '${n.duration!.inMilliseconds} ms' : null; + return Column( + children: [ + InkWell( + onTap: () => setState(() => _expanded = !_expanded), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Expanded( + child: Text( + methodUrl, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + const SizedBox(width: 12), + if (status != null) Text(status), + if (status != null && duration != null) const Text(' | '), + if (duration != null) Text(duration), + const SizedBox(width: 6), + Icon(_expanded + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down), + ], + ), + ), + ), + if (_expanded) NetworkDetails(n: n), + ], + ); + } +} + +class NetworkDetails extends StatelessWidget { + const NetworkDetails({super.key, required this.n}); + final NetworkLogData n; + @override + Widget build(BuildContext context) { + final tiles = [ + ExpandableSection( + title: 'Network', + child: _kvBody({ + 'API Type': n.apiType.name, + 'Phase': n.phase.name, + if (n.isStreaming) 'Streaming': 'true', + if (n.sentAt != null) 'Sent': n.sentAt!.toIso8601String(), + if (n.completedAt != null) + 'Completed': n.completedAt!.toIso8601String(), + if (n.duration != null) + 'Duration': '${n.duration!.inMilliseconds} ms', + 'URL': n.url, + 'Method': n.method.name.toUpperCase(), + if (n.responseStatus != null) 'Status': '${n.responseStatus}', + if (n.errorMessage != null) 'Error': n.errorMessage!, + }), + ), + ExpandableSection( + title: 'Request Headers', child: _mapBody(n.requestHeaders)), + ExpandableSection( + title: 'Request Body', child: _textBody(n.requestBodyPreview)), + ExpandableSection( + title: 'Response Headers', child: _mapBody(n.responseHeaders)), + ExpandableSection( + title: 'Response Body', child: _textBody(n.responseBodyPreview)), + ]; + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: Column( + children: [ + for (int i = 0; i < tiles.length; i++) ...[ + tiles[i], + if (i != tiles.length - 1) const SizedBox(height: 8), + ], + ], + ), + ); + } + + Widget _textBody(String? text) { + return SelectableText(text == null || text.isEmpty ? '(empty)' : text); + } + + Widget _mapBody(Map? map) { + if (map == null || map.isEmpty) { + return const SelectableText('(none)'); + } + final lines = map.entries.map((e) => '${e.key}: ${e.value}').join('\n'); + return SelectableText(lines); + } + + Widget _kvBody(Map map) { + final lines = map.entries.map((e) => '${e.key}: ${e.value}').join('\n'); + return SelectableText(lines); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index cae6c455..e48f8517 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -33,6 +33,10 @@ export 'field_cell.dart'; export 'field_json_search.dart'; export 'field_read_only.dart'; export 'field_text_bounded.dart'; +export 'field_search.dart'; +export 'expandable_section.dart'; +export 'empty_message.dart'; +export 'terminal_tiles.dart'; export 'field_url.dart'; export 'intro_message.dart'; export 'markdown.dart';