diff --git a/lib/components/viewers/json_viewer.dart b/lib/components/viewers/json_viewer.dart index d111d40..50ce9e1 100644 --- a/lib/components/viewers/json_viewer.dart +++ b/lib/components/viewers/json_viewer.dart @@ -1423,7 +1423,16 @@ class _AsyncJsonParserState extends State { } if (raw is String) { final trimmed = raw.trim(); - if (trimmed.isEmpty || (trimmed[0] != '{' && trimmed[0] != '[')) { + // Normalise empty / whitespace-only strings to `null` so consumers + // can treat them uniformly as "no data" (renders an EmptyState) + // instead of falling through to a blank JSON viewer. + if (trimmed.isEmpty) { + _parsedData = null; + _isJson = false; + _ready = true; + return true; + } + if (trimmed[0] != '{' && trimmed[0] != '[') { _parsedData = raw; _isJson = false; _ready = true; diff --git a/lib/features/all_events/presentation/buttons/header_icon_button.dart b/lib/features/all_events/presentation/buttons/header_icon_button.dart new file mode 100644 index 0000000..df7434c --- /dev/null +++ b/lib/features/all_events/presentation/buttons/header_icon_button.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +/// 30×30 hover-fade icon button used in the network detail's mini-header +/// ("Close", "Screenshot", …). Background tweens to a tinted fill on hover. +class HeaderIconButton extends StatefulWidget { + final IconData icon; + final String tooltip; + final bool isDark; + final VoidCallback onTap; + + const HeaderIconButton({ + super.key, + required this.icon, + required this.tooltip, + required this.isDark, + required this.onTap, + }); + + @override + State createState() => _HeaderIconButtonState(); +} + +class _HeaderIconButtonState extends State { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final hoverColor = widget.isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.05); + final iconColor = widget.isDark + ? const Color(0xFF9A9A9A) + : const Color(0xFF6B6B6B); + return Tooltip( + message: widget.tooltip, + child: MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: widget.onTap, + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: const Cubic(0.16, 1, 0.3, 1), + width: 30, + height: 30, + decoration: BoxDecoration( + color: _hovered ? hoverColor : Colors.transparent, + borderRadius: BorderRadius.circular(7), + ), + child: Icon(widget.icon, size: 14, color: iconColor), + ), + ), + ), + ); + } +} diff --git a/lib/features/all_events/presentation/buttons/icon_btn.dart b/lib/features/all_events/presentation/buttons/icon_btn.dart new file mode 100644 index 0000000..faff7d4 --- /dev/null +++ b/lib/features/all_events/presentation/buttons/icon_btn.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/theme/color_tokens.dart'; + +/// Square 28×28 icon button for the All Events toolbar. Three visual states: +/// +/// - **active** (primary tint) — for sort/auto-scroll toggles that are on. +/// - **danger-on-hover** (error tint) — for the clear-all trash icon. +/// - **muted-on-hover** — neutral hover for inactive icons. +class IconBtn extends StatefulWidget { + final IconData icon; + final String tooltip; + final bool isActive; + final bool isDanger; + final VoidCallback onTap; + + const IconBtn({ + super.key, + required this.icon, + required this.tooltip, + this.isActive = false, + this.isDanger = false, + required this.onTap, + }); + + @override + State createState() => _IconBtnState(); +} + +class _IconBtnState extends State { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + Color iconColor; + Color bgColor; + + if (widget.isActive) { + iconColor = ColorTokens.primary; + bgColor = ColorTokens.primary.withValues(alpha: 0.15); + } else if (widget.isDanger && _hovered) { + iconColor = ColorTokens.error; + bgColor = ColorTokens.error.withValues(alpha: 0.12); + } else if (_hovered) { + iconColor = isDark ? Colors.grey[300]! : Colors.grey[700]!; + bgColor = isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.06); + } else { + iconColor = isDark ? Colors.grey[500]! : Colors.grey[500]!; + bgColor = Colors.transparent; + } + + return Tooltip( + message: widget.tooltip, + child: GestureDetector( + onTap: widget.onTap, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 28, + height: 28, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(7), + ), + child: Icon( + widget.icon, + size: 14, + color: iconColor, + ), + ), + ), + ), + ); + } +} + +/// Slightly larger icon button used in the header next to the port/device +/// counters (the server restart "Reload" button). +class HeaderActionIcon extends StatefulWidget { + final IconData icon; + final String tooltip; + final bool spinning; + final VoidCallback onTap; + + const HeaderActionIcon({ + super.key, + required this.icon, + required this.tooltip, + required this.spinning, + required this.onTap, + }); + + @override + State createState() => _HeaderActionIconState(); +} + +class _HeaderActionIconState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _spinCtrl; + bool _hovered = false; + + @override + void initState() { + super.initState(); + _spinCtrl = AnimationController( + vsync: this, + duration: const Duration(seconds: 1), + ); + if (widget.spinning) _spinCtrl.repeat(); + } + + @override + void didUpdateWidget(covariant HeaderActionIcon old) { + super.didUpdateWidget(old); + if (widget.spinning && !_spinCtrl.isAnimating) { + _spinCtrl.repeat(); + } else if (!widget.spinning && _spinCtrl.isAnimating) { + _spinCtrl.reset(); + } + } + + @override + void dispose() { + _spinCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final iconColor = _hovered || widget.spinning + ? ColorTokens.primary + : (isDark ? Colors.grey[400]! : Colors.grey[600]!); + final bgColor = _hovered || widget.spinning + ? ColorTokens.primary.withValues(alpha: 0.12) + : Colors.transparent; + + return Tooltip( + message: widget.tooltip, + child: GestureDetector( + onTap: widget.spinning ? null : widget.onTap, + child: MouseRegion( + cursor: + widget.spinning ? SystemMouseCursors.basic : SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(6), + ), + child: RotationTransition( + turns: _spinCtrl, + child: Icon(widget.icon, size: 12, color: iconColor), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/all_events/presentation/buttons/mini_icon_button.dart b/lib/features/all_events/presentation/buttons/mini_icon_button.dart new file mode 100644 index 0000000..8fb688d --- /dev/null +++ b/lib/features/all_events/presentation/buttons/mini_icon_button.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +/// 24×24 neutral square icon button. Used inside dense detail-panel +/// sections where the 28×28 [IconBtn] would feel too large. +class MiniIconButton extends StatelessWidget { + final IconData icon; + final String tooltip; + final VoidCallback onTap; + + const MiniIconButton({ + super.key, + required this.icon, + required this.tooltip, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: GestureDetector( + onTap: onTap, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: Colors.grey.withValues(alpha: 0.06), + ), + child: Icon(icon, size: 11, color: Colors.grey[500]), + ), + ), + ), + ); + } +} diff --git a/lib/features/all_events/presentation/buttons/pressable_button.dart b/lib/features/all_events/presentation/buttons/pressable_button.dart new file mode 100644 index 0000000..7bcf285 --- /dev/null +++ b/lib/features/all_events/presentation/buttons/pressable_button.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +import '../../../../components/text/text_component.dart'; + +/// Wraps [child] in a press-feedback that scales to 0.92 and dips opacity +/// while the pointer is held. Used as the chassis for [CopyButton], +/// [FormatToggleButton], and [ActionButton] in the detail panel. +class PressableButton extends StatefulWidget { + final VoidCallback onTap; + final Widget child; + + const PressableButton({ + super.key, + required this.onTap, + required this.child, + }); + + @override + State createState() => _PressableButtonState(); +} + +class _PressableButtonState extends State { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) { + setState(() => _pressed = false); + widget.onTap(); + }, + onTapCancel: () => setState(() => _pressed = false), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: AnimatedScale( + scale: _pressed ? 0.92 : 1.0, + duration: const Duration(milliseconds: 100), + child: AnimatedOpacity( + opacity: _pressed ? 0.7 : 1.0, + duration: const Duration(milliseconds: 100), + child: widget.child, + ), + ), + ), + ); + } +} + +/// Compact icon-and-label button (e.g. "Format", "Reload") rendered with +/// the shared [PressableButton] press feedback. +class ActionButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + + const ActionButton({ + super.key, + required this.icon, + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return PressableButton( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(5), + color: Colors.grey.withValues(alpha: 0.08), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: Colors.grey[500]), + const SizedBox(width: 4), + TextComponent( + label, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Colors.grey[500], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/all_events/presentation/detail/detail_header.dart b/lib/features/all_events/presentation/detail/detail_header.dart new file mode 100644 index 0000000..45aa09a --- /dev/null +++ b/lib/features/all_events/presentation/detail/detail_header.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../components/misc/status_badge.dart'; +import '../../../../components/text/text_component.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../server/providers/server_providers.dart'; +import '../../provider/all_events_provider.dart'; +import '../buttons/pressable_button.dart'; + +/// Mini-header at the top of the detail panel: shows the event's type +/// icon/label, originating device, timestamp, and the screenshot + close +/// actions. +class DetailHeader extends ConsumerWidget { + final UnifiedEvent event; + final VoidCallback onClose; + final VoidCallback onFullScreenshot; + final VoidCallback? onTabScreenshot; + final bool hasMultipleTabs; + + const DetailHeader({ + super.key, + required this.event, + required this.onClose, + required this.onFullScreenshot, + this.onTabScreenshot, + this.hasMultipleTabs = false, + }); + + static (Color, IconData, String) staticTypeDetails(EventType type) { + switch (type) { + case EventType.log: + return (ColorTokens.logInfo, LucideIcons.terminal, 'Log Detail'); + case EventType.network: + return (ColorTokens.success, LucideIcons.globe, 'Network Detail'); + case EventType.state: + return ( + ColorTokens.secondary, + LucideIcons.layers, + 'State Detail' + ); + case EventType.storage: + return ( + ColorTokens.warning, + LucideIcons.database, + 'Storage Detail' + ); + case EventType.display: + return ( + const Color(0xFF9B59B6), + LucideIcons.monitor, + 'Display Detail' + ); + case EventType.asyncOp: + return ( + const Color(0xFFE67E22), + LucideIcons.zap, + 'Async Operation' + ); + case EventType.error: + return (Colors.red, LucideIcons.alertTriangle, 'Error Detail'); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final time = DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( + DateTime.fromMillisecondsSinceEpoch(event.timestamp), + ); + + final devices = ref.watch(connectedDevicesProvider); + final device = + devices.where((d) => d.deviceId == event.deviceId).firstOrNull; + + final (typeColor, typeIcon, typeLabel) = staticTypeDetails(event.type); + + return Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + color: isDark ? ColorTokens.darkBackground : Colors.white, + ), + child: Row( + children: [ + Icon(typeIcon, size: 14, color: typeColor), + const SizedBox(width: 8), + TextComponent( + typeLabel, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: typeColor, + ), + ), + const SizedBox(width: 10), + if (device != null) ...[ + PlatformBadge(platform: device.platform), + const SizedBox(width: 8), + ], + TextComponent( + time, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 10, + color: Colors.grey[500], + ), + ), + const Spacer(), + // Screenshot buttons + Tooltip( + message: S.of(context).captureFullTooltip, + waitDuration: const Duration(milliseconds: 400), + child: ActionButton( + icon: LucideIcons.camera, + label: S.of(context).captureFull, + onTap: onFullScreenshot, + ), + ), + if (hasMultipleTabs && onTabScreenshot != null) ...[ + const SizedBox(width: 4), + Tooltip( + message: S.of(context).captureTabTooltip, + waitDuration: const Duration(milliseconds: 400), + child: ActionButton( + icon: LucideIcons.scanLine, + label: S.of(context).captureTab, + onTap: onTabScreenshot!, + ), + ), + ], + const SizedBox(width: 6), + PressableButton( + onTap: onClose, + child: Container( + width: 26, + height: 26, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: Colors.grey.withValues(alpha: 0.1), + ), + child: Icon(LucideIcons.x, size: 14, color: Colors.grey[500]), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/detail/diff_row.dart b/lib/features/all_events/presentation/detail/diff_row.dart new file mode 100644 index 0000000..8259dcb --- /dev/null +++ b/lib/features/all_events/presentation/detail/diff_row.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../components/text/text_component.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../../../models/state/state_change.dart'; + +/// One row in the state-change diff list. Shows the field path with an +/// operation icon (add / remove / change) and the old/new value(s) when +/// present. +class DiffRow extends StatelessWidget { + final StateDiffEntry diff; + + const DiffRow({super.key, required this.diff}); + + @override + Widget build(BuildContext context) { + Color opColor; + IconData opIcon; + switch (diff.operation) { + case 'add': + opColor = ColorTokens.success; + opIcon = LucideIcons.plus; + break; + case 'remove': + opColor = ColorTokens.error; + opIcon = LucideIcons.minus; + break; + default: + opColor = ColorTokens.warning; + opIcon = LucideIcons.penLine; + } + + return Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: opColor.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: opColor.withValues(alpha: 0.12)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(opIcon, size: 12, color: opColor), + const SizedBox(width: 6), + TextComponent( + diff.path, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + fontWeight: FontWeight.w600, + color: opColor, + ), + ), + const Spacer(), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: opColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(3), + ), + child: TextComponent( + diff.operation.toUpperCase(), + style: TextStyle( + fontSize: 8, + fontWeight: FontWeight.w800, + color: opColor, + ), + ), + ), + ], + ), + if (diff.oldValue != null) ...[ + const SizedBox(height: 6), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextComponent('- ', + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: ColorTokens.error)), + Expanded( + child: TextComponent( + '${diff.oldValue}', + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: ColorTokens.error.withValues(alpha: 0.8), + ), + ), + ), + ], + ), + ], + if (diff.newValue != null) ...[ + const SizedBox(height: 2), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextComponent('+ ', + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: ColorTokens.success)), + Expanded( + child: TextComponent( + '${diff.newValue}', + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: ColorTokens.success.withValues(alpha: 0.8), + ), + ), + ), + ], + ), + ], + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/detail/event_detail_panel.dart b/lib/features/all_events/presentation/detail/event_detail_panel.dart new file mode 100644 index 0000000..f5d9e0a --- /dev/null +++ b/lib/features/all_events/presentation/detail/event_detail_panel.dart @@ -0,0 +1,1433 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../components/misc/status_badge.dart'; +import '../../../../components/text/text_component.dart'; +import '../../../../components/viewers/json_viewer.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../../../core/theme/theme_provider.dart'; +import '../../../../core/utils/code_generator.dart'; +import '../../../../core/utils/duration_format.dart'; +import '../../../../core/utils/screenshot_filename.dart'; +import '../../../../core/utils/toast_utils.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../models/log/log_entry.dart'; +import '../../../../models/network/network_entry.dart'; +import '../../../../models/state/state_change.dart'; +import '../../../../models/storage/storage_entry.dart'; +import '../../../../server/providers/server_providers.dart'; +import '../../provider/all_events_provider.dart'; +import '../buttons/pressable_button.dart'; +import '../detail/detail_header.dart'; +import '../detail/diff_row.dart'; +import '../detail/fallback_detail.dart'; +import '../detail/log_detail.dart'; +import '../detail/network_detail.dart'; +import '../detail/state_detail.dart'; +import '../detail/storage_detail.dart'; +import '../network/headers_view.dart'; +import '../shared/code_block.dart'; +import '../shared/error_block.dart'; +import '../shared/info_row.dart'; +import '../shared/op_badge.dart'; +import '../shared/section_label.dart'; +import '../shared/tag_chip.dart'; +import '../shared/type_badge.dart'; + +/// Right-pane detail panel that swaps content based on the event type: +/// log / network / state / storage / display / async / error. +/// +/// Owns the screenshot machinery (full + per-tab) and the capture +/// flash/saved-toast overlays. Routes type-specific UI to the matching +/// `*Detail` widget under `detail/`. +class EventDetailPanel extends ConsumerStatefulWidget { + final UnifiedEvent event; + final VoidCallback onClose; + + const EventDetailPanel({ + super.key, + required this.event, + required this.onClose, + }); + + @override + ConsumerState createState() => _EventDetailPanelState(); +} + +class _EventDetailPanelState extends ConsumerState { + // ignore: unused_field + int _currentTabIndex = 0; + bool _currentJsonMode = false; + final _contentKey = GlobalKey(); + + Future _captureAndSave(Widget screenshotWidget, + {String? fileName}) async { + try { + // Show capture flash animation + _showCaptureFlash(); + + final overlayKey = GlobalKey(); + final theme = Theme.of(context); + + late OverlayEntry overlayEntry; + overlayEntry = OverlayEntry( + builder: (_) => Positioned( + left: -10000, + top: 0, + child: RepaintBoundary( + key: overlayKey, + child: Theme( + data: theme, + child: Material( + child: SizedBox( + width: 600, + child: screenshotWidget, + ), + ), + ), + ), + ), + ); + + Overlay.of(context).insert(overlayEntry); + await Future.delayed(const Duration(milliseconds: 600)); + + final boundary = overlayKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; + if (boundary == null) { + overlayEntry.remove(); + return; + } + + final image = await boundary.toImage(pixelRatio: 3.0); + final byteData = + await image.toByteData(format: ui.ImageByteFormat.png); + overlayEntry.remove(); + + if (byteData == null) return; + + final pngBytes = byteData.buffer.asUint8List(); + + final baseName = (fileName == null || fileName.isEmpty) + ? 'dcmt_${DateTime.now().millisecondsSinceEpoch}' + : fileName; + final withExt = + baseName.endsWith('.png') ? baseName : '$baseName.png'; + + final location = await getSaveLocation( + suggestedName: withExt, + acceptedTypeGroups: [ + const XTypeGroup(label: 'PNG Image', extensions: ['png']), + ], + ); + + if (location == null) return; + + // Force saved file's name to withExt regardless of what OS returns. + final savedPath = _ensureFilename(location.path, withExt); + final xfile = XFile.fromData( + pngBytes, + mimeType: 'image/png', + name: withExt, + length: pngBytes.lengthInBytes, + ); + await xfile.saveTo(savedPath); + + if (mounted) showScreenshotSavedToast(context, filePath: savedPath); + } catch (e) { + if (mounted) _showErrorToast('$e'); + } + } + + String _ensureFilename(String path, String desiredName) { + final sep = path.contains(r'\') ? r'\' : '/'; + final last = path.lastIndexOf(sep); + if (last == -1) return '$path$sep$desiredName'; + return '${path.substring(0, last + 1)}$desiredName'; + } + + void _showCaptureFlash() { + final overlay = Overlay.of(context); + late OverlayEntry flashEntry; + flashEntry = OverlayEntry( + builder: (_) => TweenAnimationBuilder( + tween: Tween(begin: 0.35, end: 0.0), + duration: const Duration(milliseconds: 350), + curve: Curves.easeOut, + onEnd: () { + if (flashEntry.mounted) flashEntry.remove(); + }, + builder: (context, value, _) => IgnorePointer( + child: Container( + color: Colors.white.withValues(alpha: value), + ), + ), + ), + ); + overlay.insert(flashEntry); + } + + void _showSavedToast(String path) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final overlay = Overlay.of(context); + late OverlayEntry entry; + entry = OverlayEntry( + builder: (_) => Positioned( + bottom: 32, + left: 0, + right: 0, + child: Center( + child: TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: const Duration(milliseconds: 400), + curve: Curves.easeOutCubic, + builder: (context, value, child) => Opacity( + opacity: value, + child: Transform.translate( + offset: Offset(0, 20 * (1 - value)), + child: Transform.scale( + scale: 0.92 + 0.08 * value, + child: child, + ), + ), + ), + child: Material( + color: Colors.transparent, + child: Container( + constraints: const BoxConstraints(maxWidth: 380), + decoration: BoxDecoration( + color: isDark + ? const Color(0xFF131A24) + : const Color(0xFFFFFFFF), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 32, + offset: const Offset(0, 8), + ), + if (isDark) + BoxShadow( + color: ColorTokens.success.withValues(alpha: 0.08), + blurRadius: 40, + spreadRadius: -4, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 3, + margin: const EdgeInsets.symmetric(horizontal: 40), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + ColorTokens.success.withValues(alpha: 0.0), + ColorTokens.success, + ColorTokens.success.withValues(alpha: 0.0), + ], + ), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(16), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 12, 14), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + ColorTokens.success.withValues(alpha: 0.2), + ColorTokens.success.withValues(alpha: 0.08), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: ColorTokens.success + .withValues(alpha: 0.2), + ), + ), + child: const Icon( + LucideIcons.checkCheck, + size: 18, + color: ColorTokens.success, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + TextComponent( + S.of(context).screenshotSaved, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isDark + ? ColorTokens.lightBackground + : const Color(0xFF1E293B), + letterSpacing: -0.2, + ), + ), + const SizedBox(height: 2), + TextComponent( + path.split('/').last, + style: TextStyle( + fontSize: 11, + fontFamily: AppConstants.monoFontFamily, + color: isDark + ? Colors.grey[500] + : Colors.grey[600], + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + PressableButton( + onTap: () { + entry.remove(); + Process.run('open', ['-R', path]); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 7), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: isDark + ? [ + const Color(0xFF1A2332), + const Color(0xFF1E2A3A), + ] + : [ + const Color(0xFFF0F4F8), + const Color(0xFFE8EDF2), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.1) + : Colors.black.withValues(alpha: 0.08), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.folderOpen, + size: 13, + color: isDark + ? ColorTokens.lightBackground + : const Color(0xFF374151), + ), + const SizedBox(width: 6), + TextComponent( + S.of(context).reveal, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: isDark + ? ColorTokens.lightBackground + : const Color(0xFF374151), + ), + ), + ], + ), + ), + ), + const SizedBox(width: 4), + PressableButton( + onTap: () => entry.remove(), + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: isDark + ? Colors.white.withValues(alpha: 0.04) + : Colors.black.withValues(alpha: 0.04), + ), + child: Icon(LucideIcons.x, + size: 13, + color: isDark + ? Colors.grey[600] + : Colors.grey[400]), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + overlay.insert(entry); + Future.delayed(const Duration(seconds: 5), () { + if (entry.mounted) entry.remove(); + }); + } + + void _showErrorToast(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: TextComponent('Screenshot failed: $message'), + duration: const Duration(seconds: 2), + ), + ); + } + + Future _takeFullScreenshot() async { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final capture = _buildScreenshotWidget(theme, isDark); + final fileName = _buildEventScreenshotName('_full'); + await _captureAndSave(capture, fileName: fileName); + } + + /// Builds a descriptive file name for event screenshots: + /// `___.png` + String _buildEventScreenshotName(String suffix) { + final event = widget.event; + final type = event.type.name; + + String subject = event.title; + if (event.rawData is StorageEntry) { + subject = (event.rawData as StorageEntry).key; + } else if (event.rawData is NetworkEntry) { + final url = (event.rawData as NetworkEntry).url; + try { + subject = Uri.parse(url).path.isEmpty ? url : Uri.parse(url).path; + } catch (_) { + subject = url; + } + } else if (event.rawData is LogEntry) { + final tag = (event.rawData as LogEntry).tag; + if (tag != null && tag.isNotEmpty) subject = tag; + } else if (event.rawData is StateChange) { + final sc = event.rawData as StateChange; + subject = sc.actionName.isNotEmpty + ? sc.actionName + : sc.stateManagerType; + } + + return buildRichScreenshotName( + type: type, + subject: subject, + suffix: suffix, + ); + } + + Future _takeTabScreenshot() async { + await _captureLiveContent(); + } + + /// Captures the currently visible content (with user's expand/collapse state). + Future _captureLiveContent() async { + try { + _showCaptureFlash(); + + final boundary = _contentKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; + if (boundary == null) return; + + final image = await boundary.toImage(pixelRatio: 3.0); + final byteData = + await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) return; + + final pngBytes = byteData.buffer.asUint8List(); + final fileName = + 'dcmt_${DateTime.now().millisecondsSinceEpoch}.png'; + final location = await getSaveLocation( + suggestedName: fileName, + acceptedTypeGroups: [ + const XTypeGroup(label: 'PNG Image', extensions: ['png']), + ], + ); + if (location == null) return; + + final file = File(location.path); + await file.writeAsBytes(pngBytes); + if (mounted) _showSavedToast(file.path); + } catch (e) { + if (mounted) _showErrorToast('$e'); + } + } + + /// Builds the full detail widget for screenshot (no scroll constraints). + Widget _buildScreenshotWidget(ThemeData theme, bool isDark) { + final event = widget.event; + final time = DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( + DateTime.fromMillisecondsSinceEpoch(event.timestamp), + ); + + Color typeColor; + IconData typeIcon; + String typeLabel; + switch (event.type) { + case EventType.log: + typeColor = ColorTokens.logInfo; + typeIcon = LucideIcons.terminal; + typeLabel = S.of(context).logDetail; + break; + case EventType.network: + typeColor = ColorTokens.success; + typeIcon = LucideIcons.globe; + typeLabel = S.of(context).networkDetail; + break; + case EventType.state: + typeColor = ColorTokens.secondary; + typeIcon = LucideIcons.layers; + typeLabel = S.of(context).stateDetail; + break; + case EventType.storage: + typeColor = ColorTokens.warning; + typeIcon = LucideIcons.database; + typeLabel = S.of(context).storageDetail; + break; + case EventType.display: + typeColor = const Color(0xFF9B59B6); + typeIcon = LucideIcons.monitor; + typeLabel = S.of(context).displayDetail; + break; + case EventType.asyncOp: + typeColor = const Color(0xFFE67E22); + typeIcon = LucideIcons.zap; + typeLabel = S.of(context).asyncOperation; + break; + case EventType.error: + typeColor = Colors.red; + typeIcon = LucideIcons.alertTriangle; + typeLabel = S.of(context).errorDetail; + break; + } + + return Container( + color: isDark ? ColorTokens.darkSurface : ColorTokens.lightSurface, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + color: isDark ? ColorTokens.darkBackground : Colors.white, + ), + child: Row( + children: [ + Icon(typeIcon, size: 14, color: typeColor), + const SizedBox(width: 8), + TextComponent( + typeLabel, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: typeColor, + ), + ), + const Spacer(), + TextComponent( + time, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 10, + color: Colors.grey[500], + ), + ), + ], + ), + ), + const Divider(height: 1), + _buildScreenshotContent(isDark), + ], + ), + ); + } + + /// Content for screenshot — no SingleChildScrollView, no Expanded. + /// Tabbed views are rendered as stacked sections. + Widget _buildScreenshotContent(bool isDark) { + switch (widget.event.type) { + case EventType.log: + if (widget.event.rawData is LogEntry) { + return _logScreenshot(widget.event.rawData as LogEntry, isDark); + } + return _fallbackScreenshot(widget.event, isDark); + case EventType.network: + if (widget.event.rawData is NetworkEntry) { + return _networkScreenshot( + widget.event.rawData as NetworkEntry, isDark); + } + return _fallbackScreenshot(widget.event, isDark); + case EventType.state: + if (widget.event.rawData is StateChange) { + return _stateScreenshot( + widget.event.rawData as StateChange, isDark); + } + return _fallbackScreenshot(widget.event, isDark); + case EventType.storage: + if (widget.event.rawData is StorageEntry) { + return _storageScreenshot( + widget.event.rawData as StorageEntry, isDark); + } + return _fallbackScreenshot(widget.event, isDark); + case EventType.display: + case EventType.asyncOp: + case EventType.error: + return _fallbackScreenshot(widget.event, isDark); + } + } + + Widget _logScreenshot(LogEntry entry, bool isDark) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + LogLevelBadge(level: entry.level.name), + if (entry.tag != null) ...[ + const SizedBox(width: 8), + TagChip(entry.tag!), + ], + ]), + const SizedBox(height: 16), + const SectionLabel('Message'), + const SizedBox(height: 6), + CodeBlock(text: entry.message, isDark: isDark), + if (entry.metadata != null && entry.metadata!.isNotEmpty) ...[ + const SizedBox(height: 16), + const SectionLabel('Metadata'), + const SizedBox(height: 6), + JsonViewer(data: entry.metadata, initiallyExpanded: true), + ], + if (entry.stackTrace != null) ...[ + const SizedBox(height: 16), + const SectionLabel('Stack Trace'), + const SizedBox(height: 6), + ErrorBlock(text: entry.stackTrace!, isDark: isDark), + ], + ], + ), + ); + } + + Widget _networkScreenshot(NetworkEntry entry, bool isDark) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark ? ColorTokens.darkBackground : Colors.white, + border: Border( + bottom: BorderSide( + color: isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.06), + ), + ), + ), + child: Row( + children: [ + HttpMethodBadge(method: entry.method), + const SizedBox(width: 8), + if (entry.isComplete) ...[ + StatusBadge(statusCode: entry.statusCode), + const SizedBox(width: 8), + ], + Expanded( + child: TextComponent( + entry.url, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: + isDark ? ColorTokens.lightBackground : Colors.black87, + ), + ), + ), + ], + ), + ), + if (entry.duration != null) + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TimingBar(duration: entry.duration!), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionLabel('REQUEST HEADERS'), + const SizedBox(height: 8), + HeaderTable(headers: entry.requestHeaders, isScreenshot: true), + const SizedBox(height: 20), + const SectionLabel('RESPONSE HEADERS'), + const SizedBox(height: 8), + HeaderTable(headers: entry.responseHeaders, isScreenshot: true), + ], + ), + ), + const Divider(height: 1), + if (entry.requestBody != null) + Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionLabel('REQUEST BODY'), + const SizedBox(height: 8), + if (_currentJsonMode || + !(entry.requestBody is Map || entry.requestBody is List)) + JsonPrettyViewer(data: entry.requestBody) + else + JsonViewer( + data: entry.requestBody, initiallyExpanded: true), + ], + ), + ), + if (entry.requestBody != null) const Divider(height: 1), + if (entry.responseBody != null) + Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionLabel('RESPONSE BODY'), + const SizedBox(height: 8), + if (_currentJsonMode || + !(entry.responseBody is Map || entry.responseBody is List)) + JsonPrettyViewer(data: entry.responseBody) + else + JsonViewer( + data: entry.responseBody, initiallyExpanded: true), + ], + ), + ), + if (entry.responseBody != null) const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionLabel('TIMING'), + const SizedBox(height: 8), + InfoRow( + 'Start Time', + DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( + DateTime.fromMillisecondsSinceEpoch(entry.startTime), + ), + ), + if (entry.endTime != null) + InfoRow( + 'End Time', + DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( + DateTime.fromMillisecondsSinceEpoch(entry.endTime!), + ), + ), + if (entry.duration != null) + InfoRow('Duration', formatDuration(entry.duration!)), + if (entry.error != null) ...[ + const SizedBox(height: 12), + const SectionLabel('Error'), + const SizedBox(height: 6), + ErrorBlock(text: entry.error!, isDark: isDark), + ], + ], + ), + ), + ], + ); + } + + Widget _stateScreenshot(StateChange entry, bool isDark) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + children: [ + TagChip(entry.stateManagerType, + color: ColorTokens.secondary), + const SizedBox(width: 8), + Expanded( + child: TextComponent( + entry.actionName, + style: const TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + const Divider(height: 1), + if (entry.diff.isNotEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionLabel('DIFF'), + const SizedBox(height: 8), + ...entry.diff.map((d) => DiffRow(diff: d)), + ], + ), + ), + if (entry.diff.isNotEmpty) const Divider(height: 1), + if (entry.previousState.isNotEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionLabel('PREVIOUS STATE'), + const SizedBox(height: 8), + JsonViewer( + data: entry.previousState, initiallyExpanded: true), + ], + ), + ), + if (entry.previousState.isNotEmpty) const Divider(height: 1), + if (entry.nextState.isNotEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionLabel('NEXT STATE'), + const SizedBox(height: 8), + JsonViewer( + data: entry.nextState, initiallyExpanded: true), + ], + ), + ), + ], + ); + } + + Widget _storageScreenshot(StorageEntry entry, bool isDark) { + Color opColorFor(StorageEntry e) { + switch (e.operation.toLowerCase()) { + case 'write': + return const Color(0xFF34D399); + case 'read': + return const Color(0xFF60A5FA); + case 'delete': + case 'clear': + return const Color(0xFFF87171); + default: + return const Color(0xFFFBBF24); + } + } + + final devices = ProviderScope.containerOf(context, listen: false) + .read(connectedDevicesProvider); + final platform = devices + .where((d) => d.deviceId == entry.deviceId) + .map((d) => d.platform) + .firstOrNull ?? + 'react_native'; + final codeLang = CodeGenerator.langForPlatform(platform); + final codeLabel = CodeGenerator.labelFor(codeLang); + + final mode = ProviderScope.containerOf(context, listen: false) + .read(bodyViewModeProvider); + + final labelColor = isDark ? Colors.grey[500] : Colors.grey[600]; + final dividerColor = isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06); + + String formatShape() { + final v = entry.value; + if (v == null) return 'null'; + if (v is Map) return 'Map · ${v.length} ${v.length == 1 ? "key" : "keys"}'; + if (v is List) return 'List · ${v.length} ${v.length == 1 ? "item" : "items"}'; + if (v is String) { + if (v.isEmpty) return 'String · empty'; + final t = v.trim(); + if ((t.startsWith('{') && t.endsWith('}')) || + (t.startsWith('[') && t.endsWith(']'))) { + return 'String · JSON-shaped'; + } + return 'String'; + } + return v.runtimeType.toString(); + } + + String formatSize() { + final raw = entry.value is String + ? entry.value as String + : const JsonEncoder.withIndent(' ').convert(entry.value); + return AppConstants.formatBytes(raw.length); + } + + dynamic parseJson() { + final v = entry.value; + if (v is! String) return null; + try { + final p = jsonDecode(v); + if (p is Map || p is List) return p; + } catch (_) {} + return null; + } + + dynamic displayValue() { + final v = entry.value; + if (v is Map || v is List) return v; + return parseJson() ?? v; + } + + bool isJsonLike() { + final v = entry.value; + if (v is Map || v is List) return true; + return parseJson() != null; + } + + Widget buildValueWidget() { + if (!isJsonLike()) { + return CodeBlock(text: '${entry.value}', isDark: isDark); + } + final value = displayValue(); + return switch (mode) { + BodyViewMode.tree => + JsonViewer(data: value, initiallyExpanded: true), + BodyViewMode.json => JsonPrettyViewer(data: value), + BodyViewMode.code => CodeViewer( + generated: CodeGenerator.generate(value, codeLang), + lang: codeLang, + languageLabel: codeLabel, + ), + }; + } + + final monoPrimary = TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 13, + height: 1.5, + color: isDark ? const Color(0xFFE8E8E8) : const Color(0xFF1A1A1A), + ); + final monoSecondary = TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + height: 1.5, + color: labelColor, + ); + final metaLabelStyle = TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + color: labelColor, + ); + + Widget metaCell(String label, String value, TextStyle valueStyle, + {bool monospace = false}) => + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextComponent(label, style: metaLabelStyle), + const SizedBox(height: 4), + TextComponent( + value, + style: monospace + ? valueStyle.copyWith(fontFamily: AppConstants.monoFontFamily) + : valueStyle, + ), + ], + ); + + return Container( + color: isDark ? ColorTokens.darkSurface : ColorTokens.lightSurface, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + OpBadge(label: entry.operation, color: opColorFor(entry)), + const SizedBox(width: 8), + TypeBadge(label: entry.storageType.name), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextComponent('METADATA', style: metaLabelStyle), + const SizedBox(height: 10), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: metaCell('SHAPE', formatShape(), monoPrimary), + ), + const SizedBox(width: 12), + Expanded( + child: metaCell('SIZE', formatSize(), monoPrimary), + ), + ], + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: metaCell( + 'DEVICE', entry.deviceId, monoSecondary, + monospace: true), + ), + const SizedBox(width: 12), + Expanded( + child: metaCell( + 'CAPTURED', + DateFormat('HH:mm:ss.SSS').format( + DateTime.fromMillisecondsSinceEpoch( + entry.timestamp), + ), + monoPrimary, + ), + ), + ], + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 0), + child: Container(height: 1, color: dividerColor), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionLabel('Key'), + const SizedBox(height: 6), + CodeBlock(text: entry.key, isDark: isDark), + ], + ), + ), + if (entry.value != null) ...[ + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionLabel('Value'), + const SizedBox(height: 6), + buildValueWidget(), + ], + ), + ), + ], + ], + ), + ); + } + + Widget _fallbackScreenshot(UnifiedEvent event, bool isDark) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionLabel('Title'), + const SizedBox(height: 6), + CodeBlock(text: event.title, isDark: isDark), + const SizedBox(height: 16), + const SectionLabel('Details'), + const SizedBox(height: 6), + CodeBlock(text: event.subtitle, isDark: isDark), + if (event.rawData != null) ...[ + const SizedBox(height: 16), + const SectionLabel('Raw Data'), + const SizedBox(height: 6), + if (event.rawData is Map || event.rawData is List) + JsonViewer(data: event.rawData, initiallyExpanded: true) + else + CodeBlock(text: '${event.rawData}', isDark: isDark), + ], + ], + ), + ); + } + + Widget _buildTabScreenshotWidget( + ThemeData theme, bool isDark, int tabIndex) { + final event = widget.event; + final time = DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( + DateTime.fromMillisecondsSinceEpoch(event.timestamp), + ); + + final (typeColor, typeIcon, typeLabel) = + DetailHeader.staticTypeDetails(event.type); + + final header = Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + color: isDark ? ColorTokens.darkBackground : Colors.white, + ), + child: Row( + children: [ + Icon(typeIcon, size: 14, color: typeColor), + const SizedBox(width: 8), + TextComponent(typeLabel, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: typeColor)), + const SizedBox(width: 10), + TextComponent(time, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 10, + color: Colors.grey[500])), + ], + ), + ); + + Widget tabContent; + if (event.type == EventType.network && event.rawData is NetworkEntry) { + final entry = event.rawData as NetworkEntry; + final urlBar = Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark ? ColorTokens.darkBackground : Colors.white, + border: Border( + bottom: BorderSide( + color: isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.06), + ), + ), + ), + child: Row( + children: [ + HttpMethodBadge(method: entry.method), + const SizedBox(width: 8), + if (entry.isComplete) ...[ + StatusBadge(statusCode: entry.statusCode), + const SizedBox(width: 8), + ], + Expanded( + child: TextComponent(entry.url, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: isDark + ? ColorTokens.lightBackground + : Colors.black87)), + ), + ], + ), + ); + final timingBar = entry.duration != null + ? Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: TimingBar(duration: entry.duration!), + ) + : null; + + const tabNames = ['Headers', 'Request', 'Response', 'Timing']; + final tabLabel = Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SectionLabel(tabNames[tabIndex]), + ); + + Widget body; + switch (tabIndex) { + case 0: + body = Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionLabel('Request Headers'), + const SizedBox(height: 8), + HeaderTable(headers: entry.requestHeaders, isScreenshot: true), + const SizedBox(height: 20), + const SectionLabel('Response Headers'), + const SizedBox(height: 8), + HeaderTable(headers: entry.responseHeaders, isScreenshot: true), + ], + ), + ); + break; + case 1: + body = _buildBodyScreenshot( + entry.requestBody, 'Request Body', isDark); + break; + case 2: + body = _buildBodyScreenshot( + entry.responseBody, 'Response Body', isDark); + break; + case 3: + default: + body = Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InfoRow( + 'Start Time', + DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( + DateTime.fromMillisecondsSinceEpoch(entry.startTime), + ), + ), + if (entry.endTime != null) + InfoRow( + 'End Time', + DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( + DateTime.fromMillisecondsSinceEpoch(entry.endTime!), + ), + ), + if (entry.duration != null) + InfoRow('Duration', formatDuration(entry.duration!)), + if (entry.error != null) ...[ + const SizedBox(height: 12), + const SectionLabel('Error'), + const SizedBox(height: 6), + ErrorBlock(text: entry.error!, isDark: isDark), + ], + ], + ), + ); + break; + } + + tabContent = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + urlBar, + if (timingBar != null) timingBar, + const Divider(height: 1), + tabLabel, + body, + ], + ); + } else if (event.type == EventType.state && + event.rawData is StateChange) { + final entry = event.rawData as StateChange; + const tabNames = ['Diff', 'Previous', 'Next']; + Widget body; + switch (tabIndex) { + case 0: + body = entry.diff.isEmpty + ? const Padding( + padding: EdgeInsets.all(16), + child: TextComponent('No diff')) + : Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: + entry.diff.map((d) => DiffRow(diff: d)).toList(), + ), + ); + break; + case 1: + body = _buildBodyScreenshot( + entry.previousState.isEmpty ? null : entry.previousState, + 'Previous State', + isDark); + break; + case 2: + default: + body = _buildBodyScreenshot( + entry.nextState.isEmpty ? null : entry.nextState, + 'Next State', + isDark); + break; + } + tabContent = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SectionLabel(tabNames[tabIndex]), + ), + body, + ], + ); + } else { + tabContent = _buildScreenshotContent(isDark); + } + + return Container( + color: isDark ? ColorTokens.darkSurface : ColorTokens.lightSurface, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + header, + const Divider(height: 1), + tabContent, + ], + ), + ); + } + + Widget _buildBodyScreenshot(dynamic body, String label, bool isDark) { + if (body == null) { + return Padding( + padding: const EdgeInsets.all(16), + child: TextComponent('No $label', + style: TextStyle(color: Colors.grey[500], fontSize: 12)), + ); + } + dynamic parsed = body; + if (parsed is String) { + try { + parsed = jsonDecode(parsed); + } catch (_) {} + } + final useJson = _currentJsonMode; + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionLabel(label), + const SizedBox(height: 8), + if (useJson || !(parsed is Map || parsed is List)) + JsonPrettyViewer(data: parsed) + else + JsonViewer(data: parsed, initiallyExpanded: true), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Container( + color: isDark ? ColorTokens.darkSurface : ColorTokens.lightSurface, + child: Column( + children: [ + DetailHeader( + event: widget.event, + onClose: widget.onClose, + onFullScreenshot: _takeFullScreenshot, + onTabScreenshot: _takeTabScreenshot, + hasMultipleTabs: widget.event.type == EventType.network || + widget.event.type == EventType.state, + ), + const Divider(height: 1), + Expanded( + child: RepaintBoundary( + key: _contentKey, + child: _buildContent(), + ), + ), + ], + ), + ); + } + + Widget _buildContent() { + switch (widget.event.type) { + case EventType.log: + if (widget.event.rawData is LogEntry) { + return LogDetail(entry: widget.event.rawData as LogEntry); + } + return FallbackDetail(event: widget.event); + case EventType.network: + if (widget.event.rawData is NetworkEntry) { + return NetworkDetail( + entry: widget.event.rawData as NetworkEntry, + onTabChanged: (i) => _currentTabIndex = i, + onJsonModeChanged: (v) => _currentJsonMode = v, + ); + } + return FallbackDetail(event: widget.event); + case EventType.state: + if (widget.event.rawData is StateChange) { + return StateDetail( + entry: widget.event.rawData as StateChange, + onTabChanged: (i) => _currentTabIndex = i, + onJsonModeChanged: (v) => _currentJsonMode = v, + ); + } + return FallbackDetail(event: widget.event); + case EventType.storage: + if (widget.event.rawData is StorageEntry) { + return StorageDetail( + entry: widget.event.rawData as StorageEntry, + ); + } + return FallbackDetail(event: widget.event); + case EventType.display: + case EventType.asyncOp: + case EventType.error: + return FallbackDetail(event: widget.event); + } + } +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/detail/fallback_detail.dart b/lib/features/all_events/presentation/detail/fallback_detail.dart new file mode 100644 index 0000000..8a9b0eb --- /dev/null +++ b/lib/features/all_events/presentation/detail/fallback_detail.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +import '../../../../components/viewers/json_viewer.dart'; +import '../../../../core/utils/smooth_scroll_controller.dart'; +import '../../provider/all_events_provider.dart'; +import '../shared/code_block.dart'; +import '../shared/section_label.dart'; + +/// Default detail view used when an event has no dedicated panel (display, +/// async, generic error). Renders title, subtitle, and — when present — +/// the raw `event.rawData` payload as a JSON tree. +class FallbackDetail extends StatefulWidget { + final UnifiedEvent event; + + const FallbackDetail({super.key, required this.event}); + + @override + State createState() => _FallbackDetailState(); +} + +class _FallbackDetailState extends State { + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final event = widget.event; + final isDark = Theme.of(context).brightness == Brightness.dark; + + return SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionLabel('Title'), + const SizedBox(height: 6), + CodeBlock(text: event.title, isDark: isDark), + const SizedBox(height: 16), + const SectionLabel('Details'), + const SizedBox(height: 6), + CodeBlock(text: event.subtitle, isDark: isDark), + if (event.rawData != null) ...[ + const SizedBox(height: 16), + const SectionLabel('Raw Data'), + const SizedBox(height: 6), + if (event.rawData is Map || event.rawData is List) + JsonViewer(data: event.rawData, initiallyExpanded: true) + else + CodeBlock(text: '${event.rawData}', isDark: isDark), + ], + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/detail/icon_action.dart b/lib/features/all_events/presentation/detail/icon_action.dart new file mode 100644 index 0000000..a6df078 --- /dev/null +++ b/lib/features/all_events/presentation/detail/icon_action.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +/// Subtle icon button with hover-state feedback. Replaces the hard-edged +/// `_CopyButton` for a calmer, more premium look (MOTION_INTENSITY 6). +class IconAction extends StatefulWidget { + final IconData icon; + final String tooltip; + final VoidCallback onTap; + + const IconAction({ + super.key, + required this.icon, + required this.tooltip, + required this.onTap, + }); + + @override + State createState() => _IconActionState(); +} + +class _IconActionState extends State { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Tooltip( + message: widget.tooltip, + child: MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: widget.onTap, + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + curve: const Cubic(0.16, 1, 0.3, 1), + width: 28, + height: 28, + decoration: BoxDecoration( + color: _hovered + ? (isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.05)) + : Colors.transparent, + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + widget.icon, + size: 13, + color: isDark ? const Color(0xFF8B8B8B) : const Color(0xFF6B6B6B), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/detail/log_detail.dart b/lib/features/all_events/presentation/detail/log_detail.dart new file mode 100644 index 0000000..fb3108c --- /dev/null +++ b/lib/features/all_events/presentation/detail/log_detail.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../components/misc/status_badge.dart'; +import '../../../../core/utils/smooth_scroll_controller.dart'; +import '../../../../core/utils/toast_utils.dart'; +import '../../../../models/log/log_entry.dart'; +import '../shared/code_block.dart'; +import '../shared/copy_button.dart'; +import '../shared/error_block.dart'; +import '../shared/section_label.dart'; +import '../shared/tag_chip.dart'; +import 'log_inline_json.dart'; + +/// Right-pane detail for log events. +/// +/// Renders the level badge + optional tag chip + copy button in the header +/// row, then either a [CodeBlock] (for plain text) or a [LogInlineJson] +/// (for embedded JSON), and finally the optional metadata block and stack +/// trace. +class LogDetail extends StatefulWidget { + final LogEntry entry; + + const LogDetail({super.key, required this.entry}); + + @override + State createState() => _LogDetailState(); +} + +class _LogDetailState extends State { + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + /// Try to extract JSON from the log message. + /// Returns (prefix, parsedJson) or null if no JSON found. + static (String, dynamic)? _extractJson(String message) { + // Try full message first + try { + final parsed = jsonDecode(message.trim()); + if (parsed is Map || parsed is List) return ('', parsed); + } catch (_) {} + + // Find all { or [ positions and try each + for (var i = 0; i < message.length; i++) { + final ch = message[i]; + if (ch != '{' && ch != '[') continue; + final jsonStr = message.substring(i).trim(); + try { + final parsed = jsonDecode(jsonStr); + if (parsed is Map || parsed is List) { + final prefix = message.substring(0, i).trim(); + return (prefix, parsed); + } + } catch (_) {} + } + return null; + } + + @override + Widget build(BuildContext context) { + final entry = widget.entry; + final isDark = Theme.of(context).brightness == Brightness.dark; + final jsonResult = _extractJson(entry.message); + + return SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + LogLevelBadge(level: entry.level.name), + if (entry.tag != null) ...[ + const SizedBox(width: 8), + TagChip(entry.tag!), + ], + const Spacer(), + CopyButton( + tooltip: 'Copy message', + onTap: () => _copyText(context, entry.message, 'Message'), + ), + ], + ), + const SizedBox(height: 16), + const SectionLabel('Message'), + const SizedBox(height: 6), + if (jsonResult != null) ...[ + if (jsonResult.$1.isNotEmpty) ...[ + CodeBlock(text: jsonResult.$1, isDark: isDark), + const SizedBox(height: 10), + ], + LogInlineJson(data: jsonResult.$2, label: 'Data'), + ] else + CodeBlock(text: entry.message, isDark: isDark), + if (entry.metadata != null && entry.metadata!.isNotEmpty) ...[ + const SizedBox(height: 16), + LogInlineJson(data: entry.metadata, label: 'Metadata'), + ], + if (entry.stackTrace != null) ...[ + const SizedBox(height: 16), + Row( + children: [ + const SectionLabel('Stack Trace'), + const Spacer(), + CopyButton( + tooltip: 'Copy stack trace', + onTap: () => + _copyText(context, entry.stackTrace!, 'Stack trace'), + ), + ], + ), + const SizedBox(height: 6), + ErrorBlock(text: entry.stackTrace!, isDark: isDark), + ], + ], + ), + ); + } +} + +void _copyText(BuildContext context, String text, String label) { + Clipboard.setData(ClipboardData(text: text)); + showCopiedToast(context, label: '$label copied'); +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/detail/log_inline_json.dart b/lib/features/all_events/presentation/detail/log_inline_json.dart new file mode 100644 index 0000000..2a55294 --- /dev/null +++ b/lib/features/all_events/presentation/detail/log_inline_json.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +import '../../../../components/viewers/json_viewer.dart'; + +/// Inline JSON viewer used inside [LogDetail] (and elsewhere) to render a +/// pretty-printed tree of an embedded payload under the message. +class LogInlineJson extends StatelessWidget { + final dynamic data; + final String label; + + const LogInlineJson({super.key, required this.data, required this.label}); + + @override + Widget build(BuildContext context) { + return JsonViewer(data: data, initiallyExpanded: true); + } +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/detail/meta_row.dart b/lib/features/all_events/presentation/detail/meta_row.dart new file mode 100644 index 0000000..c9d4df4 --- /dev/null +++ b/lib/features/all_events/presentation/detail/meta_row.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +/// Compact key/value row for the storage metadata footer. Label sits in +/// a fixed 84px gutter; value is selectable monospace. +class MetaRow extends StatelessWidget { + final String label; + final String value; + final TextStyle monoStyle; + final Color labelColor; + + const MetaRow({ + super.key, + required this.label, + required this.value, + required this.monoStyle, + required this.labelColor, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 84, + child: Text( + label, + style: TextStyle( + fontSize: 11, + color: labelColor, + letterSpacing: 0.3, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: SelectableText( + value, + style: monoStyle, + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/detail/network_detail.dart b/lib/features/all_events/presentation/detail/network_detail.dart new file mode 100644 index 0000000..dbd3633 --- /dev/null +++ b/lib/features/all_events/presentation/detail/network_detail.dart @@ -0,0 +1,363 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../components/misc/status_badge.dart'; +import '../../../../components/text/text_component.dart'; +import '../../../../components/viewers/json_viewer.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../../../core/theme/theme_provider.dart'; +import '../../../../core/utils/duration_format.dart'; +import '../../../../core/utils/toast_utils.dart'; +import '../../../../models/network/network_entry.dart'; +import '../shared/body_view.dart'; +import '../shared/detail_tab_bar.dart'; +import '../network/headers_view.dart'; +import '../network/timing_view.dart'; +import '../shared/copy_button.dart'; + +/// Right-pane detail for network events. Four tabs: +/// +/// - **Headers** — request + response headers ([HeadersView]). +/// - **Request** — request body ([BodyView]). +/// - **Response** — response body ([BodyView]). +/// - **Timing** — duration hero + timeline ([TimingView]). +class NetworkDetail extends ConsumerStatefulWidget { + final NetworkEntry entry; + final ValueChanged? onTabChanged; + final ValueChanged? onJsonModeChanged; + + const NetworkDetail({ + super.key, + required this.entry, + this.onTabChanged, + this.onJsonModeChanged, + }); + + @override + ConsumerState createState() => _NetworkDetailState(); +} + +class _NetworkDetailState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = _makeController(); + _tabController.addListener(_onTabIndexChange); + } + + TabController _makeController([int initialIndex = 0]) { + return TabController( + length: 4, + vsync: this, + animationDuration: ref.read(tabAnimationProvider), + initialIndex: initialIndex, + ); + } + + void _onTabIndexChange() { + if (!_tabController.indexIsChanging) { + widget.onTabChanged?.call(_tabController.index); + } + } + + void _rebuildController() { + final oldIndex = _tabController.index; + _tabController.removeListener(_onTabIndexChange); + _tabController.dispose(); + _tabController = _makeController(oldIndex); + _tabController.addListener(_onTabIndexChange); + setState(() {}); + } + + @override + void dispose() { + _tabController.removeListener(_onTabIndexChange); + _tabController.dispose(); + super.dispose(); + } + + NetworkEntry get entry => widget.entry; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + ref.listen(tabAnimationProvider, (prev, next) { + if (prev != next) _rebuildController(); + }); + + return Column( + children: [ + // URL bar + actions + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark ? ColorTokens.darkBackground : Colors.white, + border: Border( + bottom: BorderSide( + color: isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.06), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + HttpMethodBadge(method: entry.method), + const SizedBox(width: 8), + if (entry.isComplete) ...[ + StatusBadge(statusCode: entry.statusCode), + const SizedBox(width: 8), + ] else ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: ColorTokens.warning.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: ColorTokens.warning.withValues(alpha: 0.25), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 10, + height: 10, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: ColorTokens.warning, + ), + ), + const SizedBox(width: 5), + TextComponent( + 'In Progress...', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: ColorTokens.warning, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + ], + Expanded( + child: Tooltip( + message: entry.url, + waitDuration: const Duration(milliseconds: 300), + child: TextComponent( + entry.url, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: isDark + ? ColorTokens.lightBackground + : Colors.black87, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + // Action buttons row + Row( + children: [ + if (entry.duration != null) ...[ + TimingBar(duration: entry.duration!), + const Spacer(), + ] else + const Spacer(), + CopyButton( + tooltip: 'Copy URL', + icon: LucideIcons.link, + onTap: () => + _copyText(context, entry.url, 'URL'), + ), + const SizedBox(width: 4), + CopyButton( + tooltip: 'Copy Path', + icon: LucideIcons.route, + onTap: () { + try { + final uri = Uri.parse(entry.url); + final path = uri.path.isNotEmpty ? uri.path : entry.url; + _copyText(context, path, 'Path'); + } catch (_) { + _copyText(context, entry.url, 'Path'); + } + }, + ), + const SizedBox(width: 4), + CopyButton( + tooltip: 'Copy as cURL', + icon: LucideIcons.terminal, + onTap: () => _copyText( + context, _buildCurl(entry), 'cURL'), + ), + const SizedBox(width: 4), + CopyButton( + tooltip: 'Copy request', + icon: LucideIcons.upload, + onTap: () { + final body = entry.requestBody; + final text = body is String + ? body + : (body != null + ? const JsonEncoder.withIndent(' ') + .convert(body) + : ''); + _copyText(context, text, 'Request'); + }, + ), + const SizedBox(width: 4), + CopyButton( + tooltip: 'Copy response', + icon: LucideIcons.download, + onTap: () { + final body = entry.responseBody; + final text = body is String + ? body + : (body != null + ? const JsonEncoder.withIndent(' ') + .convert(body) + : ''); + _copyText(context, text, 'Response'); + }, + ), + ], + ), + ], + ), + ), + // Tabs + DetailTabBar( + controller: _tabController, + isDark: isDark, + accentColor: ColorTokens.primary, + tabs: const ['Headers', 'Request', 'Response', 'Timing'], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + LazyTab( + controller: _tabController, + index: 0, + builder: (_) => HeadersView(entry: entry), + ), + LazyTab( + controller: _tabController, + index: 1, + builder: (_) => BodyView( + body: entry.requestBody, + label: 'Request Body', + deviceId: entry.deviceId, + onJsonModeChanged: widget.onJsonModeChanged, + ), + ), + LazyTab( + controller: _tabController, + index: 2, + builder: (_) => BodyView( + body: entry.responseBody, + label: 'Response Body', + deviceId: entry.deviceId, + onJsonModeChanged: widget.onJsonModeChanged, + ), + ), + LazyTab( + controller: _tabController, + index: 3, + builder: (_) => TimingView(entry: entry), + ), + ], + ), + ), + ], + ); + } + + String _buildCurl(NetworkEntry e) { + final buf = StringBuffer("curl -X ${e.method} '${e.url}'"); + e.requestHeaders.forEach((k, v) { + buf.write(" \\\n -H '$k: $v'"); + }); + if (e.requestBody != null) { + final body = e.requestBody is String + ? e.requestBody as String + : const JsonEncoder().convert(e.requestBody); + buf.write(" \\\n -d '$body'"); + } + return buf.toString(); + } +} + +/// Tiny 200px-wide linear progress bar + duration label, used in the +/// network detail's URL bar to give an at-a-glance response-time hint. +class TimingBar extends StatelessWidget { + final int duration; + + const TimingBar({super.key, required this.duration}); + + @override + Widget build(BuildContext context) { + const maxWidth = 200.0; + final ratio = (duration / 2000).clamp(0.0, 1.0); + + Color barColor; + if (duration < 200) { + barColor = ColorTokens.success; + } else if (duration < 500) { + barColor = ColorTokens.warning; + } else { + barColor = ColorTokens.error; + } + + return Row( + children: [ + SizedBox( + width: maxWidth, + child: ClipRRect( + borderRadius: BorderRadius.circular(3), + child: LinearProgressIndicator( + value: ratio, + backgroundColor: barColor.withValues(alpha: 0.08), + valueColor: AlwaysStoppedAnimation(barColor), + minHeight: 4, + ), + ), + ), + const SizedBox(width: 8), + TextComponent( + formatDuration(duration), + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + fontWeight: FontWeight.w700, + color: barColor, + ), + ), + ], + ); + } +} + +void _copyText(BuildContext context, String text, String label) { + Clipboard.setData(ClipboardData(text: text)); + showCopiedToast(context, label: '$label copied'); +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/detail/state_detail.dart b/lib/features/all_events/presentation/detail/state_detail.dart new file mode 100644 index 0000000..27cc709 --- /dev/null +++ b/lib/features/all_events/presentation/detail/state_detail.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../components/feedback/empty_state.dart'; +import '../../../../components/text/text_component.dart'; +import '../../../../components/viewers/json_viewer.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../../../core/theme/theme_provider.dart'; +import '../../../../core/utils/smooth_scroll_controller.dart'; +import '../../../../core/utils/toast_utils.dart'; +import '../../../../models/state/state_change.dart'; +import '../shared/body_view.dart'; +import '../shared/detail_tab_bar.dart'; +import '../shared/copy_button.dart'; +import '../shared/tag_chip.dart'; +import 'diff_row.dart'; + +/// Right-pane detail for state-change events. Three tabs: +/// +/// - **Diff** — list of [DiffRow]s showing each field-level change. +/// - **Previous** — full state body before the change. +/// - **Next** — full state body after the change. +class StateDetail extends ConsumerStatefulWidget { + final StateChange entry; + final ValueChanged? onTabChanged; + final ValueChanged? onJsonModeChanged; + + const StateDetail({ + super.key, + required this.entry, + this.onTabChanged, + this.onJsonModeChanged, + }); + + @override + ConsumerState createState() => _StateDetailState(); +} + +class _StateDetailState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + final _diffScrollController = SmoothScrollController(); + + @override + void initState() { + super.initState(); + _tabController = _makeController(); + _tabController.addListener(_onTabIndexChange); + } + + TabController _makeController([int initialIndex = 0]) { + return TabController( + length: 3, + vsync: this, + animationDuration: ref.read(tabAnimationProvider), + initialIndex: initialIndex, + ); + } + + void _onTabIndexChange() { + if (!_tabController.indexIsChanging) { + widget.onTabChanged?.call(_tabController.index); + } + } + + void _rebuildController() { + final oldIndex = _tabController.index; + _tabController.removeListener(_onTabIndexChange); + _tabController.dispose(); + _tabController = _makeController(oldIndex); + _tabController.addListener(_onTabIndexChange); + setState(() {}); + } + + @override + void dispose() { + _tabController.removeListener(_onTabIndexChange); + _tabController.dispose(); + _diffScrollController.dispose(); + super.dispose(); + } + + StateChange get entry => widget.entry; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + ref.listen(tabAnimationProvider, (prev, next) { + if (prev != next) _rebuildController(); + }); + return Column( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + children: [ + TagChip(entry.stateManagerType, color: ColorTokens.secondary), + const SizedBox(width: 8), + Expanded( + child: TextComponent( + entry.actionName, + style: const TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + CopyButton( + tooltip: 'Copy action', + onTap: () => _copyText(context, entry.actionName, 'Action'), + ), + ], + ), + ), + DetailTabBar( + controller: _tabController, + isDark: isDark, + accentColor: ColorTokens.secondary, + tabs: const ['Diff', 'Previous', 'Next'], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + LazyTab( + controller: _tabController, + index: 0, + builder: (_) => entry.diff.isEmpty + ? EmptyState( + icon: LucideIcons.gitCompare, title: 'No diff') + : ListView.builder( + controller: _diffScrollController, + padding: const EdgeInsets.all(12), + itemCount: entry.diff.length, + itemBuilder: (context, index) => + DiffRow(diff: entry.diff[index]), + ), + ), + LazyTab( + controller: _tabController, + index: 1, + builder: (_) => entry.previousState.isEmpty + ? EmptyState( + icon: LucideIcons.layers, + title: 'No previous state') + : BodyView( + body: entry.previousState, + label: 'Previous State', + deviceId: entry.deviceId, + onJsonModeChanged: widget.onJsonModeChanged, + ), + ), + LazyTab( + controller: _tabController, + index: 2, + builder: (_) => entry.nextState.isEmpty + ? EmptyState( + icon: LucideIcons.layers, + title: 'No next state') + : BodyView( + body: entry.nextState, + label: 'Next State', + deviceId: entry.deviceId, + onJsonModeChanged: widget.onJsonModeChanged, + ), + ), + ], + ), + ), + ], + ); + } +} + +void _copyText(BuildContext context, String text, String label) { + Clipboard.setData(ClipboardData(text: text)); + showCopiedToast(context, label: '$label copied'); +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/detail/storage_detail.dart b/lib/features/all_events/presentation/detail/storage_detail.dart new file mode 100644 index 0000000..6571c53 --- /dev/null +++ b/lib/features/all_events/presentation/detail/storage_detail.dart @@ -0,0 +1,577 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../components/viewers/json_viewer.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../../../core/theme/theme_provider.dart'; +import '../../../../core/utils/code_generator.dart'; +import '../../../../core/utils/screenshot_filename.dart'; +import '../../../../core/utils/screenshot_utils.dart'; +import '../../../../core/utils/smooth_scroll_controller.dart'; +import '../../../../core/utils/toast_utils.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../models/device_info.dart'; +import '../../../../server/providers/server_providers.dart'; +import '../../../../models/storage/storage_entry.dart'; +import '../buttons/header_icon_button.dart'; +import '../shared/empty_value.dart'; +import '../shared/label.dart'; +import '../shared/meta_cell.dart'; +import '../shared/op_badge.dart'; +import '../shared/type_badge.dart'; + +/// Redressed storage detail panel: bento-style header (operation / type / +/// hero key), 2×2 metadata grid, and a 3-mode value viewer (Tree / JSON / +/// Code) for JSON-shaped payloads. +/// +/// Plain string/number/bool values render as a single tinted monospace +/// block. Missing values show [EmptyValue]. +class StorageDetail extends ConsumerStatefulWidget { + final StorageEntry entry; + final VoidCallback? onClose; + const StorageDetail({ + super.key, + required this.entry, + this.onClose, + }); + + @override + ConsumerState createState() => + _StorageDetailState(); +} + +class _StorageDetailState + extends ConsumerState { + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + // ── Design tokens ───────────────────────────────────────────── + // Off-black neutrals (anti-pure-black rule). One accent reserved + // for the operation chip — everything else is desaturated. + Color _textPrimary(bool isDark) => + isDark ? const Color(0xFFE8E8E8) : const Color(0xFF1A1A1A); + Color _textSecondary(bool isDark) => + isDark ? const Color(0xFF8B8B8B) : const Color(0xFF6B6B6B); + Color _divider(bool isDark) => isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06); + + Color _opColor() { + switch (widget.entry.operation.toLowerCase()) { + case 'write': + return const Color(0xFF34D399); // emerald 400 — single accent + case 'read': + return const Color(0xFF60A5FA); // blue 400 + case 'delete': + case 'clear': + return const Color(0xFFF87171); // red 400 + default: + return const Color(0xFFFBBF24); // amber 400 + } + } + + String _shapeOf(dynamic v) { + if (v == null) return 'null'; + if (v is Map) return 'Map · ${v.length} ${v.length == 1 ? "key" : "keys"}'; + if (v is List) return 'List · ${v.length} ${v.length == 1 ? "item" : "items"}'; + if (v is String) { + if (v.isEmpty) return 'String · empty'; + final t = v.trim(); + if ((t.startsWith('{') && t.endsWith('}')) || + (t.startsWith('[') && t.endsWith(']'))) { + return 'String · JSON-shaped'; + } + return 'String'; + } + return v.runtimeType.toString(); + } + + dynamic _parsedJson() { + final v = widget.entry.value; + if (v is! String) return null; + try { + final p = jsonDecode(v); + if (p is Map || p is List) return p; + } catch (_) {} + return null; + } + + dynamic _displayValue() { + final v = widget.entry.value; + if (v is Map || v is List) return v; + return _parsedJson() ?? v; + } + + bool get _isJsonLike { + final v = widget.entry.value; + if (v is Map || v is List) return true; + return _parsedJson() != null; + } + + String _sizeLabel() { + final raw = widget.entry.value is String + ? widget.entry.value as String + : const JsonEncoder.withIndent(' ').convert(widget.entry.value); + return AppConstants.formatBytes(raw.length); + } + + String _captureText() { + final v = widget.entry.value; + return v is String ? v : const JsonEncoder.withIndent(' ').convert(v); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final mode = ref.watch(bodyViewModeProvider); + final devices = ref.watch(connectedDevicesProvider); + final platform = devices + .where((d) => d.deviceId == widget.entry.deviceId) + .map((d) => d.platform) + .firstOrNull ?? + 'react_native'; + final codeLang = CodeGenerator.langForPlatform(platform); + final codeLabel = CodeGenerator.labelFor(codeLang); + + final monoPrimary = TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 13, + height: 1.5, + color: _textPrimary(isDark), + ); + final monoSecondary = TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + height: 1.5, + color: _textSecondary(isDark), + ); + + return SingleChildScrollView( + controller: _scrollController, + physics: const ClampingScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ────────────────────────────────────────────────────────── + // 1) HEADER — operation accent + storage type + key chip + // ────────────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + OpBadge(label: widget.entry.operation, color: _opColor()), + const SizedBox(width: 8), + TypeBadge(label: widget.entry.storageType.name), + const Spacer(), + HeaderIconButton( + icon: LucideIcons.copy, + tooltip: S.of(context).copyKey, + isDark: isDark, + onTap: () => _copyText(context, widget.entry.key, 'Key'), + ), + const SizedBox(width: 4), + HeaderIconButton( + icon: LucideIcons.camera, + tooltip: _isJsonLike + ? S.of(context).captureDataJson + : S.of(context).captureDataText, + isDark: isDark, + onTap: () => + _captureData(isDark, devices, codeLang, codeLabel), + ), + const SizedBox(width: 4), + HeaderIconButton( + icon: LucideIcons.x, + tooltip: S.of(context).close, + isDark: isDark, + onTap: () => widget.onClose?.call(), + ), + ], + ), + ), + + // ────────────────────────────────────────────────────────── + // 2) KEY DISPLAY — large monospace, hero element + // ────────────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Label(text: 'KEY', isDark: isDark), + const Spacer(), + HeaderIconButton( + icon: LucideIcons.copy, + tooltip: 'Copy key', + isDark: isDark, + onTap: () => _copyText(context, widget.entry.key, 'Key'), + ), + ], + ), + const SizedBox(height: 8), + SelectableText( + widget.entry.key, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 15, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + color: _textPrimary(isDark), + height: 1.4, + ), + ), + ], + ), + ), + + // ────────────────────────────────────────────────────────── + // 3) METADATA GRID — 2x2 bento layout + // ────────────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Label(text: 'METADATA', isDark: isDark), + const SizedBox(height: 10), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: MetaCell( + label: 'SHAPE', + value: _shapeOf(widget.entry.value), + valueStyle: monoPrimary, + isDark: isDark, + ), + ), + const SizedBox(width: 12), + Expanded( + child: MetaCell( + label: 'SIZE', + value: _sizeLabel(), + valueStyle: monoPrimary, + isDark: isDark, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: MetaCell( + label: 'DEVICE', + value: widget.entry.deviceId, + valueStyle: monoSecondary, + isDark: isDark, + monospace: true, + ), + ), + const SizedBox(width: 12), + Expanded( + child: MetaCell( + label: 'CAPTURED', + value: DateFormat('HH:mm:ss.SSS').format( + DateTime.fromMillisecondsSinceEpoch( + widget.entry.timestamp), + ), + valueStyle: monoPrimary, + isDark: isDark, + ), + ), + ], + ), + ], + ), + ), + + // ────────────────────────────────────────────────────────── + // 4) DIVIDER — separates data zones + // ────────────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 0), + child: Container(height: 1, color: _divider(isDark)), + ), + + // ────────────────────────────────────────────────────────── + // 5) VALUE SECTION — switcher + content + // ────────────────────────────────────────────────────────── + if (widget.entry.value != null && _isJsonLike) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), + child: SizedBox( + width: double.infinity, + child: ViewModeSwitcher( + current: mode, + codeLabel: codeLabel, + onChanged: (m) => + ref.read(bodyViewModeProvider.notifier).set(m), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), + child: _buildValueContent( + isDark: isDark, + mode: mode, + codeLang: codeLang, + codeLabel: codeLabel, + ), + ), + ] else if (widget.entry.value != null) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), + child: Row( + children: [ + Label(text: 'VALUE', isDark: isDark), + const Spacer(), + HeaderIconButton( + icon: LucideIcons.copy, + tooltip: 'Copy value', + isDark: isDark, + onTap: () => _copyText(context, _captureText(), 'Value'), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 10, 20, 28), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.03) + : Colors.black.withValues(alpha: 0.025), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _divider(isDark)), + ), + child: SelectableText( + widget.entry.value.toString(), + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + height: 1.6, + color: _textPrimary(isDark), + ), + ), + ), + ), + ] else ...[ + Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 28), + child: EmptyValue(isDark: isDark), + ), + ], + ], + ), + ); + } + + Widget _buildValueContent({ + required bool isDark, + required BodyViewMode mode, + required CodeLang codeLang, + required String codeLabel, + }) { + final value = _displayValue(); + + return DeferredBuilder( + key: ValueKey(mode), + builder: (_) { + switch (mode) { + case BodyViewMode.tree: + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: JsonViewer(data: value, initiallyExpanded: true), + ); + case BodyViewMode.json: + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: JsonPrettyViewer(data: value), + ); + case BodyViewMode.code: + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: CodeViewer( + generated: CodeGenerator.generate(value, codeLang), + lang: codeLang, + languageLabel: codeLabel, + ), + ); + } + }, + ); + } + + void _copyText(BuildContext context, String text, String label) { + Clipboard.setData(ClipboardData(text: text)); + showCopiedToast(context, label: '$label copied'); + } + + // ── Screenshot: data only (KEY + VALUE in current view mode) ── + void _captureData( + bool isDark, + List devices, + CodeLang codeLang, + String codeLabel, + ) { + final value = _displayValue(); + final monoKey = TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 14, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + color: _textPrimary(isDark), + ); + final labelStyle = TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + color: isDark ? const Color(0xFF6B6B6B) : const Color(0xFF8B8B8B), + ); + final divider = _divider(isDark); + final mode = ref.read(bodyViewModeProvider); + + // Value widget: respect 3-mode only when the payload is JSON-like. + // Plain text/number/bool capture as raw text — no switcher chrome. + final Widget valueWidget; + if (_isJsonLike) { + valueWidget = switch (mode) { + BodyViewMode.tree => JsonViewer(data: value, initiallyExpanded: true), + BodyViewMode.json => JsonPrettyViewer(data: value), + BodyViewMode.code => CodeViewer( + generated: CodeGenerator.generate(value, codeLang), + lang: codeLang, + languageLabel: codeLabel, + ), + }; + } else if (widget.entry.value == null) { + valueWidget = const SizedBox.shrink(); + } else { + valueWidget = Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.03) + : Colors.black.withValues(alpha: 0.025), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: divider), + ), + child: SelectableText( + widget.entry.value.toString(), + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + height: 1.6, + color: _textPrimary(isDark), + ), + ), + ); + } + + // Header style matches storageScreenshot for visual consistency + // between full and data captures. + final capturedAt = DateTime.now().toIso8601String().split('.').first; + final labelColor = isDark ? Colors.grey[500] : Colors.grey[600]; + + final capture = Container( + color: isDark ? const Color(0xFF121212) : const Color(0xFFFAFAFA), + padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Header context (matches storageScreenshot) ── + Row( + children: [ + Icon(LucideIcons.database, + size: 14, color: ColorTokens.warning), + const SizedBox(width: 6), + Text( + 'Storage Detail', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.0, + color: labelColor, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + '· ${widget.entry.storageType.name.toUpperCase()} · $capturedAt', + style: TextStyle( + fontSize: 10, + color: labelColor, + letterSpacing: 0.3, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + // ── Badges (mirror StorageDetail header row) ── + const SizedBox(height: 14), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + OpBadge(label: widget.entry.operation, color: _opColor()), + const SizedBox(width: 8), + TypeBadge(label: widget.entry.storageType.name), + ], + ), + const SizedBox(height: 18), + Container(height: 1, color: divider), + const SizedBox(height: 18), + Text('KEY', style: labelStyle), + const SizedBox(height: 8), + SelectableText(widget.entry.key, style: monoKey), + const SizedBox(height: 18), + Container(height: 1, color: divider), + const SizedBox(height: 18), + Text('VALUE', style: labelStyle), + const SizedBox(height: 10), + valueWidget, + ], + ), + ); + + captureWidgetAsImage( + context, + capture, + fileName: _buildScreenshotName(devices, '_data'), + onSaved: (path) { + if (mounted) showScreenshotSavedToast(context, filePath: path); + }, + ); + } + + String _buildScreenshotName(List devices, String suffix) { + final entry = widget.entry; + // Note: appName intentionally omitted to avoid leaking the app + // identifier into filenames shared with clients. + return buildRichScreenshotName( + type: entry.storageType.name, + subject: entry.key, + suffix: suffix, + ); + } +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/event_row/event_row.dart b/lib/features/all_events/presentation/event_row/event_row.dart new file mode 100644 index 0000000..a4edf07 --- /dev/null +++ b/lib/features/all_events/presentation/event_row/event_row.dart @@ -0,0 +1,309 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../components/misc/service_tag.dart'; +import '../../../../components/misc/status_badge.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../../../core/utils/duration_format.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../models/display/display_entry.dart'; +import '../../../../models/network/network_entry.dart'; +import '../../provider/all_events_provider.dart'; +import '../buttons/mini_icon_button.dart'; +import '../shared/type_info.dart'; + +/// One row in the All Events list. Visual states: +/// +/// - **selected** — accent left border (3px), tinted background +/// - **network in flight** — amber left bar + warning-tinted background +/// - **network error** — red left bar + error-tinted background +/// - **default** — neutral 2px left bar in the type's accent color +class EventRow extends StatelessWidget { + final UnifiedEvent event; + final bool isSelected; + final bool showDetail; + final String? platform; + final VoidCallback onTap; + final VoidCallback onCopyTitle; + + const EventRow({ + super.key, + required this.event, + required this.isSelected, + required this.showDetail, + this.platform, + required this.onTap, + required this.onCopyTitle, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final time = DateFormat('HH:mm:ss.SSS').format( + DateTime.fromMillisecondsSinceEpoch(event.timestamp), + ); + + final typeInfo = typeInfoFor(event); + + // Left bar color: for network, show status-based color + Color leftBarColor = typeInfo.color; + if (event.type == EventType.network && event.rawData is NetworkEntry) { + final netEntry = event.rawData as NetworkEntry; + if (!netEntry.isComplete) { + leftBarColor = ColorTokens.warning; + } else if (netEntry.statusCode <= 0 || netEntry.statusCode >= 400) { + leftBarColor = ColorTokens.error; + } else { + leftBarColor = ColorTokens.success; + } + } + + // Status-based background for network requests + final isNetworkError = event.type == EventType.network && + event.rawData is NetworkEntry && + (event.rawData as NetworkEntry).isComplete && + ((event.rawData as NetworkEntry).statusCode <= 0 || + (event.rawData as NetworkEntry).statusCode >= 400); + final isNetworkInProgress = event.type == EventType.network && + event.rawData is NetworkEntry && + !(event.rawData as NetworkEntry).isComplete; + + final bgColor = isSelected + ? ColorTokens.selectedBg(isDark) + : isNetworkError + ? ColorTokens.error.withValues(alpha: isDark ? 0.08 : 0.05) + : isNetworkInProgress + ? ColorTokens.warning.withValues(alpha: isDark ? 0.08 : 0.05) + : isDark + ? Colors.transparent + : Colors.white; + + return GestureDetector( + onTap: onTap, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + color: bgColor, + border: Border( + bottom: BorderSide( + color: isDark + ? Colors.white.withValues(alpha: 0.03) + : Colors.black.withValues(alpha: 0.04), + ), + left: BorderSide( + color: isSelected ? ColorTokens.selectedAccent : leftBarColor, + width: isSelected ? 3 : 2, + ), + ), + ), + child: Row( + children: [ + // Time + SizedBox( + width: 84, + child: Text( + time, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 10, + color: Colors.grey[500], + letterSpacing: -0.3, + ), + ), + ), + // Type badge + Container( + width: 56, + height: 22, + decoration: BoxDecoration( + color: typeInfo.color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(typeInfo.icon, size: 10, color: typeInfo.color), + const SizedBox(width: 3), + Text( + typeInfo.label, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w800, + color: typeInfo.color, + letterSpacing: 0.3, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + // Platform badge + if (platform != null) ...[ + PlatformBadge(platform: platform!), + const SizedBox(width: 8), + ], + // Title + Expanded( + child: Text( + event.title, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + color: event.level == 'error' + ? ColorTokens.error + : isDark + ? ColorTokens.lightBackground + : ColorTokens.darkNeutral, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + if (event.type == EventType.network && + event.rawData is NetworkEntry && + (event.rawData as NetworkEntry).serviceName != null) ...[ + ServiceTag( + name: (event.rawData as NetworkEntry).serviceName!), + const SizedBox(width: 6), + ], + // Subtitle / Status indicator + if (!showDetail) ...[ + if (event.type == EventType.network && + event.rawData is NetworkEntry) ...[ + if (!(event.rawData as NetworkEntry).isComplete) ...[ + SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: ColorTokens.warning, + ), + ), + const SizedBox(width: 5), + Text( + S.of(context).inProgress, + style: TextStyle( + fontSize: 10, + color: ColorTokens.warning, + fontFamily: AppConstants.monoFontFamily, + fontWeight: FontWeight.w600, + ), + ), + ] else ...[ + StatusBadge( + statusCode: + (event.rawData as NetworkEntry).statusCode), + const SizedBox(width: 6), + if ((event.rawData as NetworkEntry).duration != null) + Text( + formatDuration( + (event.rawData as NetworkEntry).duration!), + style: TextStyle( + fontSize: 10, + color: Colors.grey[500], + fontFamily: AppConstants.monoFontFamily, + ), + ), + ], + ] else + Text( + event.subtitle, + style: TextStyle( + fontSize: 10, + color: Colors.grey[500], + fontFamily: AppConstants.monoFontFamily, + ), + ), + ], + // Copy button (only visible on hover would be ideal, + // but for desktop quick-access is better) + const SizedBox(width: 4), + MiniIconButton( + icon: LucideIcons.copy, + tooltip: 'Copy', + onTap: onCopyTitle, + ), + if (isSelected) ...[ + const SizedBox(width: 2), + Icon(LucideIcons.chevronRight, + size: 12, color: ColorTokens.primary), + ], + ], + ), + ), + ), + ); + } + + /// Per-event-type metadata shared by the row's type badge and the + /// filter chip. Centralised so the two stay in lock-step visually. + static TypeInfo typeInfoFor(UnifiedEvent event) { + switch (event.type) { + case EventType.log: + return TypeInfo( + color: _logColor(event.level), + icon: LucideIcons.terminal, + label: event.level.toUpperCase(), + ); + case EventType.network: + return TypeInfo( + color: event.level == 'error' + ? ColorTokens.error + : ColorTokens.success, + icon: LucideIcons.globe, + label: 'API', + ); + case EventType.state: + return TypeInfo( + color: ColorTokens.secondary, + icon: LucideIcons.layers, + label: 'STATE', + ); + case EventType.storage: + return TypeInfo( + color: ColorTokens.warning, + icon: LucideIcons.database, + label: 'STORE', + ); + case EventType.display: + return TypeInfo( + color: const Color(0xFF9B59B6), + icon: LucideIcons.monitor, + label: 'DISPLAY', + ); + case EventType.asyncOp: + return TypeInfo( + color: const Color(0xFFE67E22), + icon: LucideIcons.zap, + label: event.rawData is AsyncOperationEntry + ? (event.rawData as AsyncOperationEntry).status.name.toUpperCase() + : 'ASYNC', + ); + case EventType.error: + return TypeInfo( + color: Colors.red, + icon: LucideIcons.alertTriangle, + label: 'ERROR', + ); + } + } + + static Color _logColor(String level) { + switch (level) { + case 'debug': + return ColorTokens.logDebug; + case 'warn': + return ColorTokens.logWarn; + case 'error': + return ColorTokens.logError; + default: + return ColorTokens.logInfo; + } + } +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/fab/draggable_reload_fab.dart b/lib/features/all_events/presentation/fab/draggable_reload_fab.dart new file mode 100644 index 0000000..4e84ffd --- /dev/null +++ b/lib/features/all_events/presentation/fab/draggable_reload_fab.dart @@ -0,0 +1,544 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../core/theme/color_tokens.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../models/device_info.dart'; + +/// Description of one reload action exposed by the FAB. +/// +/// Carries the icon, tooltip, current spinner state, and the callback to +/// fire when the user taps this slot. +class FabAction { + final IconData icon; + final String tooltip; + final bool spinning; + final VoidCallback onTap; + + const FabAction({ + required this.icon, + required this.tooltip, + required this.spinning, + required this.onTap, + }); +} + +/// Draggable, glass-effect reload FAB. The button's contents adapt to the +/// platform of the connected devices (Flutter = Hot Reload + Hot Restart, +/// RN = Metro reload, Android = Activity reset, mixed = universal reload). +/// +/// The user can drag the FAB anywhere inside a safe area — it never +/// overlaps the page header / filter bar and stays at least 20px from +/// every viewport edge. Position is intentionally NOT persisted: every cold +/// start resets to the bottom-right corner. +class DraggableReloadFab extends StatefulWidget { + final List devices; + final bool reloading; + final bool hotRestarting; + final VoidCallback onReload; + final VoidCallback onHotRestart; + + const DraggableReloadFab({ + super.key, + required this.devices, + required this.reloading, + required this.hotRestarting, + required this.onReload, + required this.onHotRestart, + }); + + @override + State createState() => _DraggableReloadFabState(); +} + +class _DraggableReloadFabState extends State + with SingleTickerProviderStateMixin { + /// Distance from the bottom-right corner of the parent (the All Events + /// page content). 20 = default 20px margin from bottom + right edges. + double _right = 20; + double _bottom = 20; + + bool _hovered = false; + bool _dragging = false; + + // Drag tracking — we record the starting screen position and the + // starting edge-distances, then compute new distances from the delta. + late double _dragStartRight; + late double _dragStartBottom; + late Offset _dragStartGlobal; + // Press feedback: a brief scale-down + opacity dip on the FAB. + bool _pressed = false; + + static const double _fabHeight = 44; + static const double _collapsedWidth = 44; + static const double _actionWidth = 38; // each action button is 38px wide + static const double _actionGap = 4; + static const double _edgeMargin = 20; // distance from screen edges + /// Combined height of the page chrome the FAB must never overlap: + /// • page header (48) — "All Events" title bar + /// • filter bar (44) — LOG / API / STATE / ... chip row + /// Mirrors the `Container(height: 48, ...)` at `Header` and the + /// `Container(height: 44, ...)` at `FilterBar` in this file; if you + /// change either, change the constants here. + static const double _headerHeight = 48; + static const double _filterBarHeight = 44; + + @override + void initState() { + super.initState(); + // Always start at the default position (bottom-right, 20px margin). + // Position is NOT persisted across launches — every cold start + // resets the FAB so the user always knows where to find it + // without hunting around the screen for a previously-dragged ghost. + _right = _edgeMargin; + _bottom = _edgeMargin; + // Eagerly allocate the controller so dispose() never trips over an + // uninitialised `late` field. The FAB can be mounted without ever + // triggering a reload (e.g. user has no devices), in which case the + // old lazy form was never read and dispose() would throw + // LateInitializationError. + _spinCtrl = AnimationController( + vsync: this, + duration: const Duration(seconds: 1), + ); + } + + // ── Drag handlers ──────────────────────────────────────────────────────── + // + // The FAB drops **wherever the user releases it** within a safe area: + // never above the header, never outside the viewport, never behind + // the dock. Position is intentionally NOT persisted — every launch + // starts fresh. + + void _onPanStart(DragStartDetails d) { + _dragStartRight = _right; + _dragStartBottom = _bottom; + _dragStartGlobal = d.globalPosition; + setState(() { + _dragging = true; + _pressed = true; + }); + } + + void _onPanUpdate(DragUpdateDetails d) { + final delta = d.globalPosition - _dragStartGlobal; + final media = MediaQuery.of(context); + final size = media.size; + + // Clamp so the FAB: + // • is at least [_edgeMargin] from each viewport edge, + // • never overlaps the page chrome — its top edge stays at least + // [_edgeMargin] below the filter bar bottom (= header + filter + // bar height from the top of the screen). This ensures the FAB + // can never float on top of the title or chip rows. + final minRight = _edgeMargin; + final maxRight = (size.width - _collapsedWidth - _edgeMargin) + .clamp(minRight, double.infinity); + final minBottom = _edgeMargin; + final maxBottom = (size.height - + _fabHeight - + _headerHeight - + _filterBarHeight - + _edgeMargin - + media.padding.bottom) + .clamp(minBottom, double.infinity); + + setState(() { + _right = (_dragStartRight - delta.dx).clamp(minRight, maxRight); + _bottom = (_dragStartBottom - delta.dy).clamp(minBottom, maxBottom); + }); + } + + Future _onPanEnd(DragEndDetails d) async { + setState(() { + _dragging = false; + _pressed = false; + }); + // Stay where the user dropped. Do NOT persist — see initState + // for the rationale. + } + + /// Dispatch a tap to the right action. We need this on the *outer* + /// GestureDetector (alongside the pan handlers) because a nested + /// GestureDetector around each action icon would out-compete the pan + /// recognizer in the gesture arena and silently break dragging. + /// + /// For a single-action FAB the whole surface triggers that one action. + /// For multi-action FABs (e.g. Flutter = Hot reload + Hot restart) we + /// pick the action by horizontal position — each action owns a strip + /// of width [_actionWidth] with [_actionGap] between strips. + void _onTapUp(TapUpDetails details) { + final actions = _actionsFor(); + if (actions.isEmpty) return; + + if (actions.length == 1) { + actions[0].onTap(); + return; + } + final localX = details.localPosition.dx; + for (var i = 0; i < actions.length; i++) { + final start = i * (_actionWidth + _actionGap); + final end = start + _actionWidth; + if (localX >= start && localX <= end) { + actions[i].onTap(); + return; + } + } + } + + // ── Action discovery ───────────────────────────────────────────────────── + + /// Returns the list of available reload actions for the currently + /// connected devices (in a stable, predictable order: hot reload before + /// hot restart). Mirrors the per-platform IDE hotkeys: + /// + /// Flutter → Hot Reload + Hot Restart + /// RN → Reload Metro + /// Android → Rebuild + /// Mixed → single universal "Reload app" + List _actionsFor() { + if (widget.devices.isEmpty) return const []; + bool isFlutter(String p) => p.toLowerCase() == 'flutter'; + bool isRN(String p) { + final lo = p.toLowerCase(); + return lo == 'reactnative' || lo == 'react_native' || lo == 'rn'; + } + bool isAndroid(String p) => p.toLowerCase() == 'android'; + + final hasFlutter = widget.devices.any((d) => isFlutter(d.platform)); + final hasRN = widget.devices.any((d) => isRN(d.platform)); + final hasAndroid = widget.devices.any((d) => isAndroid(d.platform)); + final platforms = (hasFlutter ? 1 : 0) + + (hasRN ? 1 : 0) + + (hasAndroid ? 1 : 0); + + if (platforms > 1) { + // Mixed: universal reload only. + return [ + FabAction( + icon: LucideIcons.zap, + tooltip: S.of(context).reloadApp, + spinning: widget.reloading, + onTap: widget.onReload, + ), + ]; + } + if (hasFlutter) { + return [ + FabAction( + icon: LucideIcons.zap, + tooltip: S.of(context).reloadAppHotReload, + spinning: widget.reloading, + onTap: widget.onReload, + ), + FabAction( + icon: LucideIcons.refreshCcw, + tooltip: S.of(context).reloadAppHotRestart, + spinning: widget.hotRestarting, + onTap: widget.onHotRestart, + ), + ]; + } + if (hasRN) { + return [ + FabAction( + icon: LucideIcons.rocket, + tooltip: S.of(context).reloadAppMetro, + spinning: widget.reloading, + onTap: widget.onReload, + ), + ]; + } + if (hasAndroid) { + // Android: Activity.recreate() is a runtime state reset — NOT a real + // "rebuild" of the APK. We label and icon this the same as Flutter's + // "Hot restart" so the UI reflects what actually happens (full state + // reset, no code recompile). For the *real* Android rebuild the + // developer has to run gradle assembleDebug + reinstall — which is + // outside what a runtime SDK can trigger. + return [ + FabAction( + icon: LucideIcons.refreshCcw, + tooltip: S.of(context).reloadAppHotRestart, + spinning: widget.reloading, + onTap: widget.onReload, + ), + ]; + } + return [ + FabAction( + icon: LucideIcons.zap, + tooltip: S.of(context).reloadApp, + spinning: widget.reloading, + onTap: widget.onReload, + ), + ]; + } + + // ── Sizing ────────────────────────────────────────────────────────────── + + double _currentFabWidth() { + final actions = _actionsFor(); + if (actions.isEmpty) return _collapsedWidth; + // When multiple actions exist, the FAB expands on hover to show them. + // For single-action platforms it stays the same size (no expand needed). + if (actions.length > 1 && _hovered) { + return _actionWidth * actions.length + + _actionGap * (actions.length - 1); + } + return _collapsedWidth; + } + + // ── Build ─────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final actions = _actionsFor(); + + // When the FAB has multiple actions available (Flutter = Hot Reload + + // Hot Restart) the *width* animates from 44 → 80 on hover — but the + // *Row* underneath still tries to lay out every action. With a 44px + // container and an 80px-tall row of two 38px icons + 4px gap, the + // second one overflows invisibly until the user hovers. Drop it + // until the pill actually expands. + final visibleActions = (actions.length > 1 && !_hovered) + ? actions.take(1).toList() + : actions; + + final fabWidth = _currentFabWidth(); + final disabled = actions.isEmpty; + + // Shared spinner — whichever reload is in flight, the shared + // `_spinCtrl` ticks. Toggled once per build so the multi-action case + // doesn't fight itself (the old per-icon helper would reset the + // controller while the other icon was still spinning). + _syncSpinner(visibleActions.any((a) => a.spinning)); + + // Clamp in build() too, not just `_onPanUpdate` — if the user + // resizes the window smaller than the dragged position, the + // Positioned offsets need to be re-clamped to the new viewport or + // the FAB escapes the screen. + final media = MediaQuery.of(context); + final size = media.size; + final maxRight = (size.width - fabWidth - _edgeMargin) + .clamp(_edgeMargin, double.infinity); + final maxBottom = (size.height - + _fabHeight - + _headerHeight - + _filterBarHeight - + _edgeMargin - + media.padding.bottom) + .clamp(_edgeMargin, double.infinity); + final clampedRight = _right.clamp(_edgeMargin, maxRight); + final clampedBottom = _bottom.clamp(_edgeMargin, maxBottom); + + return Positioned( + // Plain Positioned (no AnimatedPositioned) so the FAB tracks the + // cursor instantly during drag. Any animation here would lag behind + // the pointer and feel sticky. + right: clampedRight, + bottom: clampedBottom, + child: AnimatedScale( + // Press feedback + slight grow while dragging. + scale: _pressed ? 0.94 : (_dragging ? 1.04 : 1.0), + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + child: MouseRegion( + cursor: SystemMouseCursors.grab, + onEnter: (_) { + if (!_dragging) setState(() => _hovered = true); + }, + onExit: (_) { + if (!_dragging) setState(() => _hovered = false); + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: _onPanStart, + onPanUpdate: _onPanUpdate, + onPanEnd: _onPanEnd, + // Single tap dispatch lives on the OUTER detector too — that way + // the inner per-icon GestureDetectors can't out-compete the + // pan recognizer in the gesture arena (which is what blocked + // the drag from working). Tap vs. drag is decided by Flutter's + // built-in slop: a quick release → onTapUp, a movement past + // the touch slop → onPanStart. + onTapUp: _onTapUp, + child: AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + height: _fabHeight, + width: fabWidth, + decoration: _decoration(isDark, disabled, _hovered || _dragging), + child: Stack( + children: [ + // Inner top highlight — a 1px-tall gradient line simulating + // the refraction on a glass surface's top edge. Cheaper and + // crisper than a true inset shadow. + Positioned( + top: 0, + left: 10, + right: 10, + child: IgnorePointer( + child: Container( + height: 1, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.transparent, + (isDark ? Colors.white : Colors.white) + .withValues(alpha: isDark ? 0.35 : 0.7), + Colors.transparent, + ], + ), + ), + ), + ), + ), + // Action row + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (actions.isEmpty) + _fabIcon( + icon: LucideIcons.zap, + tooltip: S.of(context).reloadAppNoDevices, + spinning: false, + disabled: true, + isDark: isDark, + ) + else + for (int i = 0; i < visibleActions.length; i++) ...[ + if (i > 0) const SizedBox(width: _actionGap), + _fabIcon( + icon: visibleActions[i].icon, + tooltip: visibleActions[i].tooltip, + spinning: visibleActions[i].spinning, + disabled: false, + isDark: isDark, + ), + ], + ], + ), + ], + ), + ), + ), + ), + ), + ); + } + + /// Frosted-glass decoration. Three layers (top to bottom): + /// 1. Outer drop shadow — tinted to the background, never pure black, + /// no neon glow. + /// 2. Background fill — high-alpha neutral (260 of 255 alpha) so the + /// surface beneath shows through subtly without backdrop-blur cost. + /// For the *true* frosted look we wrap it in BackdropFilter so it + /// actually blurs what's behind when there's content (e.g. event + /// rows); we drop BackdropFilter when content is empty (no perf + /// cost on idle empty states). + /// 3. 1px border — translucent white in dark, translucent black in light. + BoxDecoration _decoration(bool isDark, bool disabled, bool emphasised) { + final surfaceColor = isDark + ? const Color(0xFF1B2129) + : const Color(0xFFFDFEFF); + return BoxDecoration( + color: disabled + ? surfaceColor.withValues(alpha: 0.72) + : surfaceColor.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.10) + : Colors.black.withValues(alpha: 0.06), + width: 1, + ), + boxShadow: [ + // Drop shadow, tinted to background. + BoxShadow( + color: isDark + ? Colors.black.withValues(alpha: 0.55) + : Colors.black.withValues(alpha: 0.18), + blurRadius: disabled ? 16 : 22, + spreadRadius: 0, + offset: const Offset(0, 6), + ), + // Slight forward "lift" when hovered/dragged — felt, not loud. + if (emphasised) + BoxShadow( + color: ColorTokens.primary.withValues(alpha: 0.18), + blurRadius: 20, + spreadRadius: -2, + offset: const Offset(0, 4), + ), + ], + ); + } + + /// One icon "slot" inside the pill. Spins while its action is in flight. + /// + /// IMPORTANT: this widget is purely visual. It does **not** wrap its child + /// in a [GestureDetector] — taps are dispatched by the outer FAB-level + /// handler via [_onTapUp] using horizontal position. Adding an inner + /// `GestureDetector(onTap: ...)` here would out-compete the outer pan + /// recognizer in the gesture arena and silently break dragging. + Widget _fabIcon({ + required IconData icon, + required String tooltip, + required bool spinning, + required bool disabled, + required bool isDark, + }) { + final color = disabled + ? (isDark ? Colors.grey[700]! : Colors.grey[400]!) + : (isDark ? Colors.grey[200]! : Colors.grey[800]!); + + final iconWidget = spinning + ? RotationTransition( + // Always drive from the shared `_spinCtrl` — whether it's + // actually animating or not is decided by `_syncSpinner()` in + // `build()`, not per-icon here. + turns: _spinCtrl, + child: Icon(icon, size: 17, color: color), + ) + : Icon(icon, size: 17, color: color); + + return Tooltip( + message: tooltip, + child: SizedBox( + width: _actionWidth, + height: _fabHeight, + child: Center(child: iconWidget), + ), + ); + } + + // Spinner — one controller, repeats when in flight. Cheap because every + // icon uses the same controller (visual only; not part of any layout + // pass that affects the parent). Initialised in initState() (not as a + // `late final` with an initializer) so dispose() never trips over an + // uninitialised controller — the FAB can be mounted without ever + // triggering a reload, in which case the old lazy form threw + // LateInitializationError when the widget tree was torn down. + late AnimationController _spinCtrl; + + /// Single source of truth for the shared spinner: any action currently + /// in flight → repeat; otherwise → stop. Called once per build so the + /// multi-action case doesn't fight itself (the old `_spinController(bool)` + /// helper toggled start/stop per icon and the second action would reset + /// the controller while the first was still spinning). + void _syncSpinner(bool shouldSpin) { + if (shouldSpin && !_spinCtrl.isAnimating) { + _spinCtrl.repeat(); + } else if (!shouldSpin && _spinCtrl.isAnimating) { + _spinCtrl.stop(); + } + } + + @override + void dispose() { + _spinCtrl.dispose(); + super.dispose(); + } +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/header/filter_bar.dart b/lib/features/all_events/presentation/header/filter_bar.dart new file mode 100644 index 0000000..9ce9c60 --- /dev/null +++ b/lib/features/all_events/presentation/header/filter_bar.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart' hide FilterChip; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../core/providers/tab_visibility_provider.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../provider/all_events_provider.dart'; +import 'filter_chip.dart'; + +/// Horizontal row of type-filter chips for the All Events page. Each chip +/// reflects the current count for its type and toggles the corresponding +/// filter set. Visibility of each chip is gated by the per-tab visibility +/// provider so the bar never shows chips for disabled source tabs. +class FilterBar extends ConsumerWidget { + final List events; + + const FilterBar({super.key, required this.events}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final activeFilters = ref.watch(allEventsFilterProvider); + final enabledTabs = ref.watch(tabVisibilityProvider); + final errorsOnly = ref.watch(allEventsErrorsOnlyProvider); + + final logCount = events.where((e) => e.type == EventType.log).length; + final netCount = events.where((e) => e.type == EventType.network).length; + final stateCount = events.where((e) => e.type == EventType.state).length; + final storeCount = events.where((e) => e.type == EventType.storage).length; + final displayCount = events.where((e) => e.type == EventType.display).length; + final asyncCount = events.where((e) => e.type == EventType.asyncOp).length; + final errorCount = events.where((e) => e.level == 'error').length; + + // Only show filter chips for enabled tabs + final chips = []; + if (enabledTabs.contains(TabKey.console)) { + chips.add(FilterChip( + label: 'LOG', + count: logCount, + icon: LucideIcons.terminal, + color: ColorTokens.logInfo, + isActive: activeFilters.contains(EventType.log), + onTap: () => _toggle(ref, EventType.log), + )); + } + if (enabledTabs.contains(TabKey.network)) { + if (chips.isNotEmpty) chips.add(const SizedBox(width: 8)); + chips.add(FilterChip( + label: 'API', + count: netCount, + icon: LucideIcons.globe, + color: ColorTokens.success, + isActive: activeFilters.contains(EventType.network), + onTap: () => _toggle(ref, EventType.network), + )); + } + if (enabledTabs.contains(TabKey.state)) { + if (chips.isNotEmpty) chips.add(const SizedBox(width: 8)); + chips.add(FilterChip( + label: 'STATE', + count: stateCount, + icon: LucideIcons.layers, + color: ColorTokens.secondary, + isActive: activeFilters.contains(EventType.state), + onTap: () => _toggle(ref, EventType.state), + )); + } + if (enabledTabs.contains(TabKey.storage)) { + if (chips.isNotEmpty) chips.add(const SizedBox(width: 8)); + chips.add(FilterChip( + label: 'STORE', + count: storeCount, + icon: LucideIcons.database, + color: ColorTokens.warning, + isActive: activeFilters.contains(EventType.storage), + onTap: () => _toggle(ref, EventType.storage), + )); + } + // Display events (always visible — no dedicated tab) + if (displayCount > 0 || activeFilters.contains(EventType.display)) { + if (chips.isNotEmpty) chips.add(const SizedBox(width: 8)); + chips.add(FilterChip( + label: 'DISPLAY', + count: displayCount, + icon: LucideIcons.monitor, + color: const Color(0xFF9B59B6), + isActive: activeFilters.contains(EventType.display), + onTap: () => _toggle(ref, EventType.display), + )); + } + // Async operation events (always visible — no dedicated tab) + if (asyncCount > 0 || activeFilters.contains(EventType.asyncOp)) { + if (chips.isNotEmpty) chips.add(const SizedBox(width: 8)); + chips.add(FilterChip( + label: 'ASYNC', + count: asyncCount, + icon: LucideIcons.zap, + color: const Color(0xFFE67E22), + isActive: activeFilters.contains(EventType.asyncOp), + onTap: () => _toggle(ref, EventType.asyncOp), + )); + } + + return Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: isDark ? ColorTokens.darkBackground : ColorTokens.lightSurface, + border: Border( + bottom: BorderSide( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + ), + ), + ), + child: Row( + children: [ + ...chips, + if (errorCount > 0) ...[ + const SizedBox(width: 10), + Container( + width: 1, + height: 18, + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.08), + ), + const SizedBox(width: 10), + FilterChip( + label: 'ERRORS', + count: errorCount, + icon: LucideIcons.triangleAlert, + color: ColorTokens.error, + isActive: errorsOnly, + onTap: () => ref + .read(allEventsErrorsOnlyProvider.notifier) + .update((v) => !v), + ), + ], + const Spacer(), + ], + ), + ); + } + + void _toggle(WidgetRef ref, EventType type) { + final current = ref.read(allEventsFilterProvider); + if (current.contains(type)) { + ref.read(allEventsFilterProvider.notifier).state = + current.difference({type}); + } else { + ref.read(allEventsFilterProvider.notifier).state = {...current, type}; + } + } +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/header/filter_chip.dart b/lib/features/all_events/presentation/header/filter_chip.dart new file mode 100644 index 0000000..0fd58d0 --- /dev/null +++ b/lib/features/all_events/presentation/header/filter_chip.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart' hide FilterChip; + +import '../buttons/pressable_button.dart'; + +/// A single filter chip on the All Events filter bar (LOG / API / STATE / +/// …) with a per-type color and live count. Uses the shared +/// [PressableButton] for tactile feedback so it matches every other +/// tap-target on the page. +class FilterChip extends StatelessWidget { + final String label; + final int count; + final IconData icon; + final Color color; + final bool isActive; + final VoidCallback onTap; + + const FilterChip({ + super.key, + required this.label, + required this.count, + required this.icon, + required this.color, + required this.isActive, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return PressableButton( + onTap: onTap, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: isActive + ? color.withValues(alpha: 0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isActive + ? color.withValues(alpha: 0.3) + : Colors.grey.withValues(alpha: 0.1), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, + size: 13, + color: isActive ? color : Colors.grey[600]), + const SizedBox(width: 5), + Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: isActive ? color : Colors.grey[600], + letterSpacing: 0.3, + ), + ), + const SizedBox(width: 5), + Text( + '$count', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w800, + color: isActive + ? color.withValues(alpha: 0.7) + : Colors.grey[500], + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/header/header_bar.dart b/lib/features/all_events/presentation/header/header_bar.dart new file mode 100644 index 0000000..d0a8d1c --- /dev/null +++ b/lib/features/all_events/presentation/header/header_bar.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../components/inputs/search_field.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../models/device_info.dart'; +import '../../../../core/theme/theme_provider.dart'; +import '../../provider/all_events_provider.dart'; +import '../buttons/icon_btn.dart'; +import '../status/reload_pill.dart'; +import '../status/server_status_pill.dart'; + +/// Top toolbar of the All Events page. +/// +/// Three regions, left → right: +/// 1. **Identity** — title, live event count, server status pill, +/// reload-connection pill. +/// 2. **Search** — search field that drives [allEventsSearchProvider]. +/// 3. **Actions** — auto-scroll, sort order, and clear-all grouped in a +/// single rounded container. +class Header extends ConsumerWidget { + final ValueNotifier eventCount; + final bool serverRunning; + final int port; + final int deviceCount; + final List devices; + final bool autoScroll; + final bool restarting; + final bool reloading; + final bool hotRestarting; + final VoidCallback onToggleAutoScroll; + final VoidCallback onClear; + final VoidCallback onReload; + final VoidCallback onReloadApp; + final VoidCallback onHotRestart; + + const Header({ + super.key, + required this.eventCount, + required this.serverRunning, + required this.port, + required this.deviceCount, + required this.devices, + required this.autoScroll, + required this.onToggleAutoScroll, + required this.onClear, + required this.restarting, + required this.onReload, + required this.reloading, + required this.hotRestarting, + required this.onReloadApp, + required this.onHotRestart, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + // Port-occupied detection: server failed to start (typically "Address + // already in use") AND is currently not running. + final startError = ref.watch(serverStartErrorProvider); + final portOccupied = !serverRunning && startError != null; + + return Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: isDark ? ColorTokens.darkBackground : Colors.white, + ), + child: Row( + children: [ + // ── Left: Title + meta ── + Icon(LucideIcons.activity, size: 16, color: ColorTokens.primary), + const SizedBox(width: 8), + Text('All Events', style: theme.textTheme.titleMedium), + const SizedBox(width: 8), + ValueListenableBuilder( + valueListenable: eventCount, + builder: (_, count, __) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '$count', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: AppConstants.monoFontFamily, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + ), + ), + const SizedBox(width: 14), + // Unified server-status pill — port + device count + state + ServerStatusPill( + serverRunning: serverRunning, + portOccupied: portOccupied, + port: port, + deviceCount: deviceCount, + startError: startError, + ), + + // Reload connection — sits next to the status pill so it's + // obvious that this action restarts the server (and therefore + // forces every connected SDK into its reconnect path). + const SizedBox(width: 8), + ReloadPill( + restarting: restarting, + tooltip: S.of(context).restartServer, + onTap: restarting ? () {} : onReload, + ), + + const Spacer(), + + SizedBox( + width: 200, + child: SearchField( + hintText: S.of(context).searchEvents, + onChanged: (v) => + ref.read(allEventsSearchProvider.notifier).state = v, + ), + ), + const SizedBox(width: 12), + + // ── Action group ── + Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.04) + : Colors.black.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Reload-app buttons were here — moved to a draggable + // floating FAB (see `DraggableReloadFab` rendered in the + // page-level Stack) so the button stays out of the user's + // way during long debug sessions. + IconBtn( + icon: LucideIcons.arrowDownToLine, + tooltip: S.of(context).autoScroll, + isActive: autoScroll, + onTap: onToggleAutoScroll, + ), + const SizedBox(width: 2), + Consumer( + builder: (context, ref, _) { + final sort = ref.watch(allEventsSortOrderProvider); + final isNewest = sort == SortOrder.newestFirst; + return IconBtn( + icon: isNewest ? LucideIcons.arrowUpNarrowWide : LucideIcons.arrowDownNarrowWide, + tooltip: isNewest ? S.of(context).newestFirst : S.of(context).oldestFirst, + isActive: isNewest, + onTap: () => ref.read(allEventsSortOrderProvider.notifier).state = + isNewest ? SortOrder.oldestFirst : SortOrder.newestFirst, + ); + }, + ), + const SizedBox(width: 2), + Container( + width: 1, + height: 18, + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.08), + ), + const SizedBox(width: 2), + IconBtn( + icon: LucideIcons.trash2, + tooltip: S.of(context).clearAll, + isDanger: true, + onTap: onClear, + ), + ], + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/network/headers_view.dart b/lib/features/all_events/presentation/network/headers_view.dart new file mode 100644 index 0000000..992f9cb --- /dev/null +++ b/lib/features/all_events/presentation/network/headers_view.dart @@ -0,0 +1,367 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../components/text/text_component.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../../../core/utils/smooth_scroll_controller.dart'; +import '../../../../core/utils/toast_utils.dart'; +import '../../../../models/network/network_entry.dart'; + +/// Full network headers view: two sections (request / response) rendered +/// as bordered cards with a count badge and a list of [HeaderRowCopy] rows +/// underneath each. +class HeadersView extends StatefulWidget { + final NetworkEntry entry; + + const HeadersView({super.key, required this.entry}); + + @override + State createState() => _HeadersViewState(); +} + +class _HeadersViewState extends State { + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final entry = widget.entry; + final isDark = Theme.of(context).brightness == Brightness.dark; + return SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + HeaderSection( + icon: LucideIcons.arrowUpRight, + iconColor: ColorTokens.primary, + title: 'Request Headers', + count: entry.requestHeaders.length, + headers: entry.requestHeaders, + isDark: isDark, + ), + const SizedBox(height: 16), + HeaderSection( + icon: LucideIcons.arrowDownLeft, + iconColor: ColorTokens.success, + title: 'Response Headers', + count: entry.responseHeaders.length, + headers: entry.responseHeaders, + isDark: isDark, + ), + ], + ), + ); + } +} + +/// One request/response section card: title row with icon + count badge, +/// followed by a list of [HeaderRowCopy] rows. +class HeaderSection extends StatelessWidget { + final IconData icon; + final Color iconColor; + final String title; + final int count; + final Map headers; + final bool isDark; + + const HeaderSection({ + super.key, + required this.icon, + required this.iconColor, + required this.title, + required this.count, + required this.headers, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: isDark ? ColorTokens.darkBackground : Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + ), + ), + child: Column( + children: [ + // Section header + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1C2128) : ColorTokens.lightSurface, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(10)), + border: Border( + bottom: BorderSide( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + ), + ), + ), + child: Row( + children: [ + Icon(icon, size: 13, color: iconColor), + const SizedBox(width: 8), + TextComponent( + title, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white70 : Colors.black87, + ), + ), + const SizedBox(width: 8), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: TextComponent( + '$count', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: iconColor, + ), + ), + ), + ], + ), + ), + // Header rows + if (headers.isEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: TextComponent('No headers', + style: TextStyle(color: Colors.grey[500], fontSize: 12)), + ) + else + ...headers.entries.toList().asMap().entries.map((entry) { + final e = entry.value; + final isLast = entry.key == headers.length - 1; + return Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: isLast + ? null + : BoxDecoration( + border: Border( + bottom: BorderSide( + color: isDark + ? Colors.white.withValues(alpha: 0.04) + : Colors.black.withValues(alpha: 0.04), + ), + ), + ), + child: HeaderRowCopy( + headerKey: e.key, + headerValue: e.value, + isDark: isDark, + ), + ); + }), + ], + ), + ); + } +} + +/// Flattened header list used inside screenshots (no per-row hover +/// controls, just key/value lines with VSCode-syntax colors). +class HeaderTable extends StatelessWidget { + final Map headers; + final bool isScreenshot; + + const HeaderTable({super.key, required this.headers, this.isScreenshot = false}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + if (headers.isEmpty) { + return TextComponent('No headers', + style: TextStyle(color: Colors.grey[500], fontSize: 12)); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: headers.entries.map((e) { + if (isScreenshot) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 170, + child: TextComponent(e.key, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + fontWeight: FontWeight.w600, + color: isDark + ? const Color(0xFF9CDCFE) + : const Color(0xFF0451A5))), + ), + Expanded( + child: TextComponent(e.value, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: isDark + ? const Color(0xFFCE9178) + : const Color(0xFFA31515))), + ), + ], + ), + ); + } + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: HeaderRowCopy( + headerKey: e.key, + headerValue: e.value, + isDark: isDark, + ), + ); + }).toList(), + ); + } +} + +/// One header row with VSCode-syntax colors. Long values collapse to 4 +/// lines with a "Show more" toggle; on hover, a small copy icon appears +/// at the end of the row. +class HeaderRowCopy extends StatefulWidget { + final String headerKey; + final String headerValue; + final bool isDark; + + const HeaderRowCopy({ + super.key, + required this.headerKey, + required this.headerValue, + required this.isDark, + }); + + @override + State createState() => _HeaderRowCopyState(); +} + +class _HeaderRowCopyState extends State { + bool _hovered = false; + bool _copied = false; + bool _expanded = false; + + static const _maxCollapsedLines = 4; + + bool get _isLong => '\n'.allMatches(widget.headerValue).length >= _maxCollapsedLines || + widget.headerValue.length > 200; + + @override + Widget build(BuildContext context) { + final valueStyle = TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: widget.isDark + ? const Color(0xFFCE9178) + : const Color(0xFFA31515), + ); + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() { + _hovered = false; + _copied = false; + }), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 170, + child: TextComponent( + widget.headerKey, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + fontWeight: FontWeight.w600, + color: widget.isDark + ? const Color(0xFF9CDCFE) + : const Color(0xFF0451A5), + ), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_expanded) + TextComponent(widget.headerValue, style: valueStyle) + else + TextComponent( + widget.headerValue, + style: valueStyle, + maxLines: _maxCollapsedLines, + overflow: TextOverflow.ellipsis, + ), + if (_isLong) + GestureDetector( + onTap: () => setState(() => _expanded = !_expanded), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Padding( + padding: const EdgeInsets.only(top: 2), + child: TextComponent( + _expanded ? 'Collapse' : 'Show more', + style: TextStyle( + fontSize: 10, + color: ColorTokens.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ), + if (_hovered) + GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: '${widget.headerKey}: ${widget.headerValue}')); + setState(() => _copied = true); + showCopiedToast(context); + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Padding( + padding: const EdgeInsets.only(left: 6), + child: Icon( + _copied ? LucideIcons.check : LucideIcons.copy, + size: 12, + color: _copied + ? ColorTokens.chartGreen + : (widget.isDark ? Colors.white38 : Colors.black26), + ), + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/network/timing_view.dart b/lib/features/all_events/presentation/network/timing_view.dart new file mode 100644 index 0000000..a5fdd2b --- /dev/null +++ b/lib/features/all_events/presentation/network/timing_view.dart @@ -0,0 +1,434 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../components/text/text_component.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../../../core/utils/duration_format.dart'; +import '../../../../core/utils/smooth_scroll_controller.dart'; +import '../../../../models/network/network_entry.dart'; +import '../shared/error_block.dart'; +import '../shared/section_label.dart'; + +/// Network timing view: large duration hero card with a 270° gauge, a +/// timeline (start/end), and a method/status info card. +class TimingView extends StatefulWidget { + final NetworkEntry entry; + + const TimingView({super.key, required this.entry}); + + @override + State createState() => _TimingViewState(); +} + +class _TimingViewState extends State { + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final entry = widget.entry; + final isDark = Theme.of(context).brightness == Brightness.dark; + final duration = entry.duration; + final startDt = DateTime.fromMillisecondsSinceEpoch(entry.startTime); + final endDt = entry.endTime != null + ? DateTime.fromMillisecondsSinceEpoch(entry.endTime!) + : null; + + Color durationColor; + String durationLabel; + IconData durationIcon; + if (duration == null) { + durationColor = Colors.grey; + durationLabel = 'In Progress'; + durationIcon = LucideIcons.loader; + } else if (duration < 200) { + durationColor = ColorTokens.success; + durationLabel = 'Fast'; + durationIcon = LucideIcons.zap; + } else if (duration < 500) { + durationColor = ColorTokens.warning; + durationLabel = 'Normal'; + durationIcon = LucideIcons.clock; + } else if (duration < 2000) { + durationColor = const Color(0xFFE5853D); + durationLabel = 'Slow'; + durationIcon = LucideIcons.triangleAlert; + } else { + durationColor = ColorTokens.error; + durationLabel = 'Very Slow'; + durationIcon = LucideIcons.circleAlert; + } + + return SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Loading indicator for pending requests + if (duration == null) + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + ColorTokens.warning.withValues(alpha: 0.12), + ColorTokens.warning.withValues(alpha: 0.04), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: ColorTokens.warning.withValues(alpha: 0.2), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: ColorTokens.warning, + ), + ), + const SizedBox(width: 12), + TextComponent( + 'Waiting for response...', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: ColorTokens.warning, + ), + ), + ], + ), + ), + if (duration == null) const SizedBox(height: 16), + // Duration hero card + if (duration != null) + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + durationColor.withValues(alpha: 0.12), + durationColor.withValues(alpha: 0.04), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: durationColor.withValues(alpha: 0.2), + ), + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: durationColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(durationIcon, size: 22, color: durationColor), + ), + const SizedBox(width: 14), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextComponent( + formatDuration(duration), + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 24, + fontWeight: FontWeight.w700, + color: durationColor, + height: 1.1, + ), + ), + const SizedBox(height: 2), + TextComponent( + durationLabel, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: durationColor.withValues(alpha: 0.8), + ), + ), + ], + ), + const Spacer(), + // Performance gauge + SizedBox( + width: 56, + height: 56, + child: CustomPaint( + painter: GaugePainter( + ratio: (duration / 2000).clamp(0.0, 1.0), + color: durationColor, + isDark: isDark, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + // Timeline + TimingInfoCard( + isDark: isDark, + children: [ + TimingInfoRow( + icon: LucideIcons.play, + iconColor: ColorTokens.success, + label: 'Start Time', + value: DateFormat('HH:mm:ss.SSS').format(startDt), + subtitle: DateFormat('yyyy-MM-dd').format(startDt), + isDark: isDark, + ), + if (endDt != null) ...[ + TimingDividerLine(isDark: isDark), + TimingInfoRow( + icon: LucideIcons.square, + iconColor: ColorTokens.error, + label: 'End Time', + value: DateFormat('HH:mm:ss.SSS').format(endDt), + subtitle: DateFormat('yyyy-MM-dd').format(endDt), + isDark: isDark, + ), + ], + ], + ), + // Status info + if (entry.statusCode > 0) ...[ + const SizedBox(height: 12), + TimingInfoCard( + isDark: isDark, + children: [ + TimingInfoRow( + icon: LucideIcons.arrowUpRight, + iconColor: ColorTokens.primary, + label: 'Method', + value: entry.method, + isDark: isDark, + ), + TimingDividerLine(isDark: isDark), + TimingInfoRow( + icon: LucideIcons.hash, + iconColor: entry.statusCode >= 400 + ? ColorTokens.error + : ColorTokens.success, + label: 'Status', + value: '${entry.statusCode}', + isDark: isDark, + ), + ], + ), + ], + // Error section + if (entry.error != null) ...[ + const SizedBox(height: 16), + const SectionLabel('Error'), + const SizedBox(height: 8), + ErrorBlock(text: entry.error!, isDark: isDark), + ], + ], + ), + ); + } +} + +/// Card container that groups one or more [TimingInfoRow]s with a thin +/// border + rounded corners. Used for the timeline and status cards. +class TimingInfoCard extends StatelessWidget { + final bool isDark; + final List children; + + const TimingInfoCard({super.key, required this.isDark, required this.children}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: isDark ? ColorTokens.darkBackground : Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + ), + ), + child: Column( + children: children, + ), + ); + } +} + +/// One labelled value row inside a [TimingInfoCard]. Icon, fixed-width +/// label, value (and optional subtitle) on the right. +class TimingInfoRow extends StatelessWidget { + final IconData icon; + final Color iconColor; + final String label; + final String value; + final String? subtitle; + final bool isDark; + + const TimingInfoRow({ + super.key, + required this.icon, + required this.iconColor, + required this.label, + required this.value, + this.subtitle, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(7), + ), + child: Icon(icon, size: 14, color: iconColor), + ), + const SizedBox(width: 12), + SizedBox( + width: 80, + child: TextComponent( + label, + style: TextStyle( + fontSize: 11, + color: Colors.grey[500], + fontWeight: FontWeight.w500, + ), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextComponent( + value, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white : Colors.black87, + ), + ), + if (subtitle != null) + TextComponent( + subtitle!, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 10, + color: Colors.grey[500], + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// Thin 1px divider inside a [TimingInfoCard] with a left indent so the +/// line doesn't visually touch the icon column. +class TimingDividerLine extends StatelessWidget { + final bool isDark; + const TimingDividerLine({super.key, required this.isDark}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 28), + child: Divider( + height: 1, + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + ), + ); + } +} + +/// 270° arc gauge for the duration hero. Draws a tinted background arc +/// then a value arc sized to the duration ratio. +class GaugePainter extends CustomPainter { + final double ratio; + final Color color; + final bool isDark; + + GaugePainter({ + required this.ratio, + required this.color, + required this.isDark, + }); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + final r = size.width / 2 - 4; + const startAngle = 2.356; // 135 degrees + const sweepTotal = 4.712; // 270 degrees + + // Background arc + final bgPaint = Paint() + ..color = (isDark ? Colors.white : Colors.black).withValues(alpha: 0.08) + ..strokeWidth = 5 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + + canvas.drawArc( + Rect.fromCircle(center: Offset(cx, cy), radius: r), + startAngle, + sweepTotal, + false, + bgPaint, + ); + + // Value arc + final valuePaint = Paint() + ..color = color + ..strokeWidth = 5 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + + canvas.drawArc( + Rect.fromCircle(center: Offset(cx, cy), radius: r), + startAngle, + sweepTotal * ratio, + false, + valuePaint, + ); + } + + @override + bool shouldRepaint(GaugePainter old) => + old.ratio != ratio || old.color != color; +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/pages/all_events_page.dart b/lib/features/all_events/presentation/pages/all_events_page.dart index 3c7ac18..33f4477 100644 --- a/lib/features/all_events/presentation/pages/all_events_page.dart +++ b/lib/features/all_events/presentation/pages/all_events_page.dart @@ -1,56 +1,41 @@ -import 'dart:convert'; -import 'dart:io'; -import 'dart:ui' as ui; - -import 'package:file_selector/file_selector.dart'; -import '../../../../core/utils/duration_format.dart'; -import '../../../../core/utils/screenshot_utils.dart'; -import '../../../../core/utils/screenshot_filename.dart'; import 'package:flutter/material.dart'; -import '../../../../l10n/app_localizations.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../../core/preferences/app_preferences.dart'; -import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; -import '../../../../core/constants/app_constants.dart'; import '../../../../components/feedback/empty_state.dart'; -import '../../../../components/inputs/search_field.dart'; -import '../../../../components/text/text_component.dart'; -import '../../../../components/misc/status_badge.dart'; -import '../../../../components/misc/service_tag.dart'; -import '../../../../components/viewers/json_viewer.dart'; -import '../../../../core/providers/tab_visibility_provider.dart'; -import '../../../../core/theme/color_tokens.dart'; +import '../../../../components/lists/stable_list_view.dart'; +import '../../../../components/misc/jump_to_latest_fab.dart'; import '../../../../core/theme/theme_provider.dart'; -import '../../../../core/utils/code_generator.dart'; -import '../../../../models/device_info.dart'; -import '../../../../models/display/display_entry.dart'; -import '../../../../models/log/log_entry.dart'; -import '../../../../models/network/network_entry.dart'; -import '../../../../models/state/state_change.dart'; -import '../../../../models/storage/storage_entry.dart'; +import '../../../../core/utils/smooth_scroll_controller.dart'; +import '../../../../core/utils/toast_utils.dart'; +import '../../../../l10n/app_localizations.dart'; import '../../../../server/providers/server_providers.dart'; +import '../../../benchmark/provider/benchmark_providers.dart'; import '../../../console/provider/console_providers.dart'; import '../../../display/provider/display_providers.dart'; import '../../../error_inspector/provider/error_providers.dart'; import '../../../network_inspector/provider/network_providers.dart'; import '../../../performance/provider/performance_providers.dart'; -import '../../../benchmark/provider/benchmark_providers.dart'; import '../../../state_inspector/provider/state_providers.dart'; import '../../../storage_viewer/provider/storage_providers.dart'; -import '../../../../components/lists/stable_list_view.dart'; -import '../../../../components/misc/jump_to_latest_fab.dart'; -import '../../../../core/utils/toast_utils.dart'; -import '../../../../core/utils/smooth_scroll_controller.dart'; import '../../provider/all_events_provider.dart'; +import '../detail/event_detail_panel.dart'; +import '../event_row/event_row.dart'; +import '../fab/draggable_reload_fab.dart'; +import '../header/filter_bar.dart'; +import '../header/header_bar.dart'; -// ═══════════════════════════════════════════════ -// All Events Page -// ═══════════════════════════════════════════════ - +/// Top-level All Events page. Composes: +/// +/// - [Header] — title, search, sort, action group, server status pill +/// - [FilterBar] — per-type filter chips +/// - [EventRow] list — the streaming events +/// - [EventDetailPanel] — slides in when a row is selected +/// - [DraggableReloadFab] — draggable, glass FAB for reload / hot-restart +/// +/// Owns the page-level state: scroll controller, selection, auto-scroll, +/// and the in-memory event cache populated from [filteredAllEventsProvider]. class AllEventsPage extends ConsumerStatefulWidget { const AllEventsPage({super.key}); @@ -141,11 +126,13 @@ class _AllEventsPageState extends ConsumerState { _programmaticScroll = false; return; } - _scrollController.animateTo( + _scrollController + .animateTo( target, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, - ).whenComplete(() { + ) + .whenComplete(() { if (!mounted || !_autoScroll || !_scrollController.hasClients) { _programmaticScroll = false; return; @@ -197,81 +184,8 @@ class _AllEventsPageState extends ConsumerState { bool _restarting = false; /// Restart the WebSocket server. Forces every connected SDK into its - /// reconnect path — useful when you've changed the port, switched WiFi, - /// or want to verify the machineId handshake from scratch. - /// Ask every connected device to reload its own app. - /// - /// Behaviour on each SDK: - /// - **Flutter** → `WidgetsBinding.instance.reassembleApplication()` (full - /// widget tree rebuild — the same mechanism `flutter run -r` uses) - /// - **React Native** → `DevSettings.reload()` (Metro reload) - /// - **Android** → `Activity.recreate()` on the host activity - /// - /// We broadcast to ALL devices regardless of platform — the wire message - /// is the same, the SDK knows what to do with it. The icon and tooltip on - /// the button adapt to the dominant connected platform, but the click - /// always sends the reload to every device. - bool _reloading = false; - - Future _reloadApp() async { - if (_reloading) return; - final handler = ref.read(wsMessageHandlerProvider); - final devices = ref.read(connectedDevicesProvider); - if (devices.isEmpty) { - showInfoToast( - context, - message: S.of(context).reloadApp, - subtitle: S.of(context).reloadAppNoDevices, - ); - return; - } - setState(() => _reloading = true); - try { - handler.broadcastReload(); - if (!mounted) return; - showSuccessToast( - context, - message: S.of(context).reloadSent, - subtitle: S.of(context).sentReloadTo(devices.length), - ); - } finally { - if (mounted) setState(() => _reloading = false); - } - } - - bool _hotRestarting = false; - - /// Hot restart — the heavier Flutter IDE action. Same broadcast semantics - /// as [_reloadApp] but sends `server:hot_restart` so Flutter SDKs can - /// branch into their heavy-weight handler (default still - /// `reassembleApplication`; apps can register `onHotRestartRequest` to - /// actually wipe state). - Future _hotRestartApp() async { - if (_hotRestarting) return; - final handler = ref.read(wsMessageHandlerProvider); - final devices = ref.read(connectedDevicesProvider); - if (devices.isEmpty) { - showInfoToast( - context, - message: S.of(context).reloadAppHotRestart, - subtitle: S.of(context).reloadAppNoDevices, - ); - return; - } - setState(() => _hotRestarting = true); - try { - handler.broadcastReload(hotRestart: true); - if (!mounted) return; - showSuccessToast( - context, - message: S.of(context).reloadSent, - subtitle: S.of(context).sentReloadTo(devices.length), - ); - } finally { - if (mounted) setState(() => _hotRestarting = false); - } - } - + /// reconnect path — useful when the port changed, WiFi switched, or the + /// machineId handshake needs to be verified from scratch. Future _restartServer() async { if (_restarting) return; setState(() => _restarting = true); @@ -351,6 +265,77 @@ class _AllEventsPageState extends ConsumerState { } } + /// Ask every connected device to reload its own app. + /// + /// Behaviour on each SDK: + /// - **Flutter** → `WidgetsBinding.instance.reassembleApplication()` (full + /// widget tree rebuild — the same mechanism `flutter run -r` uses) + /// - **React Native** → `DevSettings.reload()` (Metro reload) + /// - **Android** → `Activity.recreate()` on the host activity + /// + /// We broadcast to ALL devices regardless of platform — the wire message + /// is the same, the SDK knows what to do with it. + bool _reloading = false; + + Future _reloadApp() async { + if (_reloading) return; + final handler = ref.read(wsMessageHandlerProvider); + final devices = ref.read(connectedDevicesProvider); + if (devices.isEmpty) { + showInfoToast( + context, + message: S.of(context).reloadApp, + subtitle: S.of(context).reloadAppNoDevices, + ); + return; + } + setState(() => _reloading = true); + try { + handler.broadcastReload(); + if (!mounted) return; + showSuccessToast( + context, + message: S.of(context).reloadSent, + subtitle: S.of(context).sentReloadTo(devices.length), + ); + } finally { + if (mounted) setState(() => _reloading = false); + } + } + + bool _hotRestarting = false; + + /// Hot restart — the heavier Flutter IDE action. Same broadcast semantics + /// as [_reloadApp] but sends `server:hot_restart` so Flutter SDKs can + /// branch into their heavy-weight handler (default still + /// `reassembleApplication`; apps can register `onHotRestartRequest` to + /// actually wipe state). + Future _hotRestartApp() async { + if (_hotRestarting) return; + final handler = ref.read(wsMessageHandlerProvider); + final devices = ref.read(connectedDevicesProvider); + if (devices.isEmpty) { + showInfoToast( + context, + message: S.of(context).reloadAppHotRestart, + subtitle: S.of(context).reloadAppNoDevices, + ); + return; + } + setState(() => _hotRestarting = true); + try { + handler.broadcastReload(hotRestart: true); + if (!mounted) return; + showSuccessToast( + context, + message: S.of(context).reloadSent, + subtitle: S.of(context).sentReloadTo(devices.length), + ); + } finally { + if (mounted) setState(() => _hotRestarting = false); + } + } + UnifiedEvent? _findEvent(String? id) { if (id == null) return null; return _events.where((e) => e.id == id).firstOrNull; @@ -369,142 +354,152 @@ class _AllEventsPageState extends ConsumerState { children: [ Column( children: [ - // ── Header ── - _Header( - eventCount: _eventCount, - serverRunning: server.isRunning, - port: server.isRunning ? server.port : 9090, - deviceCount: devices.length, - devices: devices, - autoScroll: _autoScroll, - onToggleAutoScroll: _toggleAutoScroll, - onClear: _clearAll, - restarting: _restarting, - onReload: _restartServer, - reloading: _reloading, - hotRestarting: _hotRestarting, - onReloadApp: _reloadApp, - onHotRestart: _hotRestartApp, - ), - // ── Stats + Filters ── (rebuilds via _eventCount, not setState) - ValueListenableBuilder( - valueListenable: _eventCount, - builder: (_, __, ___) => _FilterBar(events: _events), - ), - // ── Content ── - Expanded( - child: _events.isEmpty - ? EmptyState( - icon: LucideIcons.layoutDashboard, - title: devices.isEmpty - ? S.of(context).noDevicesConnected - : S.of(context).noEventsYet, - subtitle: devices.isEmpty - ? S.of(context).startAppToSeeEvents - : S.of(context).eventsAppearHere, - ) - : Stack( - children: [ - Row( + // ── Header ── + Header( + eventCount: _eventCount, + serverRunning: server.isRunning, + port: server.isRunning ? server.port : 9090, + deviceCount: devices.length, + devices: devices, + autoScroll: _autoScroll, + onToggleAutoScroll: _toggleAutoScroll, + onClear: _clearAll, + restarting: _restarting, + onReload: _restartServer, + reloading: _reloading, + hotRestarting: _hotRestarting, + onReloadApp: _reloadApp, + onHotRestart: _hotRestartApp, + ), + // ── Stats + Filters ── (rebuilds via _eventCount, not setState) + ValueListenableBuilder( + valueListenable: _eventCount, + builder: (_, count, __) => FilterBar(events: _events), + ), + // ── Content ── + Expanded( + child: _events.isEmpty + ? EmptyState( + icon: LucideIcons.layoutDashboard, + title: devices.isEmpty + ? S.of(context).noDevicesConnected + : S.of(context).noEventsYet, + subtitle: devices.isEmpty + ? S.of(context).startAppToSeeEvents + : S.of(context).eventsAppearHere, + ) + : Stack( children: [ - // ── Event List ── - // List never rebuilds due to selection change. - Expanded( - child: ListView.custom( - controller: _scrollController, - itemExtent: 44, - childrenDelegate: StableBuilderDelegate( - generation: _generation, - childCount: _visibleCount, - findChildIndexCallback: (key) { - if (key is ValueKey) { - final idx = _events.indexWhere((e) => e.id == key.value); - return idx == -1 ? null : idx; - } - return null; - }, - builder: (context, index) { - final actualIndex = sortOrder == SortOrder.newestFirst - ? _visibleCount - 1 - index - : index; - if (actualIndex < 0 || actualIndex >= _events.length) { + Row( + children: [ + // ── Event List ── + // List never rebuilds due to selection change. + Expanded( + child: ListView.custom( + controller: _scrollController, + itemExtent: 44, + childrenDelegate: StableBuilderDelegate( + generation: _generation, + childCount: _visibleCount, + findChildIndexCallback: (key) { + if (key is ValueKey) { + final idx = _events + .indexWhere((e) => e.id == key.value); + return idx == -1 ? null : idx; + } + return null; + }, + builder: (context, index) { + final actualIndex = sortOrder == + SortOrder.newestFirst + ? _visibleCount - 1 - index + : index; + if (actualIndex < 0 || + actualIndex >= _events.length) { + return const SizedBox.shrink(); + } + final event = _events[actualIndex]; + final device = devices + .where((d) => + d.deviceId == event.deviceId) + .firstOrNull; + return RepaintBoundary( + key: ValueKey(event.id), + child: ValueListenableBuilder( + valueListenable: _selectedEventId, + builder: (context, selectedId, _) { + final isSelected = + selectedId == event.id; + return EventRow( + event: event, + isSelected: isSelected, + showDetail: false, + platform: device?.platform, + onTap: () { + _selectedEventId.value = + isSelected ? null : event.id; + if (!isSelected && _autoScroll) { + _autoScroll = false; + _programmaticScroll = false; + if (_scrollController + .hasClients) { + _scrollController.jumpTo( + _scrollController.offset); + } + setState(() {}); + } + }, + onCopyTitle: () => + _copy(context, event.title), + ); + }, + ), + ); + }, + ), + ), + ), + // ── Detail Panel ── + // Only the detail panel listens to selection changes. + ValueListenableBuilder( + valueListenable: _selectedEventId, + builder: (context, selectedId, _) { + final selectedEvent = _findEvent(selectedId); + if (selectedEvent == null) { return const SizedBox.shrink(); } - final event = _events[actualIndex]; - final device = devices - .where( - (d) => d.deviceId == event.deviceId) - .firstOrNull; - return RepaintBoundary( - key: ValueKey(event.id), - child: ValueListenableBuilder( - valueListenable: _selectedEventId, - builder: (context, selectedId, _) { - final isSelected = selectedId == event.id; - return _EventRow( - event: event, - isSelected: isSelected, - showDetail: false, - platform: device?.platform, - onTap: () { - _selectedEventId.value = - isSelected ? null : event.id; - if (!isSelected && _autoScroll) { - _autoScroll = false; - _programmaticScroll = false; - if (_scrollController.hasClients) { - _scrollController.jumpTo(_scrollController.offset); - } - setState(() {}); - } - }, - onCopyTitle: () => - _copy(context, event.title), - ); - }, - ), + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + VerticalDivider( + width: 1, + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.08), + ), + SizedBox( + width: MediaQuery.of(context).size.width * + 0.45, + child: EventDetailPanel( + key: ValueKey(selectedEvent.id), + event: selectedEvent, + onClose: () => + _selectedEventId.value = null, + ), + ), + ], ); }, ), - ), + ], ), - // ── Detail Panel ── - // Only the detail panel listens to selection changes. - ValueListenableBuilder( - valueListenable: _selectedEventId, - builder: (context, selectedId, _) { - final selectedEvent = _findEvent(selectedId); - if (selectedEvent == null) return const SizedBox.shrink(); - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - VerticalDivider( - width: 1, - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.08), - ), - SizedBox( - width: MediaQuery.of(context).size.width * 0.45, - child: _EventDetailPanel( - key: ValueKey(selectedEvent.id), - event: selectedEvent, - onClose: () => _selectedEventId.value = null, - ), - ), - ], - ); - }, + PositionedJumpToLatestFab( + scrollController: _scrollController, + reversed: sortOrder == SortOrder.newestFirst, ), ], ), - PositionedJumpToLatestFab( - scrollController: _scrollController, - reversed: sortOrder == SortOrder.newestFirst, - ), - ], - ), - ), + ), ], ), // Draggable floating action button — sits on top of every panel @@ -512,7 +507,7 @@ class _AllEventsPageState extends ConsumerState { // latest" pill) so it's always reachable but never blocks data // because it lives at the screen edge by default and the user can // drag it anywhere within the viewport. - _DraggableReloadFab( + DraggableReloadFab( devices: devices, reloading: _reloading, hotRestarting: _hotRestarting, @@ -527,6726 +522,4 @@ class _AllEventsPageState extends ConsumerState { Clipboard.setData(ClipboardData(text: text)); showCopiedToast(context); } -} - -// ═══════════════════════════════════════════════ -// Header -// ═══════════════════════════════════════════════ - -class _Header extends ConsumerWidget { - final ValueNotifier eventCount; - final bool serverRunning; - final int port; - final int deviceCount; - final List devices; - final bool autoScroll; - final bool restarting; - final bool reloading; - final bool hotRestarting; - final VoidCallback onToggleAutoScroll; - final VoidCallback onClear; - final VoidCallback onReload; - final VoidCallback onReloadApp; - final VoidCallback onHotRestart; - - const _Header({ - required this.eventCount, - required this.serverRunning, - required this.port, - required this.deviceCount, - required this.devices, - required this.autoScroll, - required this.onToggleAutoScroll, - required this.onClear, - required this.restarting, - required this.onReload, - required this.reloading, - required this.hotRestarting, - required this.onReloadApp, - required this.onHotRestart, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - // Port-occupied detection: server failed to start (typically "Address - // already in use") AND is currently not running. - final startError = ref.watch(serverStartErrorProvider); - final portOccupied = !serverRunning && startError != null; - - return Container( - height: 48, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : Colors.white, - ), - child: Row( - children: [ - // ── Left: Title + meta ── - Icon(LucideIcons.activity, size: 16, color: ColorTokens.primary), - const SizedBox(width: 8), - Text('All Events', style: theme.textTheme.titleMedium), - const SizedBox(width: 8), - ValueListenableBuilder( - valueListenable: eventCount, - builder: (_, count, __) => Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.black.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - '$count', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - fontFamily: AppConstants.monoFontFamily, - color: isDark ? Colors.grey[400] : Colors.grey[600], - ), - ), - ), - ), - const SizedBox(width: 14), - // Unified server-status pill — port + device count + state - _ServerStatusPill( - serverRunning: serverRunning, - portOccupied: portOccupied, - port: port, - deviceCount: deviceCount, - startError: startError, - ), - - // Reload connection — sits next to the status pill so it's - // obvious that this action restarts the server (and therefore - // forces every connected SDK into its reconnect path). - const SizedBox(width: 8), - _ReloadPill( - restarting: restarting, - tooltip: S.of(context).restartServer, - onTap: restarting ? () {} : onReload, - ), - - const Spacer(), - - SizedBox( - width: 200, - child: SearchField( - hintText: S.of(context).searchEvents, - onChanged: (v) => - ref.read(allEventsSearchProvider.notifier).state = v, - ), - ), - const SizedBox(width: 12), - - // ── Action group ── - Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: isDark - ? Colors.white.withValues(alpha: 0.04) - : Colors.black.withValues(alpha: 0.04), - borderRadius: BorderRadius.circular(10), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Reload-app buttons were here — moved to a draggable - // floating FAB (see `_DraggableReloadFab` rendered in the - // page-level Stack) so the button stays out of the user's - // way during long debug sessions. - _IconBtn( - icon: LucideIcons.arrowDownToLine, - tooltip: S.of(context).autoScroll, - isActive: autoScroll, - onTap: onToggleAutoScroll, - ), - const SizedBox(width: 2), - Consumer( - builder: (context, ref, _) { - final sort = ref.watch(allEventsSortOrderProvider); - final isNewest = sort == SortOrder.newestFirst; - return _IconBtn( - icon: isNewest ? LucideIcons.arrowUpNarrowWide : LucideIcons.arrowDownNarrowWide, - tooltip: isNewest ? S.of(context).newestFirst : S.of(context).oldestFirst, - isActive: isNewest, - onTap: () => ref.read(allEventsSortOrderProvider.notifier).state = - isNewest ? SortOrder.oldestFirst : SortOrder.newestFirst, - ); - }, - ), - const SizedBox(width: 2), - Container( - width: 1, - height: 18, - color: isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.black.withValues(alpha: 0.08), - ), - const SizedBox(width: 2), - _IconBtn( - icon: LucideIcons.trash2, - tooltip: S.of(context).clearAll, - isDanger: true, - onTap: onClear, - ), - ], - ), - ), - ], - ), - ); - } -} - -// ═══════════════════════════════════════════════ -// Filter Bar -// ═══════════════════════════════════════════════ - -class _FilterBar extends ConsumerWidget { - final List events; - - const _FilterBar({required this.events}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final activeFilters = ref.watch(allEventsFilterProvider); - final enabledTabs = ref.watch(tabVisibilityProvider); - final errorsOnly = ref.watch(allEventsErrorsOnlyProvider); - - final logCount = events.where((e) => e.type == EventType.log).length; - final netCount = events.where((e) => e.type == EventType.network).length; - final stateCount = events.where((e) => e.type == EventType.state).length; - final storeCount = events.where((e) => e.type == EventType.storage).length; - final displayCount = events.where((e) => e.type == EventType.display).length; - final asyncCount = events.where((e) => e.type == EventType.asyncOp).length; - final errorCount = events.where((e) => e.level == 'error').length; - - // Only show filter chips for enabled tabs - final chips = []; - if (enabledTabs.contains(TabKey.console)) { - chips.add(_FilterChip( - label: 'LOG', - count: logCount, - icon: LucideIcons.terminal, - color: ColorTokens.logInfo, - isActive: activeFilters.contains(EventType.log), - onTap: () => _toggle(ref, EventType.log), - )); - } - if (enabledTabs.contains(TabKey.network)) { - if (chips.isNotEmpty) chips.add(const SizedBox(width: 8)); - chips.add(_FilterChip( - label: 'API', - count: netCount, - icon: LucideIcons.globe, - color: ColorTokens.success, - isActive: activeFilters.contains(EventType.network), - onTap: () => _toggle(ref, EventType.network), - )); - } - if (enabledTabs.contains(TabKey.state)) { - if (chips.isNotEmpty) chips.add(const SizedBox(width: 8)); - chips.add(_FilterChip( - label: 'STATE', - count: stateCount, - icon: LucideIcons.layers, - color: ColorTokens.secondary, - isActive: activeFilters.contains(EventType.state), - onTap: () => _toggle(ref, EventType.state), - )); - } - if (enabledTabs.contains(TabKey.storage)) { - if (chips.isNotEmpty) chips.add(const SizedBox(width: 8)); - chips.add(_FilterChip( - label: 'STORE', - count: storeCount, - icon: LucideIcons.database, - color: ColorTokens.warning, - isActive: activeFilters.contains(EventType.storage), - onTap: () => _toggle(ref, EventType.storage), - )); - } - // Display events (always visible — no dedicated tab) - if (displayCount > 0 || activeFilters.contains(EventType.display)) { - if (chips.isNotEmpty) chips.add(const SizedBox(width: 8)); - chips.add(_FilterChip( - label: 'DISPLAY', - count: displayCount, - icon: LucideIcons.monitor, - color: const Color(0xFF9B59B6), - isActive: activeFilters.contains(EventType.display), - onTap: () => _toggle(ref, EventType.display), - )); - } - // Async operation events (always visible — no dedicated tab) - if (asyncCount > 0 || activeFilters.contains(EventType.asyncOp)) { - if (chips.isNotEmpty) chips.add(const SizedBox(width: 8)); - chips.add(_FilterChip( - label: 'ASYNC', - count: asyncCount, - icon: LucideIcons.zap, - color: const Color(0xFFE67E22), - isActive: activeFilters.contains(EventType.asyncOp), - onTap: () => _toggle(ref, EventType.asyncOp), - )); - } - - return Container( - height: 44, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : ColorTokens.lightSurface, - border: Border( - bottom: BorderSide( - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06), - ), - ), - ), - child: Row( - children: [ - ...chips, - if (errorCount > 0) ...[ - const SizedBox(width: 10), - Container( - width: 1, - height: 18, - color: isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.black.withValues(alpha: 0.08), - ), - const SizedBox(width: 10), - _FilterChip( - label: 'ERRORS', - count: errorCount, - icon: LucideIcons.triangleAlert, - color: ColorTokens.error, - isActive: errorsOnly, - onTap: () => ref - .read(allEventsErrorsOnlyProvider.notifier) - .update((v) => !v), - ), - ], - const Spacer(), - ], - ), - ); - } - - void _toggle(WidgetRef ref, EventType type) { - final current = ref.read(allEventsFilterProvider); - if (current.contains(type)) { - ref.read(allEventsFilterProvider.notifier).state = - current.difference({type}); - } else { - ref.read(allEventsFilterProvider.notifier).state = {...current, type}; - } - } -} - -// ═══════════════════════════════════════════════ -// Event Row (optimized: fixed height, minimal rebuild) -// ═══════════════════════════════════════════════ - -class _EventRow extends StatelessWidget { - final UnifiedEvent event; - final bool isSelected; - final bool showDetail; - final String? platform; - final VoidCallback onTap; - final VoidCallback onCopyTitle; - - const _EventRow({ - super.key, - required this.event, - required this.isSelected, - required this.showDetail, - this.platform, - required this.onTap, - required this.onCopyTitle, - }); - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final time = DateFormat('HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch(event.timestamp), - ); - - final typeInfo = _typeInfo(event); - - // Left bar color: for network, show status-based color - Color leftBarColor = typeInfo.color; - if (event.type == EventType.network && event.rawData is NetworkEntry) { - final netEntry = event.rawData as NetworkEntry; - if (!netEntry.isComplete) { - leftBarColor = ColorTokens.warning; - } else if (netEntry.statusCode <= 0 || netEntry.statusCode >= 400) { - leftBarColor = ColorTokens.error; - } else { - leftBarColor = ColorTokens.success; - } - } - - // Status-based background for network requests - final isNetworkError = event.type == EventType.network && - event.rawData is NetworkEntry && - (event.rawData as NetworkEntry).isComplete && - ((event.rawData as NetworkEntry).statusCode <= 0 || - (event.rawData as NetworkEntry).statusCode >= 400); - final isNetworkInProgress = event.type == EventType.network && - event.rawData is NetworkEntry && - !(event.rawData as NetworkEntry).isComplete; - - final bgColor = isSelected - ? ColorTokens.selectedBg(isDark) - : isNetworkError - ? ColorTokens.error.withValues(alpha: isDark ? 0.08 : 0.05) - : isNetworkInProgress - ? ColorTokens.warning.withValues(alpha: isDark ? 0.08 : 0.05) - : isDark - ? Colors.transparent - : Colors.white; - - return GestureDetector( - onTap: onTap, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Container( - height: 44, - padding: const EdgeInsets.symmetric(horizontal: 14), - decoration: BoxDecoration( - color: bgColor, - border: Border( - bottom: BorderSide( - color: isDark - ? Colors.white.withValues(alpha: 0.03) - : Colors.black.withValues(alpha: 0.04), - ), - left: BorderSide( - color: isSelected ? ColorTokens.selectedAccent : leftBarColor, - width: isSelected ? 3 : 2, - ), - ), - ), - child: Row( - children: [ - // Time - SizedBox( - width: 84, - child: Text( - time, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 10, - color: Colors.grey[500], - letterSpacing: -0.3, - ), - ), - ), - // Type badge - Container( - width: 56, - height: 22, - decoration: BoxDecoration( - color: typeInfo.color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(typeInfo.icon, size: 10, color: typeInfo.color), - const SizedBox(width: 3), - Text( - typeInfo.label, - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w800, - color: typeInfo.color, - letterSpacing: 0.3, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - // Platform badge - if (platform != null) ...[ - PlatformBadge(platform: platform!), - const SizedBox(width: 8), - ], - // Title - Expanded( - child: Text( - event.title, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 12, - color: event.level == 'error' - ? ColorTokens.error - : isDark - ? ColorTokens.lightBackground - : ColorTokens.darkNeutral, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - if (event.type == EventType.network && - event.rawData is NetworkEntry && - (event.rawData as NetworkEntry).serviceName != null) ...[ - ServiceTag( - name: (event.rawData as NetworkEntry).serviceName!), - const SizedBox(width: 6), - ], - // Subtitle / Status indicator - if (!showDetail) ...[ - if (event.type == EventType.network && - event.rawData is NetworkEntry) ...[ - if (!(event.rawData as NetworkEntry).isComplete) ...[ - SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator( - strokeWidth: 1.5, - color: ColorTokens.warning, - ), - ), - const SizedBox(width: 5), - Text( - S.of(context).inProgress, - style: TextStyle( - fontSize: 10, - color: ColorTokens.warning, - fontFamily: AppConstants.monoFontFamily, - fontWeight: FontWeight.w600, - ), - ), - ] else ...[ - StatusBadge( - statusCode: - (event.rawData as NetworkEntry).statusCode), - const SizedBox(width: 6), - if ((event.rawData as NetworkEntry).duration != null) - Text( - formatDuration( - (event.rawData as NetworkEntry).duration!), - style: TextStyle( - fontSize: 10, - color: Colors.grey[500], - fontFamily: AppConstants.monoFontFamily, - ), - ), - ], - ] else - Text( - event.subtitle, - style: TextStyle( - fontSize: 10, - color: Colors.grey[500], - fontFamily: AppConstants.monoFontFamily, - ), - ), - ], - // Copy button (only visible on hover would be ideal, - // but for desktop quick-access is better) - const SizedBox(width: 4), - _MiniIconButton( - icon: LucideIcons.copy, - tooltip: 'Copy', - onTap: onCopyTitle, - ), - if (isSelected) ...[ - const SizedBox(width: 2), - Icon(LucideIcons.chevronRight, - size: 12, color: ColorTokens.primary), - ], - ], - ), - ), - ), - ); - } - - static _TypeInfo _typeInfo(UnifiedEvent event) { - switch (event.type) { - case EventType.log: - return _TypeInfo( - color: _logColor(event.level), - icon: LucideIcons.terminal, - label: event.level.toUpperCase(), - ); - case EventType.network: - return _TypeInfo( - color: event.level == 'error' - ? ColorTokens.error - : ColorTokens.success, - icon: LucideIcons.globe, - label: 'API', - ); - case EventType.state: - return _TypeInfo( - color: ColorTokens.secondary, - icon: LucideIcons.layers, - label: 'STATE', - ); - case EventType.storage: - return _TypeInfo( - color: ColorTokens.warning, - icon: LucideIcons.database, - label: 'STORE', - ); - case EventType.display: - return _TypeInfo( - color: const Color(0xFF9B59B6), - icon: LucideIcons.monitor, - label: 'DISPLAY', - ); - case EventType.asyncOp: - return _TypeInfo( - color: const Color(0xFFE67E22), - icon: LucideIcons.zap, - label: event.rawData is AsyncOperationEntry - ? (event.rawData as AsyncOperationEntry).status.name.toUpperCase() - : 'ASYNC', - ); - case EventType.error: - return _TypeInfo( - color: Colors.red, - icon: LucideIcons.alertTriangle, - label: 'ERROR', - ); - } - } - - static Color _logColor(String level) { - switch (level) { - case 'debug': - return ColorTokens.logDebug; - case 'warn': - return ColorTokens.logWarn; - case 'error': - return ColorTokens.logError; - default: - return ColorTokens.logInfo; - } - } -} - -// ═══════════════════════════════════════════════ -// Small UI Components -// ═══════════════════════════════════════════════ - -// ═══════════════════════════════════════════════ -// Detail Tab Bar -// ═══════════════════════════════════════════════ - -class _DetailTabBar extends StatelessWidget { - final TabController controller; - final bool isDark; - final Color accentColor; - final List tabs; - - const _DetailTabBar({ - required this.controller, - required this.isDark, - required this.accentColor, - required this.tabs, - }); - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: isDark - ? Colors.white.withValues(alpha: 0.05) - : Colors.black.withValues(alpha: 0.04), - borderRadius: BorderRadius.circular(8), - ), - child: TabBar( - controller: controller, - labelStyle: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - letterSpacing: 0.2, - ), - unselectedLabelStyle: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w500, - ), - labelColor: accentColor, - unselectedLabelColor: isDark ? Colors.grey[500] : Colors.grey[600], - indicatorSize: TabBarIndicatorSize.tab, - indicator: BoxDecoration( - color: isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.white, - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: isDark - ? accentColor.withValues(alpha: 0.25) - : Colors.black.withValues(alpha: 0.06), - ), - boxShadow: isDark - ? null - : [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 2, - offset: const Offset(0, 1), - ), - ], - ), - indicatorPadding: EdgeInsets.zero, - dividerHeight: 0, - splashFactory: NoSplash.splashFactory, - overlayColor: WidgetStateProperty.all(Colors.transparent), - padding: EdgeInsets.zero, - labelPadding: EdgeInsets.zero, - tabs: tabs - .map((t) => Tab(height: 28, text: t)) - .toList(), - ), - ); - } -} - -class _IconBtn extends StatefulWidget { - final IconData icon; - final String tooltip; - final bool isActive; - final bool isDanger; - final VoidCallback onTap; - - const _IconBtn({ - required this.icon, - required this.tooltip, - this.isActive = false, - this.isDanger = false, - required this.onTap, - }); - - @override - State<_IconBtn> createState() => _IconBtnState(); -} - -class _IconBtnState extends State<_IconBtn> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - - Color iconColor; - Color bgColor; - - if (widget.isActive) { - iconColor = ColorTokens.primary; - bgColor = ColorTokens.primary.withValues(alpha: 0.15); - } else if (widget.isDanger && _hovered) { - iconColor = ColorTokens.error; - bgColor = ColorTokens.error.withValues(alpha: 0.12); - } else if (_hovered) { - iconColor = isDark ? Colors.grey[300]! : Colors.grey[700]!; - bgColor = isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.black.withValues(alpha: 0.06); - } else { - iconColor = isDark ? Colors.grey[500]! : Colors.grey[500]!; - bgColor = Colors.transparent; - } - - return Tooltip( - message: widget.tooltip, - child: GestureDetector( - onTap: widget.onTap, - child: MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - width: 28, - height: 28, - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(7), - ), - child: Icon( - widget.icon, - size: 14, - color: iconColor, - ), - ), - ), - ), - ); - } -} - -/// Slightly larger icon button used in the header next to the port/device -/// counters (the server restart "Reload" button). -class _HeaderActionIcon extends StatefulWidget { - final IconData icon; - final String tooltip; - final bool spinning; - final VoidCallback onTap; - - const _HeaderActionIcon({ - required this.icon, - required this.tooltip, - required this.spinning, - required this.onTap, - }); - - @override - State<_HeaderActionIcon> createState() => _HeaderActionIconState(); -} - -class _HeaderActionIconState extends State<_HeaderActionIcon> - with SingleTickerProviderStateMixin { - late final AnimationController _spinCtrl; - bool _hovered = false; - - @override - void initState() { - super.initState(); - _spinCtrl = AnimationController( - vsync: this, - duration: const Duration(seconds: 1), - ); - if (widget.spinning) _spinCtrl.repeat(); - } - - @override - void didUpdateWidget(covariant _HeaderActionIcon old) { - super.didUpdateWidget(old); - if (widget.spinning && !_spinCtrl.isAnimating) { - _spinCtrl.repeat(); - } else if (!widget.spinning && _spinCtrl.isAnimating) { - _spinCtrl.reset(); - } - } - - @override - void dispose() { - _spinCtrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final iconColor = _hovered || widget.spinning - ? ColorTokens.primary - : (isDark ? Colors.grey[400]! : Colors.grey[600]!); - final bgColor = _hovered || widget.spinning - ? ColorTokens.primary.withValues(alpha: 0.12) - : Colors.transparent; - - return Tooltip( - message: widget.tooltip, - child: GestureDetector( - onTap: widget.spinning ? null : widget.onTap, - child: MouseRegion( - cursor: - widget.spinning ? SystemMouseCursors.basic : SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(6), - ), - child: RotationTransition( - turns: _spinCtrl, - child: Icon(widget.icon, size: 12, color: iconColor), - ), - ), - ), - ), - ); - } -} - -/// Compact status indicator for the All Events header. -/// -/// Single rounded pill that conveys server state + port + device count. -/// Dot gently pulses when the server is running (perpetual micro-motion), -/// turns warning amber if the port is occupied, error red if stopped. -class _ServerStatusPill extends StatefulWidget { - final bool serverRunning; - final bool portOccupied; - final int port; - final int deviceCount; - final String? startError; - - const _ServerStatusPill({ - required this.serverRunning, - required this.portOccupied, - required this.port, - required this.deviceCount, - required this.startError, - }); - - @override - State<_ServerStatusPill> createState() => _ServerStatusPillState(); -} - -class _ServerStatusPillState extends State<_ServerStatusPill> - with SingleTickerProviderStateMixin { - late final AnimationController _pulseCtrl; - - @override - void initState() { - super.initState(); - _pulseCtrl = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1800), - ); - if (widget.serverRunning) _pulseCtrl.repeat(reverse: true); - } - - @override - void didUpdateWidget(covariant _ServerStatusPill old) { - super.didUpdateWidget(old); - if (widget.serverRunning && !_pulseCtrl.isAnimating) { - _pulseCtrl.repeat(reverse: true); - } else if (!widget.serverRunning && _pulseCtrl.isAnimating) { - _pulseCtrl.stop(); - _pulseCtrl.value = 0; - } - } - - @override - void dispose() { - _pulseCtrl.dispose(); - super.dispose(); - } - - Color _accent() { - if (widget.portOccupied) return ColorTokens.warning; - if (!widget.serverRunning) return ColorTokens.error; - return ColorTokens.success; - } - - String _label() { - if (widget.portOccupied) return S.of(context).portOccupied(widget.port); - if (!widget.serverRunning) return S.of(context).stopped; - return 'Port ${widget.port}'; - } - - IconData _icon() { - if (widget.portOccupied) return LucideIcons.triangleAlert; - if (!widget.serverRunning) return LucideIcons.circlePause; - return LucideIcons.radio; - } - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final accent = _accent(); - final label = _label(); - final icon = _icon(); - - return Tooltip( - message: widget.portOccupied && widget.startError != null - ? widget.startError! - : label, - child: Container( - height: 28, - padding: const EdgeInsets.symmetric(horizontal: 10), - decoration: BoxDecoration( - color: isDark - ? accent.withValues(alpha: 0.08) - : accent.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: accent.withValues(alpha: 0.18), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Pulsing dot - AnimatedBuilder( - animation: _pulseCtrl, - builder: (_, __) { - final scale = widget.serverRunning - ? 1.0 + 0.4 * _pulseCtrl.value - : 1.0; - final alpha = widget.serverRunning - ? 1.0 - 0.5 * _pulseCtrl.value - : 1.0; - return Container( - width: 6, - height: 6, - decoration: BoxDecoration( - color: accent.withValues(alpha: alpha), - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: accent.withValues(alpha: 0.45 * alpha), - blurRadius: 6 * scale, - spreadRadius: 1 * scale, - ), - ], - ), - ); - }, - ), - const SizedBox(width: 6), - Icon(icon, size: 11, color: accent.withValues(alpha: 0.85)), - const SizedBox(width: 4), - Text( - label, - style: TextStyle( - fontSize: 11, - fontFamily: AppConstants.monoFontFamily, - color: accent.withValues(alpha: 0.92), - fontWeight: FontWeight.w600, - letterSpacing: 0.1, - ), - ), - if (widget.deviceCount > 0) ...[ - Container( - width: 1, - height: 12, - margin: const EdgeInsets.symmetric(horizontal: 8), - color: accent.withValues(alpha: 0.25), - ), - Text( - '${widget.deviceCount}', - style: TextStyle( - fontSize: 11, - fontFamily: AppConstants.monoFontFamily, - color: isDark ? Colors.grey[300] : Colors.grey[700], - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(width: 3), - Text( - widget.deviceCount == 1 ? 'device' : 'devices', - style: TextStyle( - fontSize: 10, - color: isDark ? Colors.grey[500] : Colors.grey[600], - ), - ), - ], - ], - ), - ), - ); - } -} - -/// Reload connection pill — sits next to the status pill and forces every -/// connected SDK into its reconnect path. Tactile feedback on press. -class _ReloadPill extends StatefulWidget { - final bool restarting; - final String tooltip; - final VoidCallback onTap; - - const _ReloadPill({ - required this.restarting, - required this.tooltip, - required this.onTap, - }); - - @override - State<_ReloadPill> createState() => _ReloadPillState(); -} - -class _ReloadPillState extends State<_ReloadPill> - with SingleTickerProviderStateMixin { - late final AnimationController _spinCtrl; - - @override - void initState() { - super.initState(); - _spinCtrl = AnimationController( - vsync: this, - duration: const Duration(seconds: 1), - ); - if (widget.restarting) _spinCtrl.repeat(); - } - - @override - void didUpdateWidget(covariant _ReloadPill old) { - super.didUpdateWidget(old); - if (widget.restarting && !_spinCtrl.isAnimating) { - _spinCtrl.repeat(); - } else if (!widget.restarting && _spinCtrl.isAnimating) { - _spinCtrl.reset(); - } - } - - @override - void dispose() { - _spinCtrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - return Tooltip( - message: widget.tooltip, - child: GestureDetector( - onTap: widget.restarting ? null : widget.onTap, - behavior: HitTestBehavior.opaque, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - height: 28, - padding: const EdgeInsets.symmetric(horizontal: 10), - decoration: BoxDecoration( - color: widget.restarting - ? ColorTokens.primary.withValues(alpha: isDark ? 0.14 : 0.10) - : isDark - ? Colors.white.withValues(alpha: 0.04) - : Colors.black.withValues(alpha: 0.04), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: widget.restarting - ? ColorTokens.primary.withValues(alpha: 0.35) - : isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - RotationTransition( - turns: _spinCtrl, - child: Icon( - LucideIcons.refreshCw, - size: 12, - color: widget.restarting - ? ColorTokens.primary - : (isDark ? Colors.grey[400] : Colors.grey[600]), - ), - ), - const SizedBox(width: 5), - Text( - widget.restarting ? S.of(context).restarting : widget.tooltip, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: widget.restarting - ? ColorTokens.primary - : (isDark ? Colors.grey[300] : Colors.grey[700]), - letterSpacing: 0.1, - ), - ), - ], - ), - ), - ), - ); - } -} - -class _TypeInfo { - final Color color; - final IconData icon; - final String label; - _TypeInfo({required this.color, required this.icon, required this.label}); -} - -class _FilterChip extends StatelessWidget { - final String label; - final int count; - final IconData icon; - final Color color; - final bool isActive; - final VoidCallback onTap; - - const _FilterChip({ - required this.label, - required this.count, - required this.icon, - required this.color, - required this.isActive, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return _PressableButton( - onTap: onTap, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: AnimatedContainer( - duration: const Duration(milliseconds: 120), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: isActive - ? color.withValues(alpha: 0.1) - : Colors.transparent, - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: isActive - ? color.withValues(alpha: 0.3) - : Colors.grey.withValues(alpha: 0.1), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, - size: 13, - color: isActive ? color : Colors.grey[600]), - const SizedBox(width: 5), - Text( - label, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - color: isActive ? color : Colors.grey[600], - letterSpacing: 0.3, - ), - ), - const SizedBox(width: 5), - Text( - '$count', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w800, - color: isActive - ? color.withValues(alpha: 0.7) - : Colors.grey[500], - ), - ), - ], - ), - ), - ), - ); - } -} - -// (old `_ReloadAppBtn` removed — replaced by `_DraggableReloadFab` below) - -/// Draggable floating action button — reload-app on connected devices. -/// -/// Sits in a top-layer [Stack] over the All Events page content. Lives at -/// the bottom-right by default and snaps to the nearest corner on release, -/// with the user's last position persisted via [AppPreferences]. -/// -/// Visual treatment per `design-taste-frontend-v1`: -/// -/// - True frosted glass: [BackdropFilter] blur, 1px inner border + -/// "1px inner highlight" gradient on top for edge refraction. -/// - No neon outer glow — drop shadow is tinted to the background hue. -/// - Idle footprint is a 40px circle, which expands into a pill (40 × N) -/// on hover when multiple reload actions are available (e.g. Flutter -/// → Hot Reload + Hot Restart). -/// - Drag handle is the whole surface; spring-physics snap on release. -/// - Spin animation while a reload is in flight; per-button independent -/// so Hot Reload + Hot Restart can run concurrently. -/// -/// ### Behaviour matrix -/// -/// | Devices | Visible actions | Icon(s) | -/// | --------------------- | -------------------------------------------- | --------------- | -/// | 0 devices | (button dimmed, no-op) | `zap` | -/// | Flutter only | Hot Reload + Hot Restart | `zap`, `refreshCcw` | -/// | RN only | Reload Metro | `rocket` | -/// | Android only | Rebuild | `hammer` | -/// | Mixed platforms | Reload app (sends `server:reload` to all) | `zap` | -class _DraggableReloadFab extends StatefulWidget { - final List devices; - final bool reloading; - final bool hotRestarting; - final VoidCallback onReload; - final VoidCallback onHotRestart; - - const _DraggableReloadFab({ - required this.devices, - required this.reloading, - required this.hotRestarting, - required this.onReload, - required this.onHotRestart, - }); - - @override - State<_DraggableReloadFab> createState() => _DraggableReloadFabState(); -} - -class _DraggableReloadFabState extends State<_DraggableReloadFab> - with SingleTickerProviderStateMixin { - /// Distance from the bottom-right corner of the parent (the All Events - /// page content). 20 = default 20px margin from bottom + right edges. - double _right = 20; - double _bottom = 20; - - bool _hovered = false; - bool _dragging = false; - - // Drag tracking — we record the starting screen position and the - // starting edge-distances, then compute new distances from the delta. - late double _dragStartRight; - late double _dragStartBottom; - late Offset _dragStartGlobal; - // Press feedback: a brief scale-down + opacity dip on the FAB. - bool _pressed = false; - - static const double _fabHeight = 44; - static const double _collapsedWidth = 44; - static const double _actionWidth = 38; // each action button is 38px wide - static const double _actionGap = 4; - static const double _edgeMargin = 20; // distance from screen edges - /// Combined height of the page chrome the FAB must never overlap: - /// • page header (48) — "All Events" title bar - /// • filter bar (44) — LOG / API / STATE / ... chip row - /// Mirrors the `Container(height: 48, ...)` at `_Header` and the - /// `Container(height: 44, ...)` at `_FilterBar` in this file; if you - /// change either, change the constants here. - static const double _headerHeight = 48; - static const double _filterBarHeight = 44; - - @override - void initState() { - super.initState(); - // Always start at the default position (bottom-right, 20px margin). - // Position is NOT persisted across launches — every cold start - // resets the FAB so the user always knows where to find it - // without hunting around the screen for a previously-dragged ghost. - _right = _edgeMargin; - _bottom = _edgeMargin; - // Eagerly allocate the controller so dispose() never trips over an - // uninitialised `late` field. The FAB can be mounted without ever - // triggering a reload (e.g. user has no devices), in which case the - // old lazy form was never read and dispose() would throw - // LateInitializationError. - _spinCtrl = AnimationController( - vsync: this, - duration: const Duration(seconds: 1), - ); - } - - // ── Drag handlers ──────────────────────────────────────────────────────── - // - // The FAB drops **wherever the user releases it** within a safe area: - // never above the header, never outside the viewport, never behind - // the dock. Position is intentionally NOT persisted — every launch - // starts fresh. - - void _onPanStart(DragStartDetails d) { - _dragStartRight = _right; - _dragStartBottom = _bottom; - _dragStartGlobal = d.globalPosition; - setState(() { - _dragging = true; - _pressed = true; - }); - } - - void _onPanUpdate(DragUpdateDetails d) { - final delta = d.globalPosition - _dragStartGlobal; - final media = MediaQuery.of(context); - final size = media.size; - - // Clamp so the FAB: - // • is at least [_edgeMargin] from each viewport edge, - // • never overlaps the page chrome — its top edge stays at least - // [_edgeMargin] below the filter bar bottom (= header + filter - // bar height from the top of the screen). This ensures the FAB - // can never float on top of the title or chip rows. - final minRight = _edgeMargin; - final maxRight = (size.width - _collapsedWidth - _edgeMargin) - .clamp(minRight, double.infinity); - final minBottom = _edgeMargin; - final maxBottom = (size.height - - _fabHeight - - _headerHeight - - _filterBarHeight - - _edgeMargin - - media.padding.bottom) - .clamp(minBottom, double.infinity); - - setState(() { - _right = (_dragStartRight - delta.dx).clamp(minRight, maxRight); - _bottom = (_dragStartBottom - delta.dy).clamp(minBottom, maxBottom); - }); - } - - Future _onPanEnd(DragEndDetails d) async { - setState(() { - _dragging = false; - _pressed = false; - }); - // Stay where the user dropped. Do NOT persist — see initState - // for the rationale. - } - - /// Dispatch a tap to the right action. We need this on the *outer* - /// GestureDetector (alongside the pan handlers) because a nested - /// GestureDetector around each action icon would out-compete the pan - /// recognizer in the gesture arena and silently break dragging. - /// - /// For a single-action FAB the whole surface triggers that one action. - /// For multi-action FABs (e.g. Flutter = Hot reload + Hot restart) we - /// pick the action by horizontal position — each action owns a strip - /// of width [_actionWidth] with [_actionGap] between strips. - void _onTapUp(TapUpDetails details) { - final actions = _actionsFor(); - if (actions.isEmpty) return; - - if (actions.length == 1) { - actions[0].onTap(); - return; - } - final localX = details.localPosition.dx; - for (var i = 0; i < actions.length; i++) { - final start = i * (_actionWidth + _actionGap); - final end = start + _actionWidth; - if (localX >= start && localX <= end) { - actions[i].onTap(); - return; - } - } - } - - // ── Action discovery ───────────────────────────────────────────────────── - - /// Returns the list of available reload actions for the currently - /// connected devices (in a stable, predictable order: hot reload before - /// hot restart). Mirrors the per-platform IDE hotkeys: - /// - /// Flutter → Hot Reload + Hot Restart - /// RN → Reload Metro - /// Android → Rebuild - /// Mixed → single universal "Reload app" - List<_FabAction> _actionsFor() { - if (widget.devices.isEmpty) return const []; - bool isFlutter(String p) => p.toLowerCase() == 'flutter'; - bool isRN(String p) { - final lo = p.toLowerCase(); - return lo == 'reactnative' || lo == 'react_native' || lo == 'rn'; - } - bool isAndroid(String p) => p.toLowerCase() == 'android'; - - final hasFlutter = widget.devices.any((d) => isFlutter(d.platform)); - final hasRN = widget.devices.any((d) => isRN(d.platform)); - final hasAndroid = widget.devices.any((d) => isAndroid(d.platform)); - final platforms = (hasFlutter ? 1 : 0) + - (hasRN ? 1 : 0) + - (hasAndroid ? 1 : 0); - - if (platforms > 1) { - // Mixed: universal reload only. - return [ - _FabAction( - icon: LucideIcons.zap, - tooltip: S.of(context).reloadApp, - spinning: widget.reloading, - onTap: widget.onReload, - ), - ]; - } - if (hasFlutter) { - return [ - _FabAction( - icon: LucideIcons.zap, - tooltip: S.of(context).reloadAppHotReload, - spinning: widget.reloading, - onTap: widget.onReload, - ), - _FabAction( - icon: LucideIcons.refreshCcw, - tooltip: S.of(context).reloadAppHotRestart, - spinning: widget.hotRestarting, - onTap: widget.onHotRestart, - ), - ]; - } - if (hasRN) { - return [ - _FabAction( - icon: LucideIcons.rocket, - tooltip: S.of(context).reloadAppMetro, - spinning: widget.reloading, - onTap: widget.onReload, - ), - ]; - } - if (hasAndroid) { - // Android: Activity.recreate() is a runtime state reset — NOT a real - // "rebuild" of the APK. We label and icon this the same as Flutter's - // "Hot restart" so the UI reflects what actually happens (full state - // reset, no code recompile). For the *real* Android rebuild the - // developer has to run gradle assembleDebug + reinstall — which is - // outside what a runtime SDK can trigger. - return [ - _FabAction( - icon: LucideIcons.refreshCcw, - tooltip: S.of(context).reloadAppHotRestart, - spinning: widget.reloading, - onTap: widget.onReload, - ), - ]; - } - return [ - _FabAction( - icon: LucideIcons.zap, - tooltip: S.of(context).reloadApp, - spinning: widget.reloading, - onTap: widget.onReload, - ), - ]; - } - - // ── Sizing ────────────────────────────────────────────────────────────── - - double _currentFabWidth() { - final actions = _actionsFor(); - if (actions.isEmpty) return _collapsedWidth; - // When multiple actions exist, the FAB expands on hover to show them. - // For single-action platforms it stays the same size (no expand needed). - if (actions.length > 1 && _hovered) { - return _actionWidth * actions.length + - _actionGap * (actions.length - 1); - } - return _collapsedWidth; - } - - // ── Build ─────────────────────────────────────────────────────────────── - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final actions = _actionsFor(); - - // When the FAB has multiple actions available (Flutter = Hot Reload + - // Hot Restart) the *width* animates from 44 → 80 on hover — but the - // *Row* underneath still tries to lay out every action. With a 44px - // container and an 80px-tall row of two 38px icons + 4px gap, the - // second one overflows invisibly until the user hovers. Drop it - // until the pill actually expands. - final visibleActions = (actions.length > 1 && !_hovered) - ? actions.take(1).toList() - : actions; - - final fabWidth = _currentFabWidth(); - final disabled = actions.isEmpty; - - // Shared spinner — whichever reload is in flight, the shared - // `_spinCtrl` ticks. Toggled once per build so the multi-action case - // doesn't fight itself (the old per-icon helper would reset the - // controller while the other icon was still spinning). - _syncSpinner(visibleActions.any((a) => a.spinning)); - - // Clamp in build() too, not just `_onPanUpdate` — if the user - // resizes the window smaller than the dragged position, the - // Positioned offsets need to be re-clamped to the new viewport or - // the FAB escapes the screen. - final media = MediaQuery.of(context); - final size = media.size; - final maxRight = (size.width - fabWidth - _edgeMargin) - .clamp(_edgeMargin, double.infinity); - final maxBottom = (size.height - - _fabHeight - - _headerHeight - - _filterBarHeight - - _edgeMargin - - media.padding.bottom) - .clamp(_edgeMargin, double.infinity); - final clampedRight = _right.clamp(_edgeMargin, maxRight); - final clampedBottom = _bottom.clamp(_edgeMargin, maxBottom); - - return Positioned( - // Plain Positioned (no AnimatedPositioned) so the FAB tracks the - // cursor instantly during drag. Any animation here would lag behind - // the pointer and feel sticky. - right: clampedRight, - bottom: clampedBottom, - child: AnimatedScale( - // Press feedback + slight grow while dragging. - scale: _pressed ? 0.94 : (_dragging ? 1.04 : 1.0), - duration: const Duration(milliseconds: 120), - curve: Curves.easeOut, - child: MouseRegion( - cursor: SystemMouseCursors.grab, - onEnter: (_) { - if (!_dragging) setState(() => _hovered = true); - }, - onExit: (_) { - if (!_dragging) setState(() => _hovered = false); - }, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onPanStart: _onPanStart, - onPanUpdate: _onPanUpdate, - onPanEnd: _onPanEnd, - // Single tap dispatch lives on the OUTER detector too — that way - // the inner per-icon GestureDetectors can't out-compete the - // pan recognizer in the gesture arena (which is what blocked - // the drag from working). Tap vs. drag is decided by Flutter's - // built-in slop: a quick release → onTapUp, a movement past - // the touch slop → onPanStart. - onTapUp: _onTapUp, - child: AnimatedContainer( - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - height: _fabHeight, - width: fabWidth, - decoration: _decoration(isDark, disabled, _hovered || _dragging), - child: Stack( - children: [ - // Inner top highlight — a 1px-tall gradient line simulating - // the refraction on a glass surface's top edge. Cheaper and - // crisper than a true inset shadow. - Positioned( - top: 0, - left: 10, - right: 10, - child: IgnorePointer( - child: Container( - height: 1, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.transparent, - (isDark ? Colors.white : Colors.white) - .withValues(alpha: isDark ? 0.35 : 0.7), - Colors.transparent, - ], - ), - ), - ), - ), - ), - // Action row - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (actions.isEmpty) - _fabIcon( - icon: LucideIcons.zap, - tooltip: S.of(context).reloadAppNoDevices, - spinning: false, - disabled: true, - isDark: isDark, - ) - else - for (int i = 0; i < visibleActions.length; i++) ...[ - if (i > 0) const SizedBox(width: _actionGap), - _fabIcon( - icon: visibleActions[i].icon, - tooltip: visibleActions[i].tooltip, - spinning: visibleActions[i].spinning, - disabled: false, - isDark: isDark, - ), - ], - ], - ), - ], - ), - ), - ), - ), - ), - ); - } - - /// Frosted-glass decoration. Three layers (top to bottom): - /// 1. Outer drop shadow — tinted to the background, never pure black, - /// no neon glow. - /// 2. Background fill — high-alpha neutral (260 of 255 alpha) so the - /// surface beneath shows through subtly without backdrop-blur cost. - /// For the *true* frosted look we wrap it in BackdropFilter so it - /// actually blurs what's behind when there's content (e.g. event - /// rows); we drop BackdropFilter when content is empty (no perf - /// cost on idle empty states). - /// 3. 1px border — translucent white in dark, translucent black in light. - BoxDecoration _decoration(bool isDark, bool disabled, bool emphasised) { - final surfaceColor = isDark - ? const Color(0xFF1B2129) - : const Color(0xFFFDFEFF); - return BoxDecoration( - color: disabled - ? surfaceColor.withValues(alpha: 0.72) - : surfaceColor.withValues(alpha: 0.92), - borderRadius: BorderRadius.circular(22), - border: Border.all( - color: isDark - ? Colors.white.withValues(alpha: 0.10) - : Colors.black.withValues(alpha: 0.06), - width: 1, - ), - boxShadow: [ - // Drop shadow, tinted to background. - BoxShadow( - color: isDark - ? Colors.black.withValues(alpha: 0.55) - : Colors.black.withValues(alpha: 0.18), - blurRadius: disabled ? 16 : 22, - spreadRadius: 0, - offset: const Offset(0, 6), - ), - // Slight forward "lift" when hovered/dragged — felt, not loud. - if (emphasised) - BoxShadow( - color: ColorTokens.primary.withValues(alpha: 0.18), - blurRadius: 20, - spreadRadius: -2, - offset: const Offset(0, 4), - ), - ], - ); - } - - /// One icon "slot" inside the pill. Spins while its action is in flight. - /// - /// IMPORTANT: this widget is purely visual. It does **not** wrap its child - /// in a [GestureDetector] — taps are dispatched by the outer FAB-level - /// handler via [_onTapUp] using horizontal position. Adding an inner - /// `GestureDetector(onTap: ...)` here would out-compete the outer pan - /// recognizer in the gesture arena and silently break dragging. - Widget _fabIcon({ - required IconData icon, - required String tooltip, - required bool spinning, - required bool disabled, - required bool isDark, - }) { - final color = disabled - ? (isDark ? Colors.grey[700]! : Colors.grey[400]!) - : (isDark ? Colors.grey[200]! : Colors.grey[800]!); - - final iconWidget = spinning - ? RotationTransition( - // Always drive from the shared `_spinCtrl` — whether it's - // actually animating or not is decided by `_syncSpinner()` in - // `build()`, not per-icon here. - turns: _spinCtrl, - child: Icon(icon, size: 17, color: color), - ) - : Icon(icon, size: 17, color: color); - - return Tooltip( - message: tooltip, - child: SizedBox( - width: _actionWidth, - height: _fabHeight, - child: Center(child: iconWidget), - ), - ); - } - - // Spinner — one controller, repeats when in flight. Cheap because every - // icon uses the same controller (visual only; not part of any layout - // pass that affects the parent). Initialised in initState() (not as a - // `late final` with an initializer) so dispose() never trips over an - // uninitialised controller — the FAB can be mounted without ever - // triggering a reload, in which case the old lazy form threw - // LateInitializationError when the widget tree was torn down. - late AnimationController _spinCtrl; - - /// Single source of truth for the shared spinner: any action currently - /// in flight → repeat; otherwise → stop. Called once per build so the - /// multi-action case doesn't fight itself (the old `_spinController(bool)` - /// helper toggled start/stop per icon and the second action would reset - /// the controller while the first was still spinning). - void _syncSpinner(bool shouldSpin) { - if (shouldSpin && !_spinCtrl.isAnimating) { - _spinCtrl.repeat(); - } else if (!shouldSpin && _spinCtrl.isAnimating) { - _spinCtrl.stop(); - } - } - - @override - void dispose() { - _spinCtrl.dispose(); - super.dispose(); - } -} - -class _FabAction { - final IconData icon; - final String tooltip; - final bool spinning; - final VoidCallback onTap; - - const _FabAction({ - required this.icon, - required this.tooltip, - required this.spinning, - required this.onTap, - }); -} -/// -/// Same shape and 28x28 size as every other [_IconBtn] in the action group, -/// same hover/active visuals, info via tooltip on hover — just with -/// a spinning icon while the reload is in flight. -/// -/// The icon + tooltip + callback are passed in from the caller; this widget -/// only handles the visual state (hover / spin / disabled). -class _ReloadAppBtn extends StatefulWidget { - final IconData icon; - final String tooltip; - final bool reloading; - final bool disabled; - final VoidCallback onTap; - - const _ReloadAppBtn({ - required this.icon, - required this.tooltip, - required this.reloading, - required this.disabled, - required this.onTap, - }); - - @override - State<_ReloadAppBtn> createState() => _ReloadAppBtnState(); -} - -class _ReloadAppBtnState extends State<_ReloadAppBtn> - with SingleTickerProviderStateMixin { - bool _hovered = false; - late final AnimationController _spinCtrl; - - @override - void initState() { - super.initState(); - _spinCtrl = AnimationController( - vsync: this, - duration: const Duration(seconds: 1), - ); - if (widget.reloading) _spinCtrl.repeat(); - } - - @override - void didUpdateWidget(covariant _ReloadAppBtn old) { - super.didUpdateWidget(old); - if (widget.reloading && !_spinCtrl.isAnimating) { - _spinCtrl.repeat(); - } else if (!widget.reloading && _spinCtrl.isAnimating) { - _spinCtrl.reset(); - } - } - - @override - void dispose() { - _spinCtrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final active = widget.reloading || _hovered; - - // Match `_IconBtn`'s color logic exactly — keeps the action group visually - // coherent so this button doesn't stand out as "the odd one". - Color iconColor; - Color bgColor; - if (widget.disabled) { - iconColor = isDark ? Colors.grey[700]! : Colors.grey[400]!; - bgColor = Colors.transparent; - } else if (active) { - iconColor = ColorTokens.primary; - bgColor = ColorTokens.primary.withValues(alpha: 0.15); - } else if (_hovered) { - iconColor = isDark ? Colors.grey[300]! : Colors.grey[700]!; - bgColor = isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.black.withValues(alpha: 0.06); - } else { - iconColor = isDark ? Colors.grey[500]! : Colors.grey[500]!; - bgColor = Colors.transparent; - } - - return Tooltip( - message: widget.tooltip, - child: GestureDetector( - onTap: widget.disabled ? null : widget.onTap, - child: MouseRegion( - cursor: widget.disabled - ? SystemMouseCursors.basic - : SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - width: 28, - height: 28, - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(7), - ), - child: RotationTransition( - turns: _spinCtrl, - child: Icon(widget.icon, size: 14, color: iconColor), - ), - ), - ), - ), - ); - } -} - -/// Decides which reload buttons to render in the action group based on the -/// connected devices' platforms. -/// -/// Mirrors what each SDK's IDE offers: -/// • **Flutter only** → Hot Reload (`zap`) + Hot Restart (`refreshCcw`) -/// • **React Native only** → Reload Metro (`rocket`) -/// • **Native Android only** → Rebuild (`hammer`) -/// • **Mixed platforms** → single universal Reload (`zap`) -/// • **No devices** → no buttons -/// -/// Each button is icon-only; tooltips appear on hover, like every other -/// button in this action group. Returns a list of `[btn, gap, btn, gap, ...]` -/// ready to splice into the action group's child list. -// (removed — replaced by _DraggableReloadFab) - -class _MiniIconButton extends StatelessWidget { - final IconData icon; - final String tooltip; - final VoidCallback onTap; - - const _MiniIconButton({ - required this.icon, - required this.tooltip, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Tooltip( - message: tooltip, - child: GestureDetector( - onTap: onTap, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), - color: Colors.grey.withValues(alpha: 0.06), - ), - child: Icon(icon, size: 11, color: Colors.grey[500]), - ), - ), - ), - ); - } -} - -// ═══════════════════════════════════════════════ -// Detail Panel -// ═══════════════════════════════════════════════ - -class _EventDetailPanel extends StatefulWidget { - final UnifiedEvent event; - final VoidCallback onClose; - - const _EventDetailPanel({super.key, required this.event, required this.onClose}); - - @override - State<_EventDetailPanel> createState() => _EventDetailPanelState(); -} - -class _EventDetailPanelState extends State<_EventDetailPanel> { - int _currentTabIndex = 0; - bool _currentJsonMode = false; - final _contentKey = GlobalKey(); - - Future _captureAndSave(Widget screenshotWidget, - {String? fileName}) async { - try { - // Show capture flash animation - _showCaptureFlash(); - - final overlayKey = GlobalKey(); - final theme = Theme.of(context); - - late OverlayEntry overlayEntry; - overlayEntry = OverlayEntry( - builder: (_) => Positioned( - left: -10000, - top: 0, - child: RepaintBoundary( - key: overlayKey, - child: Theme( - data: theme, - child: Material( - child: SizedBox( - width: 600, - child: screenshotWidget, - ), - ), - ), - ), - ), - ); - - Overlay.of(context).insert(overlayEntry); - await Future.delayed(const Duration(milliseconds: 600)); - - final boundary = overlayKey.currentContext?.findRenderObject() - as RenderRepaintBoundary?; - if (boundary == null) { - overlayEntry.remove(); - return; - } - - final image = await boundary.toImage(pixelRatio: 3.0); - final byteData = - await image.toByteData(format: ui.ImageByteFormat.png); - overlayEntry.remove(); - - if (byteData == null) return; - - final pngBytes = byteData.buffer.asUint8List(); - - final baseName = (fileName == null || fileName.isEmpty) - ? 'dcmt_${DateTime.now().millisecondsSinceEpoch}' - : fileName; - final withExt = - baseName.endsWith('.png') ? baseName : '$baseName.png'; - - final location = await getSaveLocation( - suggestedName: withExt, - acceptedTypeGroups: [ - const XTypeGroup(label: 'PNG Image', extensions: ['png']), - ], - ); - - if (location == null) return; - - // Force saved file's name to withExt regardless of what OS returns. - final savedPath = _ensureFilename(location.path, withExt); - final xfile = XFile.fromData( - pngBytes, - mimeType: 'image/png', - name: withExt, - length: pngBytes.lengthInBytes, - ); - await xfile.saveTo(savedPath); - - if (mounted) showScreenshotSavedToast(context, filePath: savedPath); - } catch (e) { - if (mounted) _showErrorToast('$e'); - } - } - - String _ensureFilename(String path, String desiredName) { - final sep = path.contains(r'\') ? r'\' : '/'; - final last = path.lastIndexOf(sep); - if (last == -1) return '$path$sep$desiredName'; - return '${path.substring(0, last + 1)}$desiredName'; - } - - void _showCaptureFlash() { - final overlay = Overlay.of(context); - late OverlayEntry flashEntry; - flashEntry = OverlayEntry( - builder: (_) => TweenAnimationBuilder( - tween: Tween(begin: 0.35, end: 0.0), - duration: const Duration(milliseconds: 350), - curve: Curves.easeOut, - onEnd: () { - if (flashEntry.mounted) flashEntry.remove(); - }, - builder: (context, value, _) => IgnorePointer( - child: Container( - color: Colors.white.withValues(alpha: value), - ), - ), - ), - ); - overlay.insert(flashEntry); - } - - void _showSavedToast(String path) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final overlay = Overlay.of(context); - late OverlayEntry entry; - entry = OverlayEntry( - builder: (_) => Positioned( - bottom: 32, - left: 0, - right: 0, - child: Center( - child: TweenAnimationBuilder( - tween: Tween(begin: 0, end: 1), - duration: const Duration(milliseconds: 400), - curve: Curves.easeOutCubic, - builder: (context, value, child) => Opacity( - opacity: value, - child: Transform.translate( - offset: Offset(0, 20 * (1 - value)), - child: Transform.scale( - scale: 0.92 + 0.08 * value, - child: child, - ), - ), - ), - child: Material( - color: Colors.transparent, - child: Container( - constraints: const BoxConstraints(maxWidth: 380), - decoration: BoxDecoration( - color: isDark - ? const Color(0xFF131A24) - : const Color(0xFFFFFFFF), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06), - ), - boxShadow: [ - // Ambient shadow - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 32, - offset: const Offset(0, 8), - ), - // Subtle glow - if (isDark) - BoxShadow( - color: ColorTokens.success.withValues(alpha: 0.08), - blurRadius: 40, - spreadRadius: -4, - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Top accent bar - Container( - height: 3, - margin: const EdgeInsets.symmetric(horizontal: 40), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - ColorTokens.success.withValues(alpha: 0.0), - ColorTokens.success, - ColorTokens.success.withValues(alpha: 0.0), - ], - ), - borderRadius: const BorderRadius.vertical( - top: Radius.circular(16), - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 12, 14), - child: Row( - children: [ - // Success icon with glow - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - ColorTokens.success.withValues(alpha: 0.2), - ColorTokens.success.withValues(alpha: 0.08), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: ColorTokens.success - .withValues(alpha: 0.2), - ), - ), - child: const Icon( - LucideIcons.checkCheck, - size: 18, - color: ColorTokens.success, - ), - ), - const SizedBox(width: 12), - // Text content - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - TextComponent( - S.of(context).screenshotSaved, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: isDark - ? ColorTokens.lightBackground - : const Color(0xFF1E293B), - letterSpacing: -0.2, - ), - ), - const SizedBox(height: 2), - TextComponent( - path.split('/').last, - style: TextStyle( - fontSize: 11, - fontFamily: AppConstants.monoFontFamily, - color: isDark - ? Colors.grey[500] - : Colors.grey[600], - ), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - const SizedBox(width: 8), - // Show in Finder button - _PressableButton( - onTap: () { - entry.remove(); - Process.run('open', ['-R', path]); - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 14, vertical: 7), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: isDark - ? [ - const Color(0xFF1A2332), - const Color(0xFF1E2A3A), - ] - : [ - const Color(0xFFF0F4F8), - const Color(0xFFE8EDF2), - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: isDark - ? Colors.white.withValues(alpha: 0.1) - : Colors.black.withValues(alpha: 0.08), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - LucideIcons.folderOpen, - size: 13, - color: isDark - ? ColorTokens.lightBackground - : const Color(0xFF374151), - ), - const SizedBox(width: 6), - TextComponent( - S.of(context).reveal, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: isDark - ? ColorTokens.lightBackground - : const Color(0xFF374151), - ), - ), - ], - ), - ), - ), - const SizedBox(width: 4), - // Close button - _PressableButton( - onTap: () => entry.remove(), - child: Container( - width: 28, - height: 28, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - color: isDark - ? Colors.white.withValues(alpha: 0.04) - : Colors.black.withValues(alpha: 0.04), - ), - child: Icon(LucideIcons.x, - size: 13, - color: isDark - ? Colors.grey[600] - : Colors.grey[400]), - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ), - ), - ); - overlay.insert(entry); - Future.delayed(const Duration(seconds: 5), () { - if (entry.mounted) entry.remove(); - }); - } - - void _showErrorToast(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: TextComponent('Screenshot failed: $message'), - duration: const Duration(seconds: 2), - ), - ); - } - - Future _takeFullScreenshot() async { - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - final widget_ = _buildScreenshotWidget(theme, isDark); - final fileName = _buildEventScreenshotName('_full'); - await _captureAndSave(widget_, fileName: fileName); - } - - /// Builds a descriptive file name for event screenshots: - /// `___.png` - /// Falls back gracefully when key metadata is missing. - /// Note: appName is intentionally NOT included — screenshots may be - /// shared with clients and the internal app identifier must not leak. - String _buildEventScreenshotName(String suffix) { - final event = widget.event; - final type = event.type.name; - - // Pick a meaningful subject: storage key, network URL path, log tag, etc. - String subject = event.title; - if (event.rawData is StorageEntry) { - subject = (event.rawData as StorageEntry).key; - } else if (event.rawData is NetworkEntry) { - final url = (event.rawData as NetworkEntry).url; - try { - subject = Uri.parse(url).path.isEmpty ? url : Uri.parse(url).path; - } catch (_) { - subject = url; - } - } else if (event.rawData is LogEntry) { - final tag = (event.rawData as LogEntry).tag; - if (tag != null && tag.isNotEmpty) subject = tag; - } else if (event.rawData is StateChange) { - final sc = event.rawData as StateChange; - subject = sc.actionName.isNotEmpty - ? sc.actionName - : sc.stateManagerType; - } - - return buildRichScreenshotName( - type: type, - subject: subject, - suffix: suffix, - ); - } - - Future _takeTabScreenshot() async { - await _captureLiveContent(); - } - - /// Captures the currently visible content (with user's expand/collapse state). - Future _captureLiveContent() async { - try { - _showCaptureFlash(); - - final boundary = _contentKey.currentContext?.findRenderObject() - as RenderRepaintBoundary?; - if (boundary == null) return; - - final image = await boundary.toImage(pixelRatio: 3.0); - final byteData = - await image.toByteData(format: ui.ImageByteFormat.png); - if (byteData == null) return; - - final pngBytes = byteData.buffer.asUint8List(); - final fileName = - 'dcmt_${DateTime.now().millisecondsSinceEpoch}.png'; - final location = await getSaveLocation( - suggestedName: fileName, - acceptedTypeGroups: [ - const XTypeGroup(label: 'PNG Image', extensions: ['png']), - ], - ); - if (location == null) return; - - final file = File(location.path); - await file.writeAsBytes(pngBytes); - if (mounted) _showSavedToast(file.path); - } catch (e) { - if (mounted) _showErrorToast('$e'); - } - } - - /// Builds the full detail widget for screenshot (no scroll constraints). - Widget _buildScreenshotWidget(ThemeData theme, bool isDark) { - final event = widget.event; - final time = DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch(event.timestamp), - ); - - Color typeColor; - IconData typeIcon; - String typeLabel; - switch (event.type) { - case EventType.log: - typeColor = ColorTokens.logInfo; - typeIcon = LucideIcons.terminal; - typeLabel = S.of(context).logDetail; - break; - case EventType.network: - typeColor = ColorTokens.success; - typeIcon = LucideIcons.globe; - typeLabel = S.of(context).networkDetail; - break; - case EventType.state: - typeColor = ColorTokens.secondary; - typeIcon = LucideIcons.layers; - typeLabel = S.of(context).stateDetail; - break; - case EventType.storage: - typeColor = ColorTokens.warning; - typeIcon = LucideIcons.database; - typeLabel = S.of(context).storageDetail; - break; - case EventType.display: - typeColor = const Color(0xFF9B59B6); - typeIcon = LucideIcons.monitor; - typeLabel = S.of(context).displayDetail; - break; - case EventType.asyncOp: - typeColor = const Color(0xFFE67E22); - typeIcon = LucideIcons.zap; - typeLabel = S.of(context).asyncOperation; - break; - case EventType.error: - typeColor = Colors.red; - typeIcon = LucideIcons.alertTriangle; - typeLabel = S.of(context).errorDetail; - break; - } - - return Container( - color: isDark ? ColorTokens.darkSurface : ColorTokens.lightSurface, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - height: 44, - padding: const EdgeInsets.symmetric(horizontal: 14), - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : Colors.white, - ), - child: Row( - children: [ - Icon(typeIcon, size: 14, color: typeColor), - const SizedBox(width: 8), - TextComponent( - typeLabel, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: typeColor, - ), - ), - const Spacer(), - TextComponent( - time, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 10, - color: Colors.grey[500], - ), - ), - ], - ), - ), - const Divider(height: 1), - _buildScreenshotContent(isDark), - ], - ), - ); - } - - /// Content for screenshot — no SingleChildScrollView, no Expanded. - /// Tabbed views are rendered as stacked sections. - Widget _buildScreenshotContent(bool isDark) { - switch (widget.event.type) { - case EventType.log: - if (widget.event.rawData is LogEntry) { - return _logScreenshot(widget.event.rawData as LogEntry, isDark); - } - return _fallbackScreenshot(widget.event, isDark); - case EventType.network: - if (widget.event.rawData is NetworkEntry) { - return _networkScreenshot( - widget.event.rawData as NetworkEntry, isDark); - } - return _fallbackScreenshot(widget.event, isDark); - case EventType.state: - if (widget.event.rawData is StateChange) { - return _stateScreenshot( - widget.event.rawData as StateChange, isDark); - } - return _fallbackScreenshot(widget.event, isDark); - case EventType.storage: - if (widget.event.rawData is StorageEntry) { - return _storageScreenshot( - widget.event.rawData as StorageEntry, isDark); - } - return _fallbackScreenshot(widget.event, isDark); - case EventType.display: - case EventType.asyncOp: - case EventType.error: - return _fallbackScreenshot(widget.event, isDark); - } - } - - Widget _logScreenshot(LogEntry entry, bool isDark) { - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - LogLevelBadge(level: entry.level.name), - if (entry.tag != null) ...[ - const SizedBox(width: 8), - _TagChip(entry.tag!), - ], - ]), - const SizedBox(height: 16), - const _SectionLabel('Message'), - const SizedBox(height: 6), - _CodeBlock(text: entry.message, isDark: isDark), - if (entry.metadata != null && entry.metadata!.isNotEmpty) ...[ - const SizedBox(height: 16), - const _SectionLabel('Metadata'), - const SizedBox(height: 6), - JsonViewer(data: entry.metadata, initiallyExpanded: true), - ], - if (entry.stackTrace != null) ...[ - const SizedBox(height: 16), - const _SectionLabel('Stack Trace'), - const SizedBox(height: 6), - _ErrorBlock(text: entry.stackTrace!, isDark: isDark), - ], - ], - ), - ); - } - - Widget _networkScreenshot(NetworkEntry entry, bool isDark) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // URL bar - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : Colors.white, - border: Border( - bottom: BorderSide( - color: isDark - ? Colors.white.withValues(alpha: 0.05) - : Colors.black.withValues(alpha: 0.06), - ), - ), - ), - child: Row( - children: [ - HttpMethodBadge(method: entry.method), - const SizedBox(width: 8), - if (entry.isComplete) ...[ - StatusBadge(statusCode: entry.statusCode), - const SizedBox(width: 8), - ], - Expanded( - child: TextComponent( - entry.url, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: - isDark ? ColorTokens.lightBackground : Colors.black87, - ), - ), - ), - ], - ), - ), - // Timing summary - if (entry.duration != null) - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: _TimingBar(duration: entry.duration!), - ), - const Divider(height: 1), - // Headers - Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _SectionLabel('REQUEST HEADERS'), - const SizedBox(height: 8), - _HeaderTable(headers: entry.requestHeaders, isScreenshot: true), - const SizedBox(height: 20), - const _SectionLabel('RESPONSE HEADERS'), - const SizedBox(height: 8), - _HeaderTable(headers: entry.responseHeaders, isScreenshot: true), - ], - ), - ), - const Divider(height: 1), - // Request body - if (entry.requestBody != null) - Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _SectionLabel('REQUEST BODY'), - const SizedBox(height: 8), - if (_currentJsonMode || - !(entry.requestBody is Map || entry.requestBody is List)) - JsonPrettyViewer(data: entry.requestBody) - else - JsonViewer( - data: entry.requestBody, initiallyExpanded: true), - ], - ), - ), - if (entry.requestBody != null) const Divider(height: 1), - // Response body - if (entry.responseBody != null) - Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _SectionLabel('RESPONSE BODY'), - const SizedBox(height: 8), - if (_currentJsonMode || - !(entry.responseBody is Map || entry.responseBody is List)) - JsonPrettyViewer(data: entry.responseBody) - else - JsonViewer( - data: entry.responseBody, initiallyExpanded: true), - ], - ), - ), - if (entry.responseBody != null) const Divider(height: 1), - // Timing details - Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _SectionLabel('TIMING'), - const SizedBox(height: 8), - _InfoRow( - 'Start Time', - DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch(entry.startTime), - ), - ), - if (entry.endTime != null) - _InfoRow( - 'End Time', - DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch(entry.endTime!), - ), - ), - if (entry.duration != null) - _InfoRow('Duration', formatDuration(entry.duration!)), - if (entry.error != null) ...[ - const SizedBox(height: 12), - const _SectionLabel('Error'), - const SizedBox(height: 6), - _ErrorBlock(text: entry.error!, isDark: isDark), - ], - ], - ), - ), - ], - ); - } - - Widget _stateScreenshot(StateChange entry, bool isDark) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: - const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: Row( - children: [ - _TagChip(entry.stateManagerType, - color: ColorTokens.secondary), - const SizedBox(width: 8), - Expanded( - child: TextComponent( - entry.actionName, - style: const TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ), - const Divider(height: 1), - // Diff - if (entry.diff.isNotEmpty) - Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _SectionLabel('DIFF'), - const SizedBox(height: 8), - ...entry.diff.map((d) => _DiffRow(diff: d)), - ], - ), - ), - if (entry.diff.isNotEmpty) const Divider(height: 1), - // Previous state - if (entry.previousState.isNotEmpty) - Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _SectionLabel('PREVIOUS STATE'), - const SizedBox(height: 8), - JsonViewer( - data: entry.previousState, initiallyExpanded: true), - ], - ), - ), - if (entry.previousState.isNotEmpty) const Divider(height: 1), - // Next state - if (entry.nextState.isNotEmpty) - Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _SectionLabel('NEXT STATE'), - const SizedBox(height: 8), - JsonViewer( - data: entry.nextState, initiallyExpanded: true), - ], - ), - ), - ], - ); - } - - Widget _storageScreenshot(StorageEntry entry, bool isDark) { - // Operation color matches the in-app panel: emerald/blue/red/amber. - Color opColorFor(StorageEntry e) { - switch (e.operation.toLowerCase()) { - case 'write': - return const Color(0xFF34D399); - case 'read': - return const Color(0xFF60A5FA); - case 'delete': - case 'clear': - return const Color(0xFFF87171); - default: - return const Color(0xFFFBBF24); - } - } - - // Resolve platform from connected devices for code-mode export - final devices = ProviderScope.containerOf(context, listen: false) - .read(connectedDevicesProvider); - final platform = devices - .where((d) => d.deviceId == entry.deviceId) - .map((d) => d.platform) - .firstOrNull ?? - 'react_native'; - final codeLang = CodeGenerator.langForPlatform(platform); - final codeLabel = CodeGenerator.labelFor(codeLang); - - // Respect 3 view modes from global provider - final mode = ProviderScope.containerOf(context, listen: false) - .read(bodyViewModeProvider); - - // Design tokens - final labelColor = isDark ? Colors.grey[500] : Colors.grey[600]; - final dividerColor = isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06); - - // ── Helpers ───────────────────────────────────────────── - String formatShape() { - final v = entry.value; - if (v == null) return 'null'; - if (v is Map) return 'Map · ${v.length} ${v.length == 1 ? "key" : "keys"}'; - if (v is List) return 'List · ${v.length} ${v.length == 1 ? "item" : "items"}'; - if (v is String) { - if (v.isEmpty) return 'String · empty'; - final t = v.trim(); - if ((t.startsWith('{') && t.endsWith('}')) || - (t.startsWith('[') && t.endsWith(']'))) { - return 'String · JSON-shaped'; - } - return 'String'; - } - return v.runtimeType.toString(); - } - - String formatSize() { - final raw = entry.value is String - ? entry.value as String - : const JsonEncoder.withIndent(' ').convert(entry.value); - return AppConstants.formatBytes(raw.length); - } - - dynamic parseJson() { - final v = entry.value; - if (v is! String) return null; - try { - final p = jsonDecode(v); - if (p is Map || p is List) return p; - } catch (_) {} - return null; - } - - dynamic displayValue() { - final v = entry.value; - if (v is Map || v is List) return v; - return parseJson() ?? v; - } - - bool isJsonLike() { - final v = entry.value; - if (v is Map || v is List) return true; - return parseJson() != null; - } - - Widget buildValueWidget() { - if (!isJsonLike()) { - return _CodeBlock(text: '${entry.value}', isDark: isDark); - } - final value = displayValue(); - return switch (mode) { - BodyViewMode.tree => - JsonViewer(data: value, initiallyExpanded: true), - BodyViewMode.json => JsonPrettyViewer(data: value), - BodyViewMode.code => CodeViewer( - generated: CodeGenerator.generate(value, codeLang), - lang: codeLang, - languageLabel: codeLabel, - ), - }; - } - - // Match the in-app metadata bento grid (2x2: SHAPE/SIZE on row 1, - // DEVICE/CAPTURED on row 2). Each cell has uppercase label + value. - final monoPrimary = TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 13, - height: 1.5, - color: isDark ? const Color(0xFFE8E8E8) : const Color(0xFF1A1A1A), - ); - final monoSecondary = TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - height: 1.5, - color: labelColor, - ); - final metaLabelStyle = TextStyle( - fontSize: 10, - fontWeight: FontWeight.w700, - letterSpacing: 1.2, - color: labelColor, - ); - - Widget metaCell(String label, String value, TextStyle valueStyle, - {bool monospace = false}) => - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextComponent(label, style: metaLabelStyle), - const SizedBox(height: 4), - TextComponent( - value, - style: monospace - ? valueStyle.copyWith(fontFamily: AppConstants.monoFontFamily) - : valueStyle, - ), - ], - ); - - return Container( - color: isDark ? ColorTokens.darkSurface : ColorTokens.lightSurface, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ── Badges (mirror _StorageDetailRedesign header row) ── - Padding( - padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _OpBadge(label: entry.operation, color: opColorFor(entry)), - const SizedBox(width: 8), - _TypeBadge(label: entry.storageType.name), - ], - ), - ), - // ── Metadata (matches the in-app bento grid) ────────── - Padding( - padding: const EdgeInsets.fromLTRB(20, 22, 20, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextComponent('METADATA', style: metaLabelStyle), - const SizedBox(height: 10), - // Row 1: SHAPE / SIZE - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: metaCell('SHAPE', formatShape(), monoPrimary), - ), - const SizedBox(width: 12), - Expanded( - child: metaCell('SIZE', formatSize(), monoPrimary), - ), - ], - ), - const SizedBox(height: 12), - // Row 2: DEVICE / CAPTURED - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: metaCell( - 'DEVICE', entry.deviceId, monoSecondary, - monospace: true), - ), - const SizedBox(width: 12), - Expanded( - child: metaCell( - 'CAPTURED', - DateFormat('HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch( - entry.timestamp), - ), - monoPrimary, - ), - ), - ], - ), - ], - ), - ), - // ── Divider ─────────────────────────────────────────── - Padding( - padding: const EdgeInsets.fromLTRB(20, 22, 20, 0), - child: Container(height: 1, color: dividerColor), - ), - // ── Key ─────────────────────────────────────────────── - Padding( - padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionLabel('Key'), - const SizedBox(height: 6), - _CodeBlock(text: entry.key, isDark: isDark), - ], - ), - ), - // ── Value (3 view modes when JSON-like) ────────────── - if (entry.value != null) ...[ - const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionLabel('Value'), - const SizedBox(height: 6), - buildValueWidget(), - ], - ), - ), - ], - ], - ), - ); - } - - Widget _fallbackScreenshot(UnifiedEvent event, bool isDark) { - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _SectionLabel('Title'), - const SizedBox(height: 6), - _CodeBlock(text: event.title, isDark: isDark), - const SizedBox(height: 16), - const _SectionLabel('Details'), - const SizedBox(height: 6), - _CodeBlock(text: event.subtitle, isDark: isDark), - if (event.rawData != null) ...[ - const SizedBox(height: 16), - const _SectionLabel('Raw Data'), - const SizedBox(height: 6), - if (event.rawData is Map || event.rawData is List) - JsonViewer(data: event.rawData, initiallyExpanded: true) - else - _CodeBlock(text: '${event.rawData}', isDark: isDark), - ], - ], - ), - ); - } - - /// Builds a tab-only screenshot: header + URL bar (for network) + current tab content only. - Widget _buildTabScreenshotWidget( - ThemeData theme, bool isDark, int tabIndex) { - final event = widget.event; - final time = DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch(event.timestamp), - ); - - final (typeColor, typeIcon, typeLabel) = - _DetailHeader._staticTypeDetails(event.type); - - final header = Container( - height: 44, - padding: const EdgeInsets.symmetric(horizontal: 14), - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : Colors.white, - ), - child: Row( - children: [ - Icon(typeIcon, size: 14, color: typeColor), - const SizedBox(width: 8), - TextComponent(typeLabel, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: typeColor)), - const SizedBox(width: 10), - TextComponent(time, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 10, - color: Colors.grey[500])), - ], - ), - ); - - Widget tabContent; - if (event.type == EventType.network && event.rawData is NetworkEntry) { - final entry = event.rawData as NetworkEntry; - // URL bar always shown - final urlBar = Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : Colors.white, - border: Border( - bottom: BorderSide( - color: isDark - ? Colors.white.withValues(alpha: 0.05) - : Colors.black.withValues(alpha: 0.06), - ), - ), - ), - child: Row( - children: [ - HttpMethodBadge(method: entry.method), - const SizedBox(width: 8), - if (entry.isComplete) ...[ - StatusBadge(statusCode: entry.statusCode), - const SizedBox(width: 8), - ], - Expanded( - child: TextComponent(entry.url, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: isDark - ? ColorTokens.lightBackground - : Colors.black87)), - ), - ], - ), - ); - final timingBar = entry.duration != null - ? Padding( - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: _TimingBar(duration: entry.duration!), - ) - : null; - - final tabNames = ['Headers', 'Request', 'Response', 'Timing']; - final tabLabel = Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: _SectionLabel(tabNames[tabIndex]), - ); - - Widget body; - switch (tabIndex) { - case 0: // Headers - body = Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _SectionLabel('Request Headers'), - const SizedBox(height: 8), - _HeaderTable(headers: entry.requestHeaders, isScreenshot: true), - const SizedBox(height: 20), - const _SectionLabel('Response Headers'), - const SizedBox(height: 8), - _HeaderTable(headers: entry.responseHeaders, isScreenshot: true), - ], - ), - ); - break; - case 1: // Request - body = _buildBodyScreenshot( - entry.requestBody, 'Request Body', isDark); - break; - case 2: // Response - body = _buildBodyScreenshot( - entry.responseBody, 'Response Body', isDark); - break; - case 3: // Timing - default: - body = Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _InfoRow( - 'Start Time', - DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch(entry.startTime), - ), - ), - if (entry.endTime != null) - _InfoRow( - 'End Time', - DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch(entry.endTime!), - ), - ), - if (entry.duration != null) - _InfoRow('Duration', formatDuration(entry.duration!)), - if (entry.error != null) ...[ - const SizedBox(height: 12), - const _SectionLabel('Error'), - const SizedBox(height: 6), - _ErrorBlock(text: entry.error!, isDark: isDark), - ], - ], - ), - ); - break; - } - - tabContent = Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - urlBar, - if (timingBar != null) timingBar, - const Divider(height: 1), - tabLabel, - body, - ], - ); - } else if (event.type == EventType.state && - event.rawData is StateChange) { - final entry = event.rawData as StateChange; - final tabNames = ['Diff', 'Previous', 'Next']; - Widget body; - switch (tabIndex) { - case 0: - body = entry.diff.isEmpty - ? const Padding( - padding: EdgeInsets.all(16), - child: TextComponent('No diff')) - : Padding( - padding: const EdgeInsets.all(12), - child: Column( - mainAxisSize: MainAxisSize.min, - children: - entry.diff.map((d) => _DiffRow(diff: d)).toList(), - ), - ); - break; - case 1: - body = _buildBodyScreenshot( - entry.previousState.isEmpty ? null : entry.previousState, - 'Previous State', - isDark); - break; - case 2: - default: - body = _buildBodyScreenshot( - entry.nextState.isEmpty ? null : entry.nextState, - 'Next State', - isDark); - break; - } - tabContent = Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: _SectionLabel(tabNames[tabIndex]), - ), - body, - ], - ); - } else { - // Log / Storage / Fallback — no tabs, just use full content - tabContent = _buildScreenshotContent(isDark); - } - - return Container( - color: isDark ? ColorTokens.darkSurface : ColorTokens.lightSurface, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - header, - const Divider(height: 1), - tabContent, - ], - ), - ); - } - - Widget _buildBodyScreenshot(dynamic body, String label, bool isDark) { - if (body == null) { - return Padding( - padding: const EdgeInsets.all(16), - child: TextComponent('No $label', - style: TextStyle(color: Colors.grey[500], fontSize: 12)), - ); - } - dynamic parsed = body; - if (parsed is String) { - try { - parsed = jsonDecode(parsed); - } catch (_) {} - } - final useJson = _currentJsonMode; - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionLabel(label), - const SizedBox(height: 8), - if (useJson || !(parsed is Map || parsed is List)) - JsonPrettyViewer(data: parsed) - else - JsonViewer(data: parsed, initiallyExpanded: true), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - - return Container( - color: isDark ? ColorTokens.darkSurface : ColorTokens.lightSurface, - child: Column( - children: [ - _DetailHeader( - event: widget.event, - onClose: widget.onClose, - onFullScreenshot: _takeFullScreenshot, - onTabScreenshot: _takeTabScreenshot, - hasMultipleTabs: widget.event.type == EventType.network || - widget.event.type == EventType.state, - ), - const Divider(height: 1), - Expanded( - child: RepaintBoundary( - key: _contentKey, - child: _buildContent(), - ), - ), - ], - ), - ); - } - - Widget _buildContent() { - switch (widget.event.type) { - case EventType.log: - if (widget.event.rawData is LogEntry) { - return _LogDetail(entry: widget.event.rawData as LogEntry); - } - return _FallbackDetail(event: widget.event); - case EventType.network: - if (widget.event.rawData is NetworkEntry) { - return _NetworkDetail( - entry: widget.event.rawData as NetworkEntry, - onTabChanged: (i) => _currentTabIndex = i, - onJsonModeChanged: (v) => _currentJsonMode = v, - ); - } - return _FallbackDetail(event: widget.event); - case EventType.state: - if (widget.event.rawData is StateChange) { - return _StateDetail( - entry: widget.event.rawData as StateChange, - onTabChanged: (i) => _currentTabIndex = i, - onJsonModeChanged: (v) => _currentJsonMode = v, - ); - } - return _FallbackDetail(event: widget.event); - case EventType.storage: - if (widget.event.rawData is StorageEntry) { - return _StorageDetailRedesign( - entry: widget.event.rawData as StorageEntry, - ); - } - return _FallbackDetail(event: widget.event); - case EventType.display: - case EventType.asyncOp: - case EventType.error: - return _FallbackDetail(event: widget.event); - } - } -} - -class _DetailHeader extends ConsumerWidget { - final UnifiedEvent event; - final VoidCallback onClose; - final VoidCallback onFullScreenshot; - final VoidCallback? onTabScreenshot; - final bool hasMultipleTabs; - - const _DetailHeader({ - required this.event, - required this.onClose, - required this.onFullScreenshot, - this.onTabScreenshot, - this.hasMultipleTabs = false, - }); - - static (Color, IconData, String) _staticTypeDetails(EventType type) { - switch (type) { - case EventType.log: - return (ColorTokens.logInfo, LucideIcons.terminal, 'Log Detail'); - case EventType.network: - return (ColorTokens.success, LucideIcons.globe, 'Network Detail'); - case EventType.state: - return ( - ColorTokens.secondary, - LucideIcons.layers, - 'State Detail' - ); - case EventType.storage: - return ( - ColorTokens.warning, - LucideIcons.database, - 'Storage Detail' - ); - case EventType.display: - return ( - const Color(0xFF9B59B6), - LucideIcons.monitor, - 'Display Detail' - ); - case EventType.asyncOp: - return ( - const Color(0xFFE67E22), - LucideIcons.zap, - 'Async Operation' - ); - case EventType.error: - return (Colors.red, LucideIcons.alertTriangle, 'Error Detail'); - } - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final time = DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch(event.timestamp), - ); - - final devices = ref.watch(connectedDevicesProvider); - final device = - devices.where((d) => d.deviceId == event.deviceId).firstOrNull; - - final (typeColor, typeIcon, typeLabel) = _staticTypeDetails(event.type); - - return Container( - height: 44, - padding: const EdgeInsets.symmetric(horizontal: 14), - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : Colors.white, - ), - child: Row( - children: [ - Icon(typeIcon, size: 14, color: typeColor), - const SizedBox(width: 8), - TextComponent( - typeLabel, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: typeColor, - ), - ), - const SizedBox(width: 10), - if (device != null) ...[ - PlatformBadge(platform: device.platform), - const SizedBox(width: 8), - ], - TextComponent( - time, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 10, - color: Colors.grey[500], - ), - ), - const Spacer(), - // Screenshot buttons - Tooltip( - message: S.of(context).captureFullTooltip, - waitDuration: const Duration(milliseconds: 400), - child: _ActionButton( - icon: LucideIcons.camera, - label: S.of(context).captureFull, - onTap: onFullScreenshot, - ), - ), - if (hasMultipleTabs && onTabScreenshot != null) ...[ - const SizedBox(width: 4), - Tooltip( - message: S.of(context).captureTabTooltip, - waitDuration: const Duration(milliseconds: 400), - child: _ActionButton( - icon: LucideIcons.scanLine, - label: S.of(context).captureTab, - onTap: onTabScreenshot!, - ), - ), - ], - const SizedBox(width: 6), - _PressableButton( - onTap: onClose, - child: Container( - width: 26, - height: 26, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - color: Colors.grey.withValues(alpha: 0.1), - ), - child: Icon(LucideIcons.x, size: 14, color: Colors.grey[500]), - ), - ), - ], - ), - ); - } -} - -class _PressableButton extends StatefulWidget { - final VoidCallback onTap; - final Widget child; - - const _PressableButton({required this.onTap, required this.child}); - - @override - State<_PressableButton> createState() => _PressableButtonState(); -} - -class _PressableButtonState extends State<_PressableButton> { - bool _pressed = false; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) { - setState(() => _pressed = false); - widget.onTap(); - }, - onTapCancel: () => setState(() => _pressed = false), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: AnimatedScale( - scale: _pressed ? 0.92 : 1.0, - duration: const Duration(milliseconds: 100), - child: AnimatedOpacity( - opacity: _pressed ? 0.7 : 1.0, - duration: const Duration(milliseconds: 100), - child: widget.child, - ), - ), - ), - ); - } -} - -class _ActionButton extends StatelessWidget { - final IconData icon; - final String label; - final VoidCallback onTap; - - const _ActionButton({ - required this.icon, - required this.label, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return _PressableButton( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(5), - color: Colors.grey.withValues(alpha: 0.08), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 12, color: Colors.grey[500]), - const SizedBox(width: 4), - TextComponent( - label, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Colors.grey[500], - ), - ), - ], - ), - ), - ); - } -} - -// ═══════════════════════════════════════════════ -// Log Detail -// ═══════════════════════════════════════════════ - -class _LogDetail extends StatefulWidget { - final LogEntry entry; - - const _LogDetail({required this.entry}); - - @override - State<_LogDetail> createState() => _LogDetailState(); -} - -class _LogDetailState extends State<_LogDetail> { - final _scrollController = SmoothScrollController(); - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - /// Try to extract JSON from the log message. - /// Returns (prefix, parsedJson) or null if no JSON found. - static (String, dynamic)? _extractJson(String message) { - // Try full message first - try { - final parsed = jsonDecode(message.trim()); - if (parsed is Map || parsed is List) return ('', parsed); - } catch (_) {} - - // Find all { or [ positions and try each - for (var i = 0; i < message.length; i++) { - final ch = message[i]; - if (ch != '{' && ch != '[') continue; - final jsonStr = message.substring(i).trim(); - try { - final parsed = jsonDecode(jsonStr); - if (parsed is Map || parsed is List) { - final prefix = message.substring(0, i).trim(); - return (prefix, parsed); - } - } catch (_) {} - } - return null; - } - - @override - Widget build(BuildContext context) { - final entry = widget.entry; - final isDark = Theme.of(context).brightness == Brightness.dark; - final jsonResult = _extractJson(entry.message); - - return SingleChildScrollView( - controller: _scrollController, - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - LogLevelBadge(level: entry.level.name), - if (entry.tag != null) ...[ - const SizedBox(width: 8), - _TagChip(entry.tag!), - ], - const Spacer(), - _CopyButton( - tooltip: 'Copy message', - onTap: () => _copyText(context, entry.message, 'Message'), - ), - ], - ), - const SizedBox(height: 16), - _SectionLabel('Message'), - const SizedBox(height: 6), - if (jsonResult != null) ...[ - if (jsonResult.$1.isNotEmpty) ...[ - _CodeBlock(text: jsonResult.$1, isDark: isDark), - const SizedBox(height: 10), - ], - _InlineJsonView(data: jsonResult.$2, label: 'Data'), - ] else - _CodeBlock(text: entry.message, isDark: isDark), - if (entry.metadata != null && entry.metadata!.isNotEmpty) ...[ - const SizedBox(height: 16), - _InlineJsonView(data: entry.metadata, label: 'Metadata'), - ], - if (entry.stackTrace != null) ...[ - const SizedBox(height: 16), - Row( - children: [ - _SectionLabel('Stack Trace'), - const Spacer(), - _CopyButton( - tooltip: 'Copy stack trace', - onTap: () => - _copyText(context, entry.stackTrace!, 'Stack trace'), - ), - ], - ), - const SizedBox(height: 6), - _ErrorBlock(text: entry.stackTrace!, isDark: isDark), - ], - ], - ), - ); - } -} - -// ═══════════════════════════════════════════════ -// Network Detail -// ═══════════════════════════════════════════════ - -class _NetworkDetail extends ConsumerStatefulWidget { - final NetworkEntry entry; - final ValueChanged? onTabChanged; - final ValueChanged? onJsonModeChanged; - - const _NetworkDetail({ - required this.entry, - this.onTabChanged, - this.onJsonModeChanged, - }); - - @override - ConsumerState<_NetworkDetail> createState() => _NetworkDetailState(); -} - -class _NetworkDetailState extends ConsumerState<_NetworkDetail> - with SingleTickerProviderStateMixin { - late TabController _tabController; - - @override - void initState() { - super.initState(); - _tabController = _makeController(); - _tabController.addListener(_onTabIndexChange); - } - - TabController _makeController([int initialIndex = 0]) { - return TabController( - length: 4, - vsync: this, - animationDuration: ref.read(tabAnimationProvider), - initialIndex: initialIndex, - ); - } - - void _onTabIndexChange() { - if (!_tabController.indexIsChanging) { - widget.onTabChanged?.call(_tabController.index); - } - } - - void _rebuildController() { - final oldIndex = _tabController.index; - _tabController.removeListener(_onTabIndexChange); - _tabController.dispose(); - _tabController = _makeController(oldIndex); - _tabController.addListener(_onTabIndexChange); - setState(() {}); - } - - @override - void dispose() { - _tabController.removeListener(_onTabIndexChange); - _tabController.dispose(); - super.dispose(); - } - - NetworkEntry get entry => widget.entry; - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - ref.listen(tabAnimationProvider, (prev, next) { - if (prev != next) _rebuildController(); - }); - - return Column( - children: [ - // URL bar + actions - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : Colors.white, - border: Border( - bottom: BorderSide( - color: isDark - ? Colors.white.withValues(alpha: 0.05) - : Colors.black.withValues(alpha: 0.06), - ), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - HttpMethodBadge(method: entry.method), - const SizedBox(width: 8), - if (entry.isComplete) ...[ - StatusBadge(statusCode: entry.statusCode), - const SizedBox(width: 8), - ] else ...[ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: ColorTokens.warning.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: ColorTokens.warning.withValues(alpha: 0.25), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 10, - height: 10, - child: CircularProgressIndicator( - strokeWidth: 1.5, - color: ColorTokens.warning, - ), - ), - const SizedBox(width: 5), - TextComponent( - 'In Progress...', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w700, - color: ColorTokens.warning, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - ], - Expanded( - child: Tooltip( - message: entry.url, - waitDuration: const Duration(milliseconds: 300), - child: TextComponent( - entry.url, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: isDark - ? ColorTokens.lightBackground - : Colors.black87, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ), - const SizedBox(height: 8), - // Action buttons row - Row( - children: [ - if (entry.duration != null) ...[ - _TimingBar(duration: entry.duration!), - const Spacer(), - ] else - const Spacer(), - _CopyButton( - tooltip: 'Copy URL', - icon: LucideIcons.link, - onTap: () => - _copyText(context, entry.url, 'URL'), - ), - const SizedBox(width: 4), - _CopyButton( - tooltip: 'Copy Path', - icon: LucideIcons.route, - onTap: () { - try { - final uri = Uri.parse(entry.url); - final path = uri.path.isNotEmpty ? uri.path : entry.url; - _copyText(context, path, 'Path'); - } catch (_) { - _copyText(context, entry.url, 'Path'); - } - }, - ), - const SizedBox(width: 4), - _CopyButton( - tooltip: 'Copy as cURL', - icon: LucideIcons.terminal, - onTap: () => _copyText( - context, _buildCurl(entry), 'cURL'), - ), - const SizedBox(width: 4), - _CopyButton( - tooltip: 'Copy request', - icon: LucideIcons.upload, - onTap: () { - final body = entry.requestBody; - final text = body is String - ? body - : (body != null - ? const JsonEncoder.withIndent(' ') - .convert(body) - : ''); - _copyText(context, text, 'Request'); - }, - ), - const SizedBox(width: 4), - _CopyButton( - tooltip: 'Copy response', - icon: LucideIcons.download, - onTap: () { - final body = entry.responseBody; - final text = body is String - ? body - : (body != null - ? const JsonEncoder.withIndent(' ') - .convert(body) - : ''); - _copyText(context, text, 'Response'); - }, - ), - ], - ), - ], - ), - ), - // Tabs - _DetailTabBar( - controller: _tabController, - isDark: isDark, - accentColor: ColorTokens.primary, - tabs: const ['Headers', 'Request', 'Response', 'Timing'], - ), - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - LazyTab( - controller: _tabController, - index: 0, - builder: (_) => _HeadersView(entry: entry), - ), - LazyTab( - controller: _tabController, - index: 1, - builder: (_) => _BodyView( - body: entry.requestBody, - label: 'Request Body', - deviceId: entry.deviceId, - onJsonModeChanged: widget.onJsonModeChanged, - ), - ), - LazyTab( - controller: _tabController, - index: 2, - builder: (_) => _BodyView( - body: entry.responseBody, - label: 'Response Body', - deviceId: entry.deviceId, - onJsonModeChanged: widget.onJsonModeChanged, - ), - ), - LazyTab( - controller: _tabController, - index: 3, - builder: (_) => _TimingView(entry: entry), - ), - ], - ), - ), - ], - ); - } - - String _buildCurl(NetworkEntry e) { - final buf = StringBuffer("curl -X ${e.method} '${e.url}'"); - e.requestHeaders.forEach((k, v) { - buf.write(" \\\n -H '$k: $v'"); - }); - if (e.requestBody != null) { - final body = e.requestBody is String - ? e.requestBody as String - : const JsonEncoder().convert(e.requestBody); - buf.write(" \\\n -d '$body'"); - } - return buf.toString(); - } -} - -class _TimingBar extends StatelessWidget { - final int duration; - - const _TimingBar({required this.duration}); - - @override - Widget build(BuildContext context) { - final maxWidth = 200.0; - final ratio = (duration / 2000).clamp(0.0, 1.0); - - Color barColor; - if (duration < 200) { - barColor = ColorTokens.success; - } else if (duration < 500) { - barColor = ColorTokens.warning; - } else { - barColor = ColorTokens.error; - } - - return Row( - children: [ - SizedBox( - width: maxWidth, - child: ClipRRect( - borderRadius: BorderRadius.circular(3), - child: LinearProgressIndicator( - value: ratio, - backgroundColor: barColor.withValues(alpha: 0.08), - valueColor: AlwaysStoppedAnimation(barColor), - minHeight: 4, - ), - ), - ), - const SizedBox(width: 8), - TextComponent( - formatDuration(duration), - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - fontWeight: FontWeight.w700, - color: barColor, - ), - ), - ], - ); - } -} - -class _HeadersView extends StatefulWidget { - final NetworkEntry entry; - - const _HeadersView({required this.entry}); - - @override - State<_HeadersView> createState() => _HeadersViewState(); -} - -class _HeadersViewState extends State<_HeadersView> { - final _scrollController = SmoothScrollController(); - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final entry = widget.entry; - final isDark = Theme.of(context).brightness == Brightness.dark; - return SingleChildScrollView( - controller: _scrollController, - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _HeaderSection( - icon: LucideIcons.arrowUpRight, - iconColor: ColorTokens.primary, - title: 'Request Headers', - count: entry.requestHeaders.length, - headers: entry.requestHeaders, - isDark: isDark, - ), - const SizedBox(height: 16), - _HeaderSection( - icon: LucideIcons.arrowDownLeft, - iconColor: ColorTokens.success, - title: 'Response Headers', - count: entry.responseHeaders.length, - headers: entry.responseHeaders, - isDark: isDark, - ), - ], - ), - ); - } -} - -class _HeaderSection extends StatelessWidget { - final IconData icon; - final Color iconColor; - final String title; - final int count; - final Map headers; - final bool isDark; - - const _HeaderSection({ - required this.icon, - required this.iconColor, - required this.title, - required this.count, - required this.headers, - required this.isDark, - }); - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : Colors.white, - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06), - ), - ), - child: Column( - children: [ - // Section header - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: isDark ? const Color(0xFF1C2128) : ColorTokens.lightSurface, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(10)), - border: Border( - bottom: BorderSide( - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06), - ), - ), - ), - child: Row( - children: [ - Icon(icon, size: 13, color: iconColor), - const SizedBox(width: 8), - TextComponent( - title, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: isDark ? Colors.white70 : Colors.black87, - ), - ), - const SizedBox(width: 8), - Container( - padding: - const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration( - color: iconColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: TextComponent( - '$count', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: iconColor, - ), - ), - ), - ], - ), - ), - // Header rows - if (headers.isEmpty) - Padding( - padding: const EdgeInsets.all(16), - child: TextComponent('No headers', - style: TextStyle(color: Colors.grey[500], fontSize: 12)), - ) - else - ...headers.entries.toList().asMap().entries.map((entry) { - final e = entry.value; - final isLast = entry.key == headers.length - 1; - return Container( - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: isLast - ? null - : BoxDecoration( - border: Border( - bottom: BorderSide( - color: isDark - ? Colors.white.withValues(alpha: 0.04) - : Colors.black.withValues(alpha: 0.04), - ), - ), - ), - child: _HeaderRowCopy( - headerKey: e.key, - headerValue: e.value, - isDark: isDark, - ), - ); - }), - ], - ), - ); - } -} - -class _HeaderTable extends StatelessWidget { - final Map headers; - final bool isScreenshot; - - const _HeaderTable({required this.headers, this.isScreenshot = false}); - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - if (headers.isEmpty) { - return TextComponent('No headers', - style: TextStyle(color: Colors.grey[500], fontSize: 12)); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: headers.entries.map((e) { - if (isScreenshot) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 170, - child: TextComponent(e.key, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - fontWeight: FontWeight.w600, - color: isDark - ? const Color(0xFF9CDCFE) - : const Color(0xFF0451A5))), - ), - Expanded( - child: TextComponent(e.value, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: isDark - ? const Color(0xFFCE9178) - : const Color(0xFFA31515))), - ), - ], - ), - ); - } - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: _HeaderRowCopy( - headerKey: e.key, - headerValue: e.value, - isDark: isDark, - ), - ); - }).toList(), - ); - } -} - -class _HeaderRowCopy extends StatefulWidget { - final String headerKey; - final String headerValue; - final bool isDark; - - const _HeaderRowCopy({ - required this.headerKey, - required this.headerValue, - required this.isDark, - }); - - @override - State<_HeaderRowCopy> createState() => _HeaderRowCopyState(); -} - -class _HeaderRowCopyState extends State<_HeaderRowCopy> { - bool _hovered = false; - bool _copied = false; - bool _expanded = false; - - static const _maxCollapsedLines = 4; - - bool get _isLong => '\n'.allMatches(widget.headerValue).length >= _maxCollapsedLines || - widget.headerValue.length > 200; - - @override - Widget build(BuildContext context) { - final valueStyle = TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: widget.isDark - ? const Color(0xFFCE9178) - : const Color(0xFFA31515), - ); - - return MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() { _hovered = false; _copied = false; }), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 170, - child: TextComponent( - widget.headerKey, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - fontWeight: FontWeight.w600, - color: widget.isDark - ? const Color(0xFF9CDCFE) - : const Color(0xFF0451A5), - ), - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (_expanded) - TextComponent(widget.headerValue, style: valueStyle) - else - TextComponent( - widget.headerValue, - style: valueStyle, - maxLines: _maxCollapsedLines, - overflow: TextOverflow.ellipsis, - ), - if (_isLong) - GestureDetector( - onTap: () => setState(() => _expanded = !_expanded), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Padding( - padding: const EdgeInsets.only(top: 2), - child: TextComponent( - _expanded ? 'Collapse' : 'Show more', - style: TextStyle( - fontSize: 10, - color: ColorTokens.primary, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ), - ], - ), - ), - if (_hovered) - GestureDetector( - onTap: () { - Clipboard.setData(ClipboardData(text: '${widget.headerKey}: ${widget.headerValue}')); - setState(() => _copied = true); - showCopiedToast(context); - }, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Padding( - padding: const EdgeInsets.only(left: 6), - child: Icon( - _copied ? LucideIcons.check : LucideIcons.copy, - size: 12, - color: _copied - ? ColorTokens.chartGreen - : (widget.isDark ? Colors.white38 : Colors.black26), - ), - ), - ), - ), - ], - ), - ); - } -} - -class _BodyView extends ConsumerStatefulWidget { - final dynamic body; - final String label; - final String? deviceId; - final ValueChanged? onJsonModeChanged; - - const _BodyView({ - required this.body, - required this.label, - this.deviceId, - this.onJsonModeChanged, - }); - - @override - ConsumerState<_BodyView> createState() => _BodyViewState(); -} - -class _BodyViewState extends ConsumerState<_BodyView> { - final _scrollController = SmoothScrollController(); - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final viewMode = ref.watch(bodyViewModeProvider); - - if (widget.body == null) { - return EmptyState(icon: LucideIcons.fileText, title: 'No ${widget.label}'); - } - - // Look up the connected device's platform to pick the Code language. - final devices = ref.watch(connectedDevicesProvider); - final platform = widget.deviceId == null - ? 'react_native' - : devices - .where((d) => d.deviceId == widget.deviceId) - .map((d) => d.platform) - .firstOrNull ?? - 'react_native'; - final codeLang = CodeGenerator.langForPlatform(platform); - - return AsyncJsonParser( - rawData: widget.body, - builder: (context, parsedBody, isJson) { - final canToggle = isJson; - final effectiveMode = canToggle ? viewMode : BodyViewMode.json; - - return Column( - children: [ - Container( - height: 36, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06), - ), - ), - ), - child: Row( - children: [ - _SectionLabel(widget.label), - const Spacer(), - if (canToggle) ...[ - ViewModeSegment( - label: 'Tree', - active: effectiveMode == BodyViewMode.tree, - position: ViewSegmentPosition.start, - onTap: () { - ref - .read(bodyViewModeProvider.notifier) - .set(BodyViewMode.tree); - widget.onJsonModeChanged?.call(false); - }, - ), - ViewModeSegment( - label: 'JSON', - active: effectiveMode == BodyViewMode.json, - position: ViewSegmentPosition.middle, - onTap: () { - ref - .read(bodyViewModeProvider.notifier) - .set(BodyViewMode.json); - widget.onJsonModeChanged?.call(true); - }, - ), - ViewModeSegment( - label: CodeGenerator.labelFor(codeLang), - active: effectiveMode == BodyViewMode.code, - position: ViewSegmentPosition.end, - onTap: () { - ref - .read(bodyViewModeProvider.notifier) - .set(BodyViewMode.code); - widget.onJsonModeChanged?.call(false); - }, - ), - ], - ], - ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.all(16), - child: _buildContent( - parsedBody: parsedBody, - canToggle: canToggle, - mode: effectiveMode, - codeLang: codeLang, - ), - ), - ), - ], - ); - }, - ); - } - - Widget _buildContent({ - required dynamic parsedBody, - required bool canToggle, - required BodyViewMode mode, - required CodeLang codeLang, - }) { - if (!canToggle) { - return JsonPrettyViewer(data: parsedBody); - } - return DeferredBuilder( - key: ValueKey(mode), - builder: (_) { - switch (mode) { - case BodyViewMode.tree: - return JsonViewer(data: parsedBody, initiallyExpanded: true); - case BodyViewMode.json: - return JsonPrettyViewer(data: widget.body); - case BodyViewMode.code: - final generated = CodeGenerator.generate(parsedBody, codeLang); - return SingleChildScrollView( - child: CodeViewer( - generated: generated, - lang: codeLang, - languageLabel: CodeGenerator.labelFor(codeLang), - ), - ); - } - }, - ); - } -} - -/// Inline Tree/JSON toggle for use inside ScrollView (non-tabbed contexts). -class _InlineJsonView extends ConsumerStatefulWidget { - final dynamic data; - final String label; - - const _InlineJsonView({required this.data, required this.label}); - - @override - ConsumerState<_InlineJsonView> createState() => _InlineJsonViewState(); -} - -class _InlineJsonViewState extends ConsumerState<_InlineJsonView> { - @override - Widget build(BuildContext context) { - final viewMode = ref.watch(bodyViewModeProvider); - - // Inline views don't know the device, so Code mode falls back to TS. - final codeLang = CodeGenerator.langForPlatform('react_native'); - - return AsyncJsonParser( - rawData: widget.data, - builder: (context, parsed, isJson) { - final canToggle = isJson; - final effectiveMode = canToggle ? viewMode : BodyViewMode.json; - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - _SectionLabel(widget.label), - const Spacer(), - if (canToggle) ...[ - ViewModeSegment( - label: 'Tree', - active: effectiveMode == BodyViewMode.tree, - position: ViewSegmentPosition.start, - onTap: () => ref - .read(bodyViewModeProvider.notifier) - .set(BodyViewMode.tree), - ), - ViewModeSegment( - label: 'JSON', - active: effectiveMode == BodyViewMode.json, - position: ViewSegmentPosition.middle, - onTap: () => ref - .read(bodyViewModeProvider.notifier) - .set(BodyViewMode.json), - ), - ViewModeSegment( - label: CodeGenerator.labelFor(codeLang), - active: effectiveMode == BodyViewMode.code, - position: ViewSegmentPosition.end, - onTap: () => ref - .read(bodyViewModeProvider.notifier) - .set(BodyViewMode.code), - ), - ], - ], - ), - const SizedBox(height: 8), - _buildInlineContent( - parsed: parsed, - canToggle: canToggle, - mode: effectiveMode, - codeLang: codeLang, - ), - ], - ); - }, - ); - } - - Widget _buildInlineContent({ - required dynamic parsed, - required bool canToggle, - required BodyViewMode mode, - required CodeLang codeLang, - }) { - if (!canToggle) return JsonPrettyViewer(data: widget.data); - return DeferredBuilder( - key: ValueKey(mode), - builder: (_) { - switch (mode) { - case BodyViewMode.tree: - return JsonViewer(data: parsed, initiallyExpanded: true); - case BodyViewMode.json: - return JsonPrettyViewer(data: widget.data); - case BodyViewMode.code: - final generated = CodeGenerator.generate(parsed, codeLang); - return CodeViewer( - generated: generated, - lang: codeLang, - languageLabel: CodeGenerator.labelFor(codeLang), - ); - } - }, - ); - } -} - -class _TimingView extends StatefulWidget { - final NetworkEntry entry; - - const _TimingView({required this.entry}); - - @override - State<_TimingView> createState() => _TimingViewState(); -} - -class _TimingViewState extends State<_TimingView> { - final _scrollController = SmoothScrollController(); - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final entry = widget.entry; - final isDark = Theme.of(context).brightness == Brightness.dark; - final duration = entry.duration; - final startDt = DateTime.fromMillisecondsSinceEpoch(entry.startTime); - final endDt = entry.endTime != null - ? DateTime.fromMillisecondsSinceEpoch(entry.endTime!) - : null; - - Color durationColor; - String durationLabel; - IconData durationIcon; - if (duration == null) { - durationColor = Colors.grey; - durationLabel = 'In Progress'; - durationIcon = LucideIcons.loader; - } else if (duration < 200) { - durationColor = ColorTokens.success; - durationLabel = 'Fast'; - durationIcon = LucideIcons.zap; - } else if (duration < 500) { - durationColor = ColorTokens.warning; - durationLabel = 'Normal'; - durationIcon = LucideIcons.clock; - } else if (duration < 2000) { - durationColor = const Color(0xFFE5853D); - durationLabel = 'Slow'; - durationIcon = LucideIcons.triangleAlert; - } else { - durationColor = ColorTokens.error; - durationLabel = 'Very Slow'; - durationIcon = LucideIcons.circleAlert; - } - - return SingleChildScrollView( - controller: _scrollController, - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Loading indicator for pending requests - if (duration == null) - Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - ColorTokens.warning.withValues(alpha: 0.12), - ColorTokens.warning.withValues(alpha: 0.04), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: ColorTokens.warning.withValues(alpha: 0.2), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2.5, - color: ColorTokens.warning, - ), - ), - const SizedBox(width: 12), - TextComponent( - 'Waiting for response...', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: ColorTokens.warning, - ), - ), - ], - ), - ), - if (duration == null) const SizedBox(height: 16), - // Duration hero card - if (duration != null) - Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - durationColor.withValues(alpha: 0.12), - durationColor.withValues(alpha: 0.04), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: durationColor.withValues(alpha: 0.2), - ), - ), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: durationColor.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(10), - ), - child: Icon(durationIcon, size: 22, color: durationColor), - ), - const SizedBox(width: 14), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextComponent( - formatDuration(duration), - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 24, - fontWeight: FontWeight.w700, - color: durationColor, - height: 1.1, - ), - ), - const SizedBox(height: 2), - TextComponent( - durationLabel, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: durationColor.withValues(alpha: 0.8), - ), - ), - ], - ), - const Spacer(), - // Performance gauge - SizedBox( - width: 56, - height: 56, - child: CustomPaint( - painter: _GaugePainter( - ratio: (duration / 2000).clamp(0.0, 1.0), - color: durationColor, - isDark: isDark, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 16), - // Timeline - _TimingInfoCard( - isDark: isDark, - children: [ - _TimingInfoRow( - icon: LucideIcons.play, - iconColor: ColorTokens.success, - label: 'Start Time', - value: DateFormat('HH:mm:ss.SSS').format(startDt), - subtitle: DateFormat('yyyy-MM-dd').format(startDt), - isDark: isDark, - ), - if (endDt != null) ...[ - _TimingDividerLine(isDark: isDark), - _TimingInfoRow( - icon: LucideIcons.square, - iconColor: ColorTokens.error, - label: 'End Time', - value: DateFormat('HH:mm:ss.SSS').format(endDt), - subtitle: DateFormat('yyyy-MM-dd').format(endDt), - isDark: isDark, - ), - ], - ], - ), - // Status info - if (entry.statusCode > 0) ...[ - const SizedBox(height: 12), - _TimingInfoCard( - isDark: isDark, - children: [ - _TimingInfoRow( - icon: LucideIcons.arrowUpRight, - iconColor: ColorTokens.primary, - label: 'Method', - value: entry.method, - isDark: isDark, - ), - _TimingDividerLine(isDark: isDark), - _TimingInfoRow( - icon: LucideIcons.hash, - iconColor: entry.statusCode >= 400 - ? ColorTokens.error - : ColorTokens.success, - label: 'Status', - value: '${entry.statusCode}', - isDark: isDark, - ), - ], - ), - ], - // Error section - if (entry.error != null) ...[ - const SizedBox(height: 16), - const _SectionLabel('Error'), - const SizedBox(height: 8), - _ErrorBlock(text: entry.error!, isDark: isDark), - ], - ], - ), - ); - } -} - -class _TimingInfoCard extends StatelessWidget { - final bool isDark; - final List children; - - const _TimingInfoCard({required this.isDark, required this.children}); - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : Colors.white, - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06), - ), - ), - child: Column( - children: children, - ), - ); - } -} - -class _TimingInfoRow extends StatelessWidget { - final IconData icon; - final Color iconColor; - final String label; - final String value; - final String? subtitle; - final bool isDark; - - const _TimingInfoRow({ - required this.icon, - required this.iconColor, - required this.label, - required this.value, - this.subtitle, - required this.isDark, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - child: Row( - children: [ - Container( - width: 30, - height: 30, - decoration: BoxDecoration( - color: iconColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(7), - ), - child: Icon(icon, size: 14, color: iconColor), - ), - const SizedBox(width: 12), - SizedBox( - width: 80, - child: TextComponent( - label, - style: TextStyle( - fontSize: 11, - color: Colors.grey[500], - fontWeight: FontWeight.w500, - ), - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextComponent( - value, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 12, - fontWeight: FontWeight.w600, - color: isDark ? Colors.white : Colors.black87, - ), - ), - if (subtitle != null) - TextComponent( - subtitle!, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 10, - color: Colors.grey[500], - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -class _TimingDividerLine extends StatelessWidget { - final bool isDark; - const _TimingDividerLine({required this.isDark}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(left: 28), - child: Divider( - height: 1, - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06), - ), - ); - } -} - -class _GaugePainter extends CustomPainter { - final double ratio; - final Color color; - final bool isDark; - - _GaugePainter({ - required this.ratio, - required this.color, - required this.isDark, - }); - - @override - void paint(Canvas canvas, Size size) { - final cx = size.width / 2; - final cy = size.height / 2; - final r = size.width / 2 - 4; - const startAngle = 2.356; // 135 degrees - const sweepTotal = 4.712; // 270 degrees - - // Background arc - final bgPaint = Paint() - ..color = (isDark ? Colors.white : Colors.black).withValues(alpha: 0.08) - ..strokeWidth = 5 - ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.round; - - canvas.drawArc( - Rect.fromCircle(center: Offset(cx, cy), radius: r), - startAngle, - sweepTotal, - false, - bgPaint, - ); - - // Value arc - final valuePaint = Paint() - ..color = color - ..strokeWidth = 5 - ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.round; - - canvas.drawArc( - Rect.fromCircle(center: Offset(cx, cy), radius: r), - startAngle, - sweepTotal * ratio, - false, - valuePaint, - ); - } - - @override - bool shouldRepaint(_GaugePainter old) => - old.ratio != ratio || old.color != color; -} - -// ═══════════════════════════════════════════════ -// State Detail -// ═══════════════════════════════════════════════ - -class _StateDetail extends ConsumerStatefulWidget { - final StateChange entry; - final ValueChanged? onTabChanged; - final ValueChanged? onJsonModeChanged; - - const _StateDetail({ - required this.entry, - this.onTabChanged, - this.onJsonModeChanged, - }); - - @override - ConsumerState<_StateDetail> createState() => _StateDetailState(); -} - -class _StateDetailState extends ConsumerState<_StateDetail> - with SingleTickerProviderStateMixin { - late TabController _tabController; - final _diffScrollController = SmoothScrollController(); - - @override - void initState() { - super.initState(); - _tabController = _makeController(); - _tabController.addListener(_onTabIndexChange); - } - - TabController _makeController([int initialIndex = 0]) { - return TabController( - length: 3, - vsync: this, - animationDuration: ref.read(tabAnimationProvider), - initialIndex: initialIndex, - ); - } - - void _onTabIndexChange() { - if (!_tabController.indexIsChanging) { - widget.onTabChanged?.call(_tabController.index); - } - } - - void _rebuildController() { - final oldIndex = _tabController.index; - _tabController.removeListener(_onTabIndexChange); - _tabController.dispose(); - _tabController = _makeController(oldIndex); - _tabController.addListener(_onTabIndexChange); - setState(() {}); - } - - @override - void dispose() { - _tabController.removeListener(_onTabIndexChange); - _tabController.dispose(); - _diffScrollController.dispose(); - super.dispose(); - } - - StateChange get entry => widget.entry; - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - ref.listen(tabAnimationProvider, (prev, next) { - if (prev != next) _rebuildController(); - }); - return Column( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: Row( - children: [ - _TagChip(entry.stateManagerType, - color: ColorTokens.secondary), - const SizedBox(width: 8), - Expanded( - child: TextComponent( - entry.actionName, - style: const TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - overflow: TextOverflow.ellipsis, - ), - ), - _CopyButton( - tooltip: 'Copy action', - onTap: () => - _copyText(context, entry.actionName, 'Action'), - ), - ], - ), - ), - _DetailTabBar( - controller: _tabController, - isDark: isDark, - accentColor: ColorTokens.secondary, - tabs: const ['Diff', 'Previous', 'Next'], - ), - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - LazyTab( - controller: _tabController, - index: 0, - builder: (_) => entry.diff.isEmpty - ? EmptyState( - icon: LucideIcons.gitCompare, title: 'No diff') - : ListView.builder( - controller: _diffScrollController, - padding: const EdgeInsets.all(12), - itemCount: entry.diff.length, - itemBuilder: (context, index) => - _DiffRow(diff: entry.diff[index]), - ), - ), - LazyTab( - controller: _tabController, - index: 1, - builder: (_) => entry.previousState.isEmpty - ? EmptyState( - icon: LucideIcons.layers, - title: 'No previous state') - : _BodyView( - body: entry.previousState, - label: 'Previous State', - deviceId: entry.deviceId, - onJsonModeChanged: widget.onJsonModeChanged, - ), - ), - LazyTab( - controller: _tabController, - index: 2, - builder: (_) => entry.nextState.isEmpty - ? EmptyState( - icon: LucideIcons.layers, - title: 'No next state') - : _BodyView( - body: entry.nextState, - label: 'Next State', - deviceId: entry.deviceId, - onJsonModeChanged: widget.onJsonModeChanged, - ), - ), - ], - ), - ), - ], - ); - } -} - -class _DiffRow extends StatelessWidget { - final StateDiffEntry diff; - - const _DiffRow({required this.diff}); - - @override - Widget build(BuildContext context) { - Color opColor; - IconData opIcon; - switch (diff.operation) { - case 'add': - opColor = ColorTokens.success; - opIcon = LucideIcons.plus; - break; - case 'remove': - opColor = ColorTokens.error; - opIcon = LucideIcons.minus; - break; - default: - opColor = ColorTokens.warning; - opIcon = LucideIcons.penLine; - } - - return Container( - margin: const EdgeInsets.only(bottom: 6), - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: opColor.withValues(alpha: 0.04), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: opColor.withValues(alpha: 0.12)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(opIcon, size: 12, color: opColor), - const SizedBox(width: 6), - TextComponent( - diff.path, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - fontWeight: FontWeight.w600, - color: opColor, - ), - ), - const Spacer(), - Container( - padding: - const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: opColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(3), - ), - child: TextComponent( - diff.operation.toUpperCase(), - style: TextStyle( - fontSize: 8, - fontWeight: FontWeight.w800, - color: opColor, - ), - ), - ), - ], - ), - if (diff.oldValue != null) ...[ - const SizedBox(height: 6), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextComponent('- ', - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: ColorTokens.error)), - Expanded( - child: TextComponent( - '${diff.oldValue}', - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: ColorTokens.error.withValues(alpha: 0.8), - ), - ), - ), - ], - ), - ], - if (diff.newValue != null) ...[ - const SizedBox(height: 2), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextComponent('+ ', - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: ColorTokens.success)), - Expanded( - child: TextComponent( - '${diff.newValue}', - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: ColorTokens.success.withValues(alpha: 0.8), - ), - ), - ), - ], - ), - ], - ], - ), - ); - } -} - -// ═══════════════════════════════════════════════ -// Storage Detail -// ═══════════════════════════════════════════════ - -class _StorageDetail extends StatefulWidget { - final StorageEntry entry; - final ValueChanged? onFormatChanged; - - const _StorageDetail({required this.entry, this.onFormatChanged}); - - @override - State<_StorageDetail> createState() => _StorageDetailState(); -} - -class _StorageDetailState extends State<_StorageDetail> { - bool _formatted = false; - final _scrollController = SmoothScrollController(); - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - StorageEntry get entry => widget.entry; - - /// Try to parse a string value as JSON - dynamic _tryParseJson(dynamic value) { - if (value is! String) return null; - try { - final parsed = jsonDecode(value); - if (parsed is Map || parsed is List) return parsed; - } catch (_) {} - return null; - } - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - - Color opColor; - switch (entry.operation.toLowerCase()) { - case 'write': - opColor = ColorTokens.success; - break; - case 'read': - opColor = ColorTokens.info; - break; - case 'delete': - case 'clear': - opColor = ColorTokens.error; - break; - default: - opColor = ColorTokens.warning; - } - - final parsedJson = _tryParseJson(entry.value); - final isAlreadyJson = entry.value is Map || entry.value is List; - final canFormat = parsedJson != null && !isAlreadyJson; - final rawText = entry.value is String - ? entry.value as String - : const JsonEncoder.withIndent(' ').convert(entry.value); - final sizeBytes = rawText.length; - final sizeLabel = AppConstants.formatBytes(sizeBytes); - - // Off-black neutral palette per anti-AI-slop rules — no pure black, - // no purple/blue glows. Subtle tonal hierarchy only. - final surfaceColor = isDark ? const Color(0xFF1A1A1A) : const Color(0xFFFAFAFA); - final borderColor = isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06); - final labelColor = isDark ? const Color(0xFF8B8B8B) : const Color(0xFF6B6B6B); - final valueColor = isDark ? const Color(0xFFE8E8E8) : const Color(0xFF1A1A1A); - final monoStyle = TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 12, - height: 1.6, - color: valueColor, - ); - - return SingleChildScrollView( - controller: _scrollController, - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ── Status row: operation + type + size + actions ── - // Asymmetric per VARIANCE 8: action cluster right-aligned, no - // centered chrome. Mathematically perfect 8px gaps. - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _TagChip(entry.operation.toUpperCase(), color: opColor), - const SizedBox(width: 8), - _TagChip(entry.storageType.name, color: ColorTokens.warning), - const SizedBox(width: 8), - _TagChip(sizeLabel, color: Colors.grey), - const Spacer(), - _IconAction( - icon: LucideIcons.copy, - tooltip: 'Copy key', - onTap: () => _copyText(context, entry.key, 'Key'), - ), - ], - ), - - const SizedBox(height: 22), - - // ── Key section: label above value, monospace value ── - _SectionLabel('Key'), - const SizedBox(height: 8), - _CodeBlock(text: entry.key, isDark: isDark), - - if (entry.value != null) ...[ - const SizedBox(height: 22), - - // ── Value section ── - if (isAlreadyJson) - _InlineJsonView(data: entry.value, label: 'Value') - else ...[ - Row( - children: [ - _SectionLabel('Value'), - const Spacer(), - if (canFormat) ...[ - _FormatToggleButton( - isFormatted: _formatted, - onToggle: () => setState(() { - _formatted = !_formatted; - widget.onFormatChanged?.call(_formatted); - }), - ), - const SizedBox(width: 8), - ], - _IconAction( - icon: LucideIcons.copy, - tooltip: 'Copy value', - onTap: () { - final text = entry.value is String - ? entry.value as String - : const JsonEncoder.withIndent(' ') - .convert(entry.value); - _copyText(context, text, 'Value'); - }, - ), - ], - ), - const SizedBox(height: 8), - if (_formatted && parsedJson != null) - _InlineJsonView(data: parsedJson, label: '') - else - _CodeBlock(text: '${entry.value}', isDark: isDark), - ], - - const SizedBox(height: 26), - - // ── Metadata divider + key/value list ── - // No card container — just a thin 1px line + tight rows. - // Monospace values per dashboard rules; labels in neutral grey. - Container(height: 1, color: borderColor), - const SizedBox(height: 14), - _MetaRow(label: 'Shape', value: _shapeOf(entry.value), monoStyle: monoStyle, labelColor: labelColor), - _MetaRow(label: 'Length', value: '$sizeBytes chars', monoStyle: monoStyle, labelColor: labelColor), - _MetaRow(label: 'Device', value: entry.deviceId, monoStyle: monoStyle, labelColor: labelColor), - _MetaRow( - label: 'Captured', - value: DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch(entry.timestamp), - ), - monoStyle: monoStyle, - labelColor: labelColor, - ), - ], - ], - ), - ); - } - - String _shapeOf(dynamic v) { - if (v == null) return 'null'; - if (v is Map) return 'Map · ${v.length} ${v.length == 1 ? "key" : "keys"}'; - if (v is List) return 'List · ${v.length} ${v.length == 1 ? "item" : "items"}'; - if (v is String) { - if (v.isEmpty) return 'String · empty'; - final t = v.trim(); - if ((t.startsWith('{') && t.endsWith('}')) || - (t.startsWith('[') && t.endsWith(']'))) { - return 'String · JSON-shaped'; - } - return 'String'; - } - return v.runtimeType.toString(); - } -} - -/// Compact key/value row for the storage metadata footer. -class _MetaRow extends StatelessWidget { - final String label; - final String value; - final TextStyle monoStyle; - final Color labelColor; - - const _MetaRow({ - required this.label, - required this.value, - required this.monoStyle, - required this.labelColor, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 84, - child: Text( - label, - style: TextStyle( - fontSize: 11, - color: labelColor, - letterSpacing: 0.3, - fontWeight: FontWeight.w500, - ), - ), - ), - const SizedBox(width: 14), - Expanded( - child: SelectableText( - value, - style: monoStyle, - ), - ), - ], - ), - ); - } -} - -/// Subtle icon button with hover-state feedback. Replaces the hard-edged -/// `_CopyButton` for a calmer, more premium look (MOTION_INTENSITY 6). -class _IconAction extends StatefulWidget { - final IconData icon; - final String tooltip; - final VoidCallback onTap; - - const _IconAction({ - required this.icon, - required this.tooltip, - required this.onTap, - }); - - @override - State<_IconAction> createState() => _IconActionState(); -} - -class _IconActionState extends State<_IconAction> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - return Tooltip( - message: widget.tooltip, - child: MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: widget.onTap, - behavior: HitTestBehavior.opaque, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - curve: const Cubic(0.16, 1, 0.3, 1), - width: 28, - height: 28, - decoration: BoxDecoration( - color: _hovered - ? (isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.black.withValues(alpha: 0.05)) - : Colors.transparent, - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - widget.icon, - size: 13, - color: isDark ? const Color(0xFF8B8B8B) : const Color(0xFF6B6B6B), - ), - ), - ), - ), - ); - } -} - -/// Metadata footer for the storage detail view. Renders a border-top divider -/// followed by a clean key/value list — no card containers, just a thin line -/// and tight monospace rows, per the dashboard-hardening design rule. -class _StorageMetadataSection extends StatelessWidget { - final StorageEntry entry; - final bool isDark; - - const _StorageMetadataSection({required this.entry, required this.isDark}); - - String _shape() { - final v = entry.value; - if (v == null) return 'null'; - if (v is Map) return 'Map · ${v.length} keys'; - if (v is List) return 'List · ${v.length} items'; - if (v is String) { - if (v.isEmpty) return 'String · empty'; - final t = v.trim(); - if ((t.startsWith('{') && t.endsWith('}')) || - (t.startsWith('[') && t.endsWith(']'))) { - return 'String · JSON'; - } - return 'String · ${v.length} chars'; - } - return v.runtimeType.toString(); - } - - @override - Widget build(BuildContext context) { - final labelColor = isDark ? Colors.grey[500] : Colors.grey[600]; - final valueColor = isDark ? const Color(0xFFD4D4D4) : const Color(0xFF1F2328); - final dividerColor = isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06); - final monoStyle = TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: valueColor, - ); - - Widget row(String label, String value) => Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 96, - child: Text( - label, - style: TextStyle( - fontSize: 11, - color: labelColor, - letterSpacing: 0.2, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - value, - style: monoStyle, - softWrap: true, - ), - ), - ], - ), - ); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container(height: 1, color: dividerColor), - const SizedBox(height: 14), - row('Type', entry.storageType.name), - row('Operation', entry.operation), - row('Shape', _shape()), - row('Device', entry.deviceId), - row('Timestamp', - DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch(entry.timestamp), - )), - ], - ); - } -} - -// ═══════════════════════════════════════════════ -// Storage Detail (Redesigned) -// ═══════════════════════════════════════════════ - -class _StorageDetailRedesign extends ConsumerStatefulWidget { - final StorageEntry entry; - final VoidCallback? onClose; - const _StorageDetailRedesign({ - required this.entry, - this.onClose, - }); - - @override - ConsumerState<_StorageDetailRedesign> createState() => - _StorageDetailRedesignState(); -} - -class _StorageDetailRedesignState - extends ConsumerState<_StorageDetailRedesign> { - final _scrollController = SmoothScrollController(); - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - // ── Design tokens ───────────────────────────────────────────── - // Off-black neutrals (anti-pure-black rule). One accent reserved - // for the operation chip — everything else is desaturated. - Color _textPrimary(bool isDark) => - isDark ? const Color(0xFFE8E8E8) : const Color(0xFF1A1A1A); - Color _textSecondary(bool isDark) => - isDark ? const Color(0xFF8B8B8B) : const Color(0xFF6B6B6B); - Color _divider(bool isDark) => isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06); - - Color _opColor() { - switch (widget.entry.operation.toLowerCase()) { - case 'write': - return const Color(0xFF34D399); // emerald 400 — single accent - case 'read': - return const Color(0xFF60A5FA); // blue 400 - case 'delete': - case 'clear': - return const Color(0xFFF87171); // red 400 - default: - return const Color(0xFFFBBF24); // amber 400 - } - } - - String _shapeOf(dynamic v) { - if (v == null) return 'null'; - if (v is Map) return 'Map · ${v.length} ${v.length == 1 ? "key" : "keys"}'; - if (v is List) return 'List · ${v.length} ${v.length == 1 ? "item" : "items"}'; - if (v is String) { - if (v.isEmpty) return 'String · empty'; - final t = v.trim(); - if ((t.startsWith('{') && t.endsWith('}')) || - (t.startsWith('[') && t.endsWith(']'))) { - return 'String · JSON-shaped'; - } - return 'String'; - } - return v.runtimeType.toString(); - } - - dynamic _parsedJson() { - final v = widget.entry.value; - if (v is! String) return null; - try { - final p = jsonDecode(v); - if (p is Map || p is List) return p; - } catch (_) {} - return null; - } - - dynamic _displayValue() { - final v = widget.entry.value; - if (v is Map || v is List) return v; - return _parsedJson() ?? v; - } - - bool get _isJsonLike { - final v = widget.entry.value; - if (v is Map || v is List) return true; - return _parsedJson() != null; - } - - String _sizeLabel() { - final raw = widget.entry.value is String - ? widget.entry.value as String - : const JsonEncoder.withIndent(' ').convert(widget.entry.value); - return AppConstants.formatBytes(raw.length); - } - - String _captureText() { - final v = widget.entry.value; - return v is String ? v : const JsonEncoder.withIndent(' ').convert(v); - } - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final mode = ref.watch(bodyViewModeProvider); - final devices = ref.watch(connectedDevicesProvider); - final platform = devices - .where((d) => d.deviceId == widget.entry.deviceId) - .map((d) => d.platform) - .firstOrNull ?? - 'react_native'; - final codeLang = CodeGenerator.langForPlatform(platform); - final codeLabel = CodeGenerator.labelFor(codeLang); - - final monoPrimary = TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 13, - height: 1.5, - color: _textPrimary(isDark), - ); - final monoSecondary = TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - height: 1.5, - color: _textSecondary(isDark), - ); - - return SingleChildScrollView( - controller: _scrollController, - physics: const ClampingScrollPhysics(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ────────────────────────────────────────────────────────── - // 1) HEADER — operation accent + storage type + key chip - // ────────────────────────────────────────────────────────── - Padding( - padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _OpBadge(label: widget.entry.operation, color: _opColor()), - const SizedBox(width: 8), - _TypeBadge(label: widget.entry.storageType.name), - const Spacer(), - _HeaderIconButton( - icon: LucideIcons.copy, - tooltip: S.of(context).copyKey, - isDark: isDark, - onTap: () => _copyText(context, widget.entry.key, 'Key'), - ), - const SizedBox(width: 4), - _HeaderIconButton( - icon: LucideIcons.camera, - tooltip: _isJsonLike - ? S.of(context).captureDataJson - : S.of(context).captureDataText, - isDark: isDark, - onTap: () => - _captureData(isDark, devices, codeLang, codeLabel), - ), - const SizedBox(width: 4), - _HeaderIconButton( - icon: LucideIcons.x, - tooltip: S.of(context).close, - isDark: isDark, - onTap: () => widget.onClose?.call(), - ), - ], - ), - ), - - // ────────────────────────────────────────────────────────── - // 2) KEY DISPLAY — large monospace, hero element - // ────────────────────────────────────────────────────────── - Padding( - padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - _Label(text: 'KEY', isDark: isDark), - const Spacer(), - _HeaderIconButton( - icon: LucideIcons.copy, - tooltip: 'Copy key', - isDark: isDark, - onTap: () => _copyText(context, widget.entry.key, 'Key'), - ), - ], - ), - const SizedBox(height: 8), - SelectableText( - widget.entry.key, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 15, - fontWeight: FontWeight.w600, - letterSpacing: -0.2, - color: _textPrimary(isDark), - height: 1.4, - ), - ), - ], - ), - ), - - // ────────────────────────────────────────────────────────── - // 3) METADATA GRID — 2x2 bento layout - // ────────────────────────────────────────────────────────── - Padding( - padding: const EdgeInsets.fromLTRB(20, 22, 20, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _Label(text: 'METADATA', isDark: isDark), - const SizedBox(height: 10), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _MetaCell( - label: 'SHAPE', - value: _shapeOf(widget.entry.value), - valueStyle: monoPrimary, - isDark: isDark, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _MetaCell( - label: 'SIZE', - value: _sizeLabel(), - valueStyle: monoPrimary, - isDark: isDark, - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _MetaCell( - label: 'DEVICE', - value: widget.entry.deviceId, - valueStyle: monoSecondary, - isDark: isDark, - monospace: true, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _MetaCell( - label: 'CAPTURED', - value: DateFormat('HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch( - widget.entry.timestamp), - ), - valueStyle: monoPrimary, - isDark: isDark, - ), - ), - ], - ), - ], - ), - ), - - // ────────────────────────────────────────────────────────── - // 4) DIVIDER — separates data zones - // ────────────────────────────────────────────────────────── - Padding( - padding: const EdgeInsets.fromLTRB(20, 24, 20, 0), - child: Container(height: 1, color: _divider(isDark)), - ), - - // ────────────────────────────────────────────────────────── - // 5) VALUE SECTION — switcher + content - // ────────────────────────────────────────────────────────── - if (widget.entry.value != null && _isJsonLike) ...[ - Padding( - padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), - child: SizedBox( - width: double.infinity, - child: ViewModeSwitcher( - current: mode, - codeLabel: codeLabel, - onChanged: (m) => - ref.read(bodyViewModeProvider.notifier).set(m), - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), - child: _buildValueContent( - isDark: isDark, - mode: mode, - codeLang: codeLang, - codeLabel: codeLabel, - ), - ), - ] else if (widget.entry.value != null) ...[ - Padding( - padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), - child: Row( - children: [ - _Label(text: 'VALUE', isDark: isDark), - const Spacer(), - _HeaderIconButton( - icon: LucideIcons.copy, - tooltip: 'Copy value', - isDark: isDark, - onTap: () => _copyText(context, _captureText(), 'Value'), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(20, 10, 20, 28), - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: isDark - ? Colors.white.withValues(alpha: 0.03) - : Colors.black.withValues(alpha: 0.025), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: _divider(isDark)), - ), - child: SelectableText( - widget.entry.value.toString(), - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 12, - height: 1.6, - color: _textPrimary(isDark), - ), - ), - ), - ), - ] else ...[ - Padding( - padding: const EdgeInsets.fromLTRB(20, 24, 20, 28), - child: _EmptyValue(isDark: isDark), - ), - ], - ], - ), - ); - } - - Widget _buildValueContent({ - required bool isDark, - required BodyViewMode mode, - required CodeLang codeLang, - required String codeLabel, - }) { - final value = _displayValue(); - - return DeferredBuilder( - key: ValueKey(mode), - builder: (_) { - switch (mode) { - case BodyViewMode.tree: - return Padding( - padding: const EdgeInsets.only(bottom: 24), - child: JsonViewer(data: value, initiallyExpanded: true), - ); - case BodyViewMode.json: - return Padding( - padding: const EdgeInsets.only(bottom: 24), - child: JsonPrettyViewer(data: value), - ); - case BodyViewMode.code: - return Padding( - padding: const EdgeInsets.only(bottom: 24), - child: CodeViewer( - generated: CodeGenerator.generate(value, codeLang), - lang: codeLang, - languageLabel: codeLabel, - ), - ); - } - }, - ); - } - - void _copyText(BuildContext context, String text, String label) { - Clipboard.setData(ClipboardData(text: text)); - showCopiedToast(context, label: '$label copied'); - } - - // ── Screenshot: data only (KEY + VALUE in current view mode) ── - void _captureData( - bool isDark, - List devices, - CodeLang codeLang, - String codeLabel, - ) { - final value = _displayValue(); - final monoKey = TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 14, - fontWeight: FontWeight.w600, - letterSpacing: -0.2, - color: _textPrimary(isDark), - ); - final labelStyle = TextStyle( - fontSize: 10, - fontWeight: FontWeight.w700, - letterSpacing: 1.2, - color: isDark ? const Color(0xFF6B6B6B) : const Color(0xFF8B8B8B), - ); - final divider = _divider(isDark); - final mode = ref.read(bodyViewModeProvider); - - // Value widget: respect 3-mode only when the payload is JSON-like. - // Plain text/number/bool capture as raw text — no switcher chrome. - final Widget valueWidget; - if (_isJsonLike) { - valueWidget = switch (mode) { - BodyViewMode.tree => JsonViewer(data: value, initiallyExpanded: true), - BodyViewMode.json => JsonPrettyViewer(data: value), - BodyViewMode.code => CodeViewer( - generated: CodeGenerator.generate(value, codeLang), - lang: codeLang, - languageLabel: codeLabel, - ), - }; - } else if (widget.entry.value == null) { - valueWidget = const SizedBox.shrink(); - } else { - valueWidget = Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: isDark - ? Colors.white.withValues(alpha: 0.03) - : Colors.black.withValues(alpha: 0.025), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: divider), - ), - child: SelectableText( - widget.entry.value.toString(), - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 12, - height: 1.6, - color: _textPrimary(isDark), - ), - ), - ); - } - - // Header style matches _storageScreenshot for visual consistency - // between full and data captures. - final capturedAt = DateTime.now().toIso8601String().split('.').first; - final labelColor = isDark ? Colors.grey[500] : Colors.grey[600]; - - final capture = Container( - color: isDark ? const Color(0xFF121212) : const Color(0xFFFAFAFA), - padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ── Header context (matches _storageScreenshot) ── - Row( - children: [ - Icon(LucideIcons.database, - size: 14, color: ColorTokens.warning), - const SizedBox(width: 6), - Text( - 'Storage Detail', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 1.0, - color: labelColor, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - '· ${widget.entry.storageType.name.toUpperCase()} · $capturedAt', - style: TextStyle( - fontSize: 10, - color: labelColor, - letterSpacing: 0.3, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - // ── Badges (mirror _StorageDetailRedesign header row) ── - const SizedBox(height: 14), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _OpBadge(label: widget.entry.operation, color: _opColor()), - const SizedBox(width: 8), - _TypeBadge(label: widget.entry.storageType.name), - ], - ), - const SizedBox(height: 18), - Container(height: 1, color: divider), - const SizedBox(height: 18), - Text('KEY', style: labelStyle), - const SizedBox(height: 8), - SelectableText(widget.entry.key, style: monoKey), - const SizedBox(height: 18), - Container(height: 1, color: divider), - const SizedBox(height: 18), - Text('VALUE', style: labelStyle), - const SizedBox(height: 10), - valueWidget, - ], - ), - ); - - captureWidgetAsImage( - context, - capture, - fileName: _buildScreenshotName(devices, '_data'), - onSaved: (path) { - if (mounted) showScreenshotSavedToast(context, filePath: path); - }, - ); - } - - String _buildScreenshotName(List devices, String suffix) { - final entry = widget.entry; - // Note: appName intentionally omitted to avoid leaking the app - // identifier into filenames shared with clients. - return buildRichScreenshotName( - type: entry.storageType.name, - subject: entry.key, - suffix: suffix, - ); - } -} - -// ── Helper widgets for the redesign ─────────────────────────── - -class _Label extends StatelessWidget { - final String text; - final bool isDark; - const _Label({required this.text, required this.isDark}); - - @override - Widget build(BuildContext context) { - return Text( - text, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w700, - letterSpacing: 1.2, - color: isDark ? const Color(0xFF6B6B6B) : const Color(0xFF8B8B8B), - ), - ); - } -} - -class _OpBadge extends StatelessWidget { - final String label; - final Color color; - const _OpBadge({required this.label, required this.color}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: color.withValues(alpha: 0.28), width: 1), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 6, - height: 6, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), - ), - const SizedBox(width: 6), - Text( - label.toUpperCase(), - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - fontWeight: FontWeight.w700, - color: color, - letterSpacing: 0.6, - ), - ), - ], - ), - ); - } -} - -class _TypeBadge extends StatelessWidget { - final String label; - const _TypeBadge({required this.label}); - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final fg = isDark ? const Color(0xFFB0B0B0) : const Color(0xFF4A4A4A); - final border = isDark - ? Colors.white.withValues(alpha: 0.10) - : Colors.black.withValues(alpha: 0.08); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - border: Border.all(color: border, width: 1), - ), - child: Text( - label, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - fontWeight: FontWeight.w600, - color: fg, - letterSpacing: 0.3, - ), - ), - ); - } -} - -class _HeaderIconButton extends StatefulWidget { - final IconData icon; - final String tooltip; - final bool isDark; - final VoidCallback onTap; - - const _HeaderIconButton({ - required this.icon, - required this.tooltip, - required this.isDark, - required this.onTap, - }); - - @override - State<_HeaderIconButton> createState() => _HeaderIconButtonState(); -} - -class _HeaderIconButtonState extends State<_HeaderIconButton> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - final hoverColor = widget.isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.black.withValues(alpha: 0.05); - final iconColor = widget.isDark - ? const Color(0xFF9A9A9A) - : const Color(0xFF6B6B6B); - return Tooltip( - message: widget.tooltip, - child: MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: widget.onTap, - behavior: HitTestBehavior.opaque, - child: AnimatedContainer( - duration: const Duration(milliseconds: 140), - curve: const Cubic(0.16, 1, 0.3, 1), - width: 30, - height: 30, - decoration: BoxDecoration( - color: _hovered ? hoverColor : Colors.transparent, - borderRadius: BorderRadius.circular(7), - ), - child: Icon(widget.icon, size: 14, color: iconColor), - ), - ), - ), - ); - } -} - -class _MetaCell extends StatelessWidget { - final String label; - final String value; - final TextStyle valueStyle; - final bool isDark; - final bool monospace; - - const _MetaCell({ - required this.label, - required this.value, - required this.valueStyle, - required this.isDark, - this.monospace = false, - }); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _Label(text: label, isDark: isDark), - const SizedBox(height: 6), - SelectableText( - value, - style: valueStyle, - maxLines: 1, - ), - ], - ); - } -} - -class _EmptyValue extends StatelessWidget { - final bool isDark; - const _EmptyValue({required this.isDark}); - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 16), - alignment: Alignment.center, - child: Column( - children: [ - Icon( - LucideIcons.database, - size: 24, - color: isDark ? const Color(0xFF4A4A4A) : const Color(0xFFB0B0B0), - ), - const SizedBox(height: 10), - Text( - 'No value stored', - style: TextStyle( - fontSize: 13, - color: isDark ? const Color(0xFF8B8B8B) : const Color(0xFF6B6B6B), - ), - ), - ], - ), - ); - } -} - -// ═══════════════════════════════════════════════ -// Fallback Detail -// ═══════════════════════════════════════════════ - -class _FallbackDetail extends StatefulWidget { - final UnifiedEvent event; - - const _FallbackDetail({required this.event}); - - @override - State<_FallbackDetail> createState() => _FallbackDetailState(); -} - -class _FallbackDetailState extends State<_FallbackDetail> { - final _scrollController = SmoothScrollController(); - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final event = widget.event; - final isDark = Theme.of(context).brightness == Brightness.dark; - - return SingleChildScrollView( - controller: _scrollController, - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionLabel('Title'), - const SizedBox(height: 6), - _CodeBlock(text: event.title, isDark: isDark), - const SizedBox(height: 16), - _SectionLabel('Details'), - const SizedBox(height: 6), - _CodeBlock(text: event.subtitle, isDark: isDark), - if (event.rawData != null) ...[ - const SizedBox(height: 16), - _SectionLabel('Raw Data'), - const SizedBox(height: 6), - if (event.rawData is Map || event.rawData is List) - JsonViewer(data: event.rawData, initiallyExpanded: true) - else - _CodeBlock(text: '${event.rawData}', isDark: isDark), - ], - ], - ), - ); - } -} - -// ═══════════════════════════════════════════════ -// Shared Widgets -// ═══════════════════════════════════════════════ - -class _SectionLabel extends StatelessWidget { - final String text; - - const _SectionLabel(this.text); - - @override - Widget build(BuildContext context) { - return TextComponent( - text, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Colors.grey[500], - letterSpacing: 0.5, - ), - ); - } -} - -class _TagChip extends StatelessWidget { - final String label; - final Color color; - - const _TagChip(this.label, {this.color = Colors.grey}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), - ), - child: TextComponent( - label, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - fontWeight: FontWeight.w600, - color: color, - ), - ), - ); - } -} - -class _CopyButton extends StatelessWidget { - final String tooltip; - final VoidCallback onTap; - final IconData icon; - - const _CopyButton({ - required this.tooltip, - required this.onTap, - this.icon = LucideIcons.copy, - }); - - @override - Widget build(BuildContext context) { - return _PressableButton( - onTap: onTap, - child: Tooltip( - message: tooltip, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Container( - width: 28, - height: 28, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - color: Colors.grey.withValues(alpha: 0.08), - ), - child: Icon(icon, size: 13, color: Colors.grey[500]), - ), - ), - ), - ); - } -} - -class _FormatToggleButton extends StatelessWidget { - final bool isFormatted; - final VoidCallback onToggle; - - const _FormatToggleButton({ - required this.isFormatted, - required this.onToggle, - }); - - @override - Widget build(BuildContext context) { - return _PressableButton( - onTap: onToggle, - child: Tooltip( - message: isFormatted ? 'Show raw' : 'Format JSON', - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - color: isFormatted - ? ColorTokens.primary.withValues(alpha: 0.15) - : Colors.grey.withValues(alpha: 0.08), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - LucideIcons.braces, - size: 12, - color: isFormatted ? ColorTokens.primary : Colors.grey[500], - ), - const SizedBox(width: 4), - TextComponent( - isFormatted ? 'Raw' : 'Format', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: - isFormatted ? ColorTokens.primary : Colors.grey[500], - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -class _CodeBlock extends StatelessWidget { - final String text; - final bool isDark; - - const _CodeBlock({required this.text, required this.isDark}); - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : const Color(0xFFF0F0F0), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: isDark - ? Colors.white.withValues(alpha: 0.05) - : Colors.black.withValues(alpha: 0.05), - ), - ), - child: TextComponent( - text, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 12, - color: isDark ? ColorTokens.lightBackground : Colors.black87, - height: 1.6, - ), - ), - ); - } -} - -class _ErrorBlock extends StatelessWidget { - final String text; - final bool isDark; - - const _ErrorBlock({required this.text, required this.isDark}); - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: ColorTokens.error.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(8), - border: - Border.all(color: ColorTokens.error.withValues(alpha: 0.15)), - ), - child: TextComponent( - text, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: ColorTokens.error.withValues(alpha: 0.9), - height: 1.5, - ), - ), - ); - } -} - -class _InfoRow extends StatelessWidget { - final String label; - final String value; - - const _InfoRow(this.label, this.value); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 5), - child: Row( - children: [ - SizedBox( - width: 100, - child: TextComponent( - label, - style: TextStyle( - fontSize: 11, - color: Colors.grey[500], - fontWeight: FontWeight.w500, - ), - ), - ), - TextComponent( - value, - style: const TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ); - } -} - -// ═══════════════════════════════════════════════ -// Helper -// ═══════════════════════════════════════════════ - -void _copyText(BuildContext context, String text, String label) { - Clipboard.setData(ClipboardData(text: text)); - showCopiedToast(context, label: '$label copied'); -} +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/shared/body_view.dart b/lib/features/all_events/presentation/shared/body_view.dart new file mode 100644 index 0000000..b766ea9 --- /dev/null +++ b/lib/features/all_events/presentation/shared/body_view.dart @@ -0,0 +1,282 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../components/feedback/empty_state.dart'; +import '../../../../components/viewers/json_viewer.dart'; +import '../../../../core/theme/theme_provider.dart'; +import '../../../../core/utils/code_generator.dart'; +import '../../../../core/utils/smooth_scroll_controller.dart'; +import '../../../../server/providers/server_providers.dart'; +import '../shared/section_label.dart'; + +/// Tab-style body viewer used by the network detail. Three modes +/// (`Tree` / `JSON` / `Code`) gated behind the global +/// [bodyViewModeProvider]. When the payload isn't JSON-shaped only the +/// `JSON` segment renders. +class BodyView extends ConsumerStatefulWidget { + final dynamic body; + final String label; + final String? deviceId; + final ValueChanged? onJsonModeChanged; + + const BodyView({ + super.key, + required this.body, + required this.label, + this.deviceId, + this.onJsonModeChanged, + }); + + @override + ConsumerState createState() => _BodyViewState(); +} + +class _BodyViewState extends ConsumerState { + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final viewMode = ref.watch(bodyViewModeProvider); + + // Treat null OR empty/whitespace-only strings as "no data" so the + // caller renders an EmptyState instead of a blank JSON viewer. + // AsyncJsonParser also normalises empty strings to null internally, + // but checking here avoids spinning the parser for the obvious case. + if (widget.body == null || + (widget.body is String && (widget.body as String).trim().isEmpty)) { + return EmptyState(icon: LucideIcons.fileText, title: 'No ${widget.label}'); + } + + // Look up the connected device's platform to pick the Code language. + final devices = ref.watch(connectedDevicesProvider); + final platform = widget.deviceId == null + ? 'react_native' + : devices + .where((d) => d.deviceId == widget.deviceId) + .map((d) => d.platform) + .firstOrNull ?? + 'react_native'; + final codeLang = CodeGenerator.langForPlatform(platform); + + return AsyncJsonParser( + rawData: widget.body, + builder: (context, parsedBody, isJson) { + final canToggle = isJson; + final effectiveMode = canToggle ? viewMode : BodyViewMode.json; + + return Column( + children: [ + Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + ), + ), + ), + child: Row( + children: [ + SectionLabel(widget.label), + const Spacer(), + if (canToggle) ...[ + ViewModeSegment( + label: 'Tree', + active: effectiveMode == BodyViewMode.tree, + position: ViewSegmentPosition.start, + onTap: () { + ref + .read(bodyViewModeProvider.notifier) + .set(BodyViewMode.tree); + widget.onJsonModeChanged?.call(false); + }, + ), + ViewModeSegment( + label: 'JSON', + active: effectiveMode == BodyViewMode.json, + position: ViewSegmentPosition.middle, + onTap: () { + ref + .read(bodyViewModeProvider.notifier) + .set(BodyViewMode.json); + widget.onJsonModeChanged?.call(true); + }, + ), + ViewModeSegment( + label: CodeGenerator.labelFor(codeLang), + active: effectiveMode == BodyViewMode.code, + position: ViewSegmentPosition.end, + onTap: () { + ref + .read(bodyViewModeProvider.notifier) + .set(BodyViewMode.code); + widget.onJsonModeChanged?.call(false); + }, + ), + ], + ], + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(16), + child: _buildContent( + parsedBody: parsedBody, + canToggle: canToggle, + mode: effectiveMode, + codeLang: codeLang, + ), + ), + ), + ], + ); + }, + ); + } + + Widget _buildContent({ + required dynamic parsedBody, + required bool canToggle, + required BodyViewMode mode, + required CodeLang codeLang, + }) { + if (!canToggle) { + return JsonPrettyViewer(data: parsedBody); + } + return DeferredBuilder( + key: ValueKey(mode), + builder: (_) { + switch (mode) { + case BodyViewMode.tree: + return JsonViewer(data: parsedBody, initiallyExpanded: true); + case BodyViewMode.json: + return JsonPrettyViewer(data: widget.body); + case BodyViewMode.code: + final generated = CodeGenerator.generate(parsedBody, codeLang); + return SingleChildScrollView( + child: CodeViewer( + generated: generated, + lang: codeLang, + languageLabel: CodeGenerator.labelFor(codeLang), + ), + ); + } + }, + ); + } +} + +/// Inline Tree/JSON toggle for use inside a ScrollView (non-tabbed +/// contexts). Code mode falls back to TypeScript here because we don't +/// know the originating device. +class InlineJsonView extends ConsumerStatefulWidget { + final dynamic data; + final String label; + + const InlineJsonView({super.key, required this.data, required this.label}); + + @override + ConsumerState createState() => _InlineJsonViewState(); +} + +class _InlineJsonViewState extends ConsumerState { + @override + Widget build(BuildContext context) { + final viewMode = ref.watch(bodyViewModeProvider); + + // Inline views don't know the device, so Code mode falls back to TS. + final codeLang = CodeGenerator.langForPlatform('react_native'); + + return AsyncJsonParser( + rawData: widget.data, + builder: (context, parsed, isJson) { + final canToggle = isJson; + final effectiveMode = canToggle ? viewMode : BodyViewMode.json; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + SectionLabel(widget.label), + const Spacer(), + if (canToggle) ...[ + ViewModeSegment( + label: 'Tree', + active: effectiveMode == BodyViewMode.tree, + position: ViewSegmentPosition.start, + onTap: () => ref + .read(bodyViewModeProvider.notifier) + .set(BodyViewMode.tree), + ), + ViewModeSegment( + label: 'JSON', + active: effectiveMode == BodyViewMode.json, + position: ViewSegmentPosition.middle, + onTap: () => ref + .read(bodyViewModeProvider.notifier) + .set(BodyViewMode.json), + ), + ViewModeSegment( + label: CodeGenerator.labelFor(codeLang), + active: effectiveMode == BodyViewMode.code, + position: ViewSegmentPosition.end, + onTap: () => ref + .read(bodyViewModeProvider.notifier) + .set(BodyViewMode.code), + ), + ], + ], + ), + const SizedBox(height: 8), + _buildInlineContent( + parsed: parsed, + canToggle: canToggle, + mode: effectiveMode, + codeLang: codeLang, + ), + ], + ); + }, + ); + } + + Widget _buildInlineContent({ + required dynamic parsed, + required bool canToggle, + required BodyViewMode mode, + required CodeLang codeLang, + }) { + if (!canToggle) return JsonPrettyViewer(data: widget.data); + return DeferredBuilder( + key: ValueKey(mode), + builder: (_) { + switch (mode) { + case BodyViewMode.tree: + return JsonViewer(data: parsed, initiallyExpanded: true); + case BodyViewMode.json: + return JsonPrettyViewer(data: widget.data); + case BodyViewMode.code: + final generated = CodeGenerator.generate(parsed, codeLang); + return CodeViewer( + generated: generated, + lang: codeLang, + languageLabel: CodeGenerator.labelFor(codeLang), + ); + } + }, + ); + } +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/shared/code_block.dart b/lib/features/all_events/presentation/shared/code_block.dart new file mode 100644 index 0000000..ad142ed --- /dev/null +++ b/lib/features/all_events/presentation/shared/code_block.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import '../../../../components/text/text_component.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/theme/color_tokens.dart'; + +/// Monospaced block of text in a tinted box — used to render raw JSON keys, +/// stack traces, pretty-printed payloads. Width stretches to fill the +/// parent so it sits naturally inside the detail panel column. +class CodeBlock extends StatelessWidget { + final String text; + final bool isDark; + + const CodeBlock({super.key, required this.text, required this.isDark}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark ? ColorTokens.darkBackground : const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.05), + ), + ), + child: TextComponent( + text, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + color: isDark ? ColorTokens.lightBackground : Colors.black87, + height: 1.6, + ), + ), + ); + } +} diff --git a/lib/features/all_events/presentation/shared/copy_button.dart b/lib/features/all_events/presentation/shared/copy_button.dart new file mode 100644 index 0000000..99c16e1 --- /dev/null +++ b/lib/features/all_events/presentation/shared/copy_button.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../buttons/pressable_button.dart'; + +/// 28×28 round copy icon with the shared [PressableButton] press feedback. +/// Tooltip and on-tap are caller-supplied. +class CopyButton extends StatelessWidget { + final String tooltip; + final VoidCallback onTap; + final IconData icon; + + const CopyButton({ + super.key, + required this.tooltip, + required this.onTap, + this.icon = LucideIcons.copy, + }); + + @override + Widget build(BuildContext context) { + return PressableButton( + onTap: onTap, + child: Tooltip( + message: tooltip, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: Colors.grey.withValues(alpha: 0.08), + ), + child: Icon(icon, size: 13, color: Colors.grey[500]), + ), + ), + ), + ); + } +} diff --git a/lib/features/all_events/presentation/shared/detail_tab_bar.dart b/lib/features/all_events/presentation/shared/detail_tab_bar.dart new file mode 100644 index 0000000..3f06400 --- /dev/null +++ b/lib/features/all_events/presentation/shared/detail_tab_bar.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +/// Pill-style tab bar used inside the All Events detail panel (e.g. for +/// the Request / Response / Timing tabs of a network entry). Each tab sits +/// in its own cell; the active cell renders as a tinted card with the +/// caller-supplied [accentColor] so the bar visibly belongs to the detail +/// it controls. +class DetailTabBar extends StatelessWidget { + final TabController controller; + final bool isDark; + final Color accentColor; + final List tabs; + + const DetailTabBar({ + super.key, + required this.controller, + required this.isDark, + required this.accentColor, + required this.tabs, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(8), + ), + child: TabBar( + controller: controller, + labelStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + unselectedLabelStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + ), + labelColor: accentColor, + unselectedLabelColor: isDark ? Colors.grey[500] : Colors.grey[600], + indicatorSize: TabBarIndicatorSize.tab, + indicator: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.white, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isDark + ? accentColor.withValues(alpha: 0.25) + : Colors.black.withValues(alpha: 0.06), + ), + boxShadow: isDark + ? null + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + indicatorPadding: EdgeInsets.zero, + dividerHeight: 0, + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateProperty.all(Colors.transparent), + padding: EdgeInsets.zero, + labelPadding: EdgeInsets.zero, + tabs: tabs + .map((t) => Tab(height: 28, text: t)) + .toList(), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/shared/empty_value.dart b/lib/features/all_events/presentation/shared/empty_value.dart new file mode 100644 index 0000000..451ee25 --- /dev/null +++ b/lib/features/all_events/presentation/shared/empty_value.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +/// Empty-state placeholder used by storage / state detail panels when the +/// underlying value was deleted or never set. Communicates "no value" with +/// a database icon and a muted label — never a blank column. +class EmptyValue extends StatelessWidget { + final bool isDark; + const EmptyValue({super.key, required this.isDark}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 16), + alignment: Alignment.center, + child: Column( + children: [ + Icon( + LucideIcons.database, + size: 24, + color: isDark ? const Color(0xFF4A4A4A) : const Color(0xFFB0B0B0), + ), + const SizedBox(height: 10), + Text( + 'No value stored', + style: TextStyle( + fontSize: 13, + color: isDark ? const Color(0xFF8B8B8B) : const Color(0xFF6B6B6B), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/all_events/presentation/shared/error_block.dart b/lib/features/all_events/presentation/shared/error_block.dart new file mode 100644 index 0000000..552319b --- /dev/null +++ b/lib/features/all_events/presentation/shared/error_block.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +import '../../../../components/text/text_component.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/theme/color_tokens.dart'; + +/// Tinted red code block used for stack traces and error payloads. Same +/// shape as [CodeBlock] but with an [ColorTokens.error] tint so the failure +/// is obvious in a glance. +class ErrorBlock extends StatelessWidget { + final String text; + final bool isDark; + + const ErrorBlock({super.key, required this.text, required this.isDark}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: ColorTokens.error.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + border: + Border.all(color: ColorTokens.error.withValues(alpha: 0.15)), + ), + child: TextComponent( + text, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: ColorTokens.error.withValues(alpha: 0.9), + height: 1.5, + ), + ), + ); + } +} diff --git a/lib/features/all_events/presentation/shared/format_toggle_button.dart b/lib/features/all_events/presentation/shared/format_toggle_button.dart new file mode 100644 index 0000000..988873a --- /dev/null +++ b/lib/features/all_events/presentation/shared/format_toggle_button.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../components/text/text_component.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../buttons/pressable_button.dart'; + +/// Toggle that flips between "Format JSON" and "Show raw". When formatted, +/// the button tints to [ColorTokens.primary]; when raw, it falls back to +/// neutral grey so the active state is unmistakable. +class FormatToggleButton extends StatelessWidget { + final bool isFormatted; + final VoidCallback onToggle; + + const FormatToggleButton({ + super.key, + required this.isFormatted, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + return PressableButton( + onTap: onToggle, + child: Tooltip( + message: isFormatted ? 'Show raw' : 'Format JSON', + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: isFormatted + ? ColorTokens.primary.withValues(alpha: 0.15) + : Colors.grey.withValues(alpha: 0.08), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.braces, + size: 12, + color: isFormatted ? ColorTokens.primary : Colors.grey[500], + ), + const SizedBox(width: 4), + TextComponent( + isFormatted ? 'Raw' : 'Format', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: + isFormatted ? ColorTokens.primary : Colors.grey[500], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/all_events/presentation/shared/info_row.dart b/lib/features/all_events/presentation/shared/info_row.dart new file mode 100644 index 0000000..90e1836 --- /dev/null +++ b/lib/features/all_events/presentation/shared/info_row.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import '../../../../components/text/text_component.dart'; +import '../../../../core/constants/app_constants.dart'; + +/// Two-column "label : value" row with the label in a fixed 100px gutter. +/// Used for displaying key/value metadata when the value fits on a single +/// line (status code, content type, timestamp, …). +class InfoRow extends StatelessWidget { + final String label; + final String value; + + const InfoRow(this.label, this.value, {super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + children: [ + SizedBox( + width: 100, + child: TextComponent( + label, + style: TextStyle( + fontSize: 11, + color: Colors.grey[500], + fontWeight: FontWeight.w500, + ), + ), + ), + TextComponent( + value, + style: const TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/all_events/presentation/shared/label.dart b/lib/features/all_events/presentation/shared/label.dart new file mode 100644 index 0000000..cbcc5b5 --- /dev/null +++ b/lib/features/all_events/presentation/shared/label.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +/// Small uppercase section label used in detail panels (e.g. "MESSAGE", +/// "REQUEST HEADERS"). Color is muted so it sits visually behind the body +/// content. +class Label extends StatelessWidget { + final String text; + final bool isDark; + const Label({super.key, required this.text, required this.isDark}); + + @override + Widget build(BuildContext context) { + return Text( + text, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + color: isDark ? const Color(0xFF6B6B6B) : const Color(0xFF8B8B8B), + ), + ); + } +} diff --git a/lib/features/all_events/presentation/shared/meta_cell.dart b/lib/features/all_events/presentation/shared/meta_cell.dart new file mode 100644 index 0000000..c8fab0d --- /dev/null +++ b/lib/features/all_events/presentation/shared/meta_cell.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +import 'label.dart'; + +/// One cell of a metadata grid: small uppercase label ([Label]) over a +/// selectable single-line value. Adopts the caller's [valueStyle] for the +/// value; the label is the same color as [Label]. +class MetaCell extends StatelessWidget { + final String label; + final String value; + final TextStyle valueStyle; + final bool isDark; + final bool monospace; + + const MetaCell({ + super.key, + required this.label, + required this.value, + required this.valueStyle, + required this.isDark, + this.monospace = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Label(text: label, isDark: isDark), + const SizedBox(height: 6), + SelectableText( + value, + style: valueStyle, + maxLines: 1, + ), + ], + ); + } +} diff --git a/lib/features/all_events/presentation/shared/op_badge.dart b/lib/features/all_events/presentation/shared/op_badge.dart new file mode 100644 index 0000000..4fab49e --- /dev/null +++ b/lib/features/all_events/presentation/shared/op_badge.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/constants/app_constants.dart'; + +/// Colored pill with a leading dot — used to label storage operations like +/// "PUT", "GET", "DELETE" with a tint color per operation. +class OpBadge extends StatelessWidget { + final String label; + final Color color; + const OpBadge({super.key, required this.label, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.28), width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Text( + label.toUpperCase(), + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + fontWeight: FontWeight.w700, + color: color, + letterSpacing: 0.6, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/all_events/presentation/shared/section_label.dart b/lib/features/all_events/presentation/shared/section_label.dart new file mode 100644 index 0000000..4f4dade --- /dev/null +++ b/lib/features/all_events/presentation/shared/section_label.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +import '../../../../components/text/text_component.dart'; + +/// Small uppercase section label rendered via [TextComponent] so it +/// participates in the global text style. Used in the right-hand detail +/// panel of the All Events page to introduce each chunk of metadata. +class SectionLabel extends StatelessWidget { + final String text; + + const SectionLabel(this.text, {super.key}); + + @override + Widget build(BuildContext context) { + return TextComponent( + text, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.grey[500], + letterSpacing: 0.5, + ), + ); + } +} diff --git a/lib/features/all_events/presentation/shared/tag_chip.dart b/lib/features/all_events/presentation/shared/tag_chip.dart new file mode 100644 index 0000000..566bde7 --- /dev/null +++ b/lib/features/all_events/presentation/shared/tag_chip.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import '../../../../components/text/text_component.dart'; +import '../../../../core/constants/app_constants.dart'; + +/// Small mono-font tag chip (e.g. log tag, error source). Tinted by the +/// caller-supplied [color]; uses a 10% alpha fill so it never fights the +/// surface chrome. +class TagChip extends StatelessWidget { + final String label; + final Color color; + + const TagChip(this.label, {super.key, this.color = Colors.grey}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: TextComponent( + label, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ); + } +} diff --git a/lib/features/all_events/presentation/shared/type_badge.dart b/lib/features/all_events/presentation/shared/type_badge.dart new file mode 100644 index 0000000..972af99 --- /dev/null +++ b/lib/features/all_events/presentation/shared/type_badge.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/constants/app_constants.dart'; + +/// Neutral pill with a thin border — used for storage types ("ASYNC", +/// "SECURE") where the operation badge ([OpBadge]) already carries the +/// dominant color cue. +class TypeBadge extends StatelessWidget { + final String label; + const TypeBadge({super.key, required this.label}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final fg = isDark ? const Color(0xFFB0B0B0) : const Color(0xFF4A4A4A); + final border = isDark + ? Colors.white.withValues(alpha: 0.10) + : Colors.black.withValues(alpha: 0.08); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all(color: border, width: 1), + ), + child: Text( + label, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + fontWeight: FontWeight.w600, + color: fg, + letterSpacing: 0.3, + ), + ), + ); + } +} diff --git a/lib/features/all_events/presentation/shared/type_info.dart b/lib/features/all_events/presentation/shared/type_info.dart new file mode 100644 index 0000000..3a0f3d8 --- /dev/null +++ b/lib/features/all_events/presentation/shared/type_info.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + +/// Per-event-type metadata used by both the event row badge and the +/// filter chip (so they stay visually in sync). Color drives the badge +/// background, icon, and the chip's active state. +class TypeInfo { + final Color color; + final IconData icon; + final String label; + const TypeInfo({required this.color, required this.icon, required this.label}); +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/status/reload_pill.dart b/lib/features/all_events/presentation/status/reload_pill.dart new file mode 100644 index 0000000..a929b44 --- /dev/null +++ b/lib/features/all_events/presentation/status/reload_pill.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../core/theme/color_tokens.dart'; +import '../../../../l10n/app_localizations.dart'; + +/// Reload connection pill — sits next to the status pill and forces every +/// connected SDK into its reconnect path. Tactile feedback on press. +class ReloadPill extends StatefulWidget { + final bool restarting; + final String tooltip; + final VoidCallback onTap; + + const ReloadPill({ + super.key, + required this.restarting, + required this.tooltip, + required this.onTap, + }); + + @override + State createState() => _ReloadPillState(); +} + +class _ReloadPillState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _spinCtrl; + + @override + void initState() { + super.initState(); + _spinCtrl = AnimationController( + vsync: this, + duration: const Duration(seconds: 1), + ); + if (widget.restarting) _spinCtrl.repeat(); + } + + @override + void didUpdateWidget(covariant ReloadPill old) { + super.didUpdateWidget(old); + if (widget.restarting && !_spinCtrl.isAnimating) { + _spinCtrl.repeat(); + } else if (!widget.restarting && _spinCtrl.isAnimating) { + _spinCtrl.reset(); + } + } + + @override + void dispose() { + _spinCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Tooltip( + message: widget.tooltip, + child: GestureDetector( + onTap: widget.restarting ? null : widget.onTap, + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: widget.restarting + ? ColorTokens.primary.withValues(alpha: isDark ? 0.14 : 0.10) + : isDark + ? Colors.white.withValues(alpha: 0.04) + : Colors.black.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: widget.restarting + ? ColorTokens.primary.withValues(alpha: 0.35) + : isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + RotationTransition( + turns: _spinCtrl, + child: Icon( + LucideIcons.refreshCw, + size: 12, + color: widget.restarting + ? ColorTokens.primary + : (isDark ? Colors.grey[400] : Colors.grey[600]), + ), + ), + const SizedBox(width: 5), + Text( + widget.restarting ? S.of(context).restarting : widget.tooltip, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: widget.restarting + ? ColorTokens.primary + : (isDark ? Colors.grey[300] : Colors.grey[700]), + letterSpacing: 0.1, + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/status/server_status_pill.dart b/lib/features/all_events/presentation/status/server_status_pill.dart new file mode 100644 index 0000000..cb53e99 --- /dev/null +++ b/lib/features/all_events/presentation/status/server_status_pill.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../../../l10n/app_localizations.dart'; + +/// Compact status indicator for the All Events header. +/// +/// Single rounded pill that conveys server state + port + device count. +/// Dot gently pulses when the server is running (perpetual micro-motion), +/// turns warning amber if the port is occupied, error red if stopped. +class ServerStatusPill extends StatefulWidget { + final bool serverRunning; + final bool portOccupied; + final int port; + final int deviceCount; + final String? startError; + + const ServerStatusPill({ + super.key, + required this.serverRunning, + required this.portOccupied, + required this.port, + required this.deviceCount, + required this.startError, + }); + + @override + State createState() => _ServerStatusPillState(); +} + +class _ServerStatusPillState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _pulseCtrl; + + @override + void initState() { + super.initState(); + _pulseCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1800), + ); + if (widget.serverRunning) _pulseCtrl.repeat(reverse: true); + } + + @override + void didUpdateWidget(covariant ServerStatusPill old) { + super.didUpdateWidget(old); + if (widget.serverRunning && !_pulseCtrl.isAnimating) { + _pulseCtrl.repeat(reverse: true); + } else if (!widget.serverRunning && _pulseCtrl.isAnimating) { + _pulseCtrl.stop(); + _pulseCtrl.value = 0; + } + } + + @override + void dispose() { + _pulseCtrl.dispose(); + super.dispose(); + } + + Color _accent() { + if (widget.portOccupied) return ColorTokens.warning; + if (!widget.serverRunning) return ColorTokens.error; + return ColorTokens.success; + } + + String _label() { + if (widget.portOccupied) return S.of(context).portOccupied(widget.port); + if (!widget.serverRunning) return S.of(context).stopped; + return 'Port ${widget.port}'; + } + + IconData _icon() { + if (widget.portOccupied) return LucideIcons.triangleAlert; + if (!widget.serverRunning) return LucideIcons.circlePause; + return LucideIcons.radio; + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final accent = _accent(); + final label = _label(); + final icon = _icon(); + + return Tooltip( + message: widget.portOccupied && widget.startError != null + ? widget.startError! + : label, + child: Container( + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: isDark + ? accent.withValues(alpha: 0.08) + : accent.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: accent.withValues(alpha: 0.18), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Pulsing dot + AnimatedBuilder( + animation: _pulseCtrl, + builder: (_, __) { + final scale = widget.serverRunning + ? 1.0 + 0.4 * _pulseCtrl.value + : 1.0; + final alpha = widget.serverRunning + ? 1.0 - 0.5 * _pulseCtrl.value + : 1.0; + return Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: accent.withValues(alpha: alpha), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: accent.withValues(alpha: 0.45 * alpha), + blurRadius: 6 * scale, + spreadRadius: 1 * scale, + ), + ], + ), + ); + }, + ), + const SizedBox(width: 6), + Icon(icon, size: 11, color: accent.withValues(alpha: 0.85)), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: 11, + fontFamily: AppConstants.monoFontFamily, + color: accent.withValues(alpha: 0.92), + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + ), + ), + if (widget.deviceCount > 0) ...[ + Container( + width: 1, + height: 12, + margin: const EdgeInsets.symmetric(horizontal: 8), + color: accent.withValues(alpha: 0.25), + ), + Text( + '${widget.deviceCount}', + style: TextStyle( + fontSize: 11, + fontFamily: AppConstants.monoFontFamily, + color: isDark ? Colors.grey[300] : Colors.grey[700], + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 3), + Text( + widget.deviceCount == 1 ? 'device' : 'devices', + style: TextStyle( + fontSize: 10, + color: isDark ? Colors.grey[500] : Colors.grey[600], + ), + ), + ], + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/console/presentation/detail/detail_panel.dart b/lib/features/console/presentation/detail/detail_panel.dart new file mode 100644 index 0000000..22da614 --- /dev/null +++ b/lib/features/console/presentation/detail/detail_panel.dart @@ -0,0 +1,240 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../components/misc/status_badge.dart'; +import '../../../../components/text/text_component.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../../../core/utils/smooth_scroll_controller.dart'; +import '../../../../core/utils/toast_utils.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../models/log/log_entry.dart'; +import '../shared/level_color.dart'; +import '../shared/section_label.dart'; +import '../shared/tag_badge.dart'; +import 'log_message_block.dart'; +import 'metadata_block.dart'; +import 'screenshot_builder.dart'; + +/// Right-pane detail panel for a selected log entry. Owns: +/// • the level + timestamp header with copy/screenshot/close +/// • the tag, message, metadata, and stack-trace sections +/// • the level-tinted stack trace styling +class LogDetailPanel extends StatefulWidget { + final LogEntry entry; + final VoidCallback onClose; + + const LogDetailPanel({ + super.key, + required this.entry, + required this.onClose, + }); + + @override + State createState() => _LogDetailPanelState(); +} + +class _LogDetailPanelState extends State { + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final entry = widget.entry; + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final color = levelColor(entry.level); + final time = DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( + DateTime.fromMillisecondsSinceEpoch(entry.timestamp), + ); + + return Container( + color: isDark ? ColorTokens.darkSurface : ColorTokens.lightSurface, + child: Column( + children: [ + // Header bar + Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + color: isDark ? ColorTokens.darkBackground : Colors.white, + border: Border( + bottom: BorderSide( + color: theme.dividerColor.withValues(alpha: 0.5), + width: 1, + ), + ), + ), + child: Row( + children: [ + LogLevelBadge(level: entry.level.name), + const SizedBox(width: 10), + Expanded( + child: TextComponent( + time, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: Colors.grey[500], + ), + ), + ), + // Copy button + Tooltip( + message: S.of(context).copyMessage, + child: GestureDetector( + onTap: () { + Clipboard.setData( + ClipboardData(text: entry.message)); + showCopiedToast(context, label: S.of(context).logCopied); + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + width: 30, + height: 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + LucideIcons.copy, + size: 14, + color: Colors.grey[500], + ), + ), + ), + ), + ), + const SizedBox(width: 4), + // Screenshot button + Tooltip( + message: 'Capture detail as image', + child: GestureDetector( + onTap: () => buildLogDetailScreenshot( + context: context, + entry: entry, + isDark: isDark, + ), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + width: 30, + height: 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + LucideIcons.camera, + size: 14, + color: Colors.grey[500], + ), + ), + ), + ), + ), + const SizedBox(width: 4), + // Close button + Tooltip( + message: S.of(context).closePanel, + child: GestureDetector( + onTap: widget.onClose, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + width: 30, + height: 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + LucideIcons.x, + size: 16, + color: Colors.grey[500], + ), + ), + ), + ), + ), + ], + ), + ), + // Scrollable content + Expanded( + child: SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Tag + if (entry.tag != null) ...[ + SectionLabel(label: S.of(context).tag), + const SizedBox(height: 6), + TagBadge(tag: entry.tag!), + const SizedBox(height: 16), + ], + + // Message — with 3 view modes (Tree / JSON / Code), same + // pattern as the All Events detail panel. + SectionLabel(label: S.of(context).message), + const SizedBox(height: 6), + LogMessageBlock( + message: entry.message, + deviceId: entry.deviceId, + ), + + // Metadata + if (entry.metadata != null && + entry.metadata!.isNotEmpty) ...[ + const SizedBox(height: 20), + SectionLabel(label: S.of(context).metadata), + const SizedBox(height: 6), + MetadataBlock( + data: entry.metadata!, + deviceId: entry.deviceId, + ), + ], + + // Stack trace + if (entry.stackTrace != null) ...[ + const SizedBox(height: 20), + SectionLabel(label: S.of(context).stackTrace), + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: color.withValues(alpha: 0.15), + width: 1, + ), + ), + child: TextComponent( + entry.stackTrace!, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: color.withValues(alpha: 0.9), + height: 1.5, + ), + ), + ), + ], + ], + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/console/presentation/detail/detail_tab_bar.dart b/lib/features/console/presentation/detail/detail_tab_bar.dart new file mode 100644 index 0000000..3a2b8f3 --- /dev/null +++ b/lib/features/console/presentation/detail/detail_tab_bar.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/theme/color_tokens.dart'; + +/// Compact pill-style tab bar for the message view-mode toggle. +/// Same visual pattern as `_DetailTabBar` in `all_events_page.dart` +/// but with a `currentIndex + onSelect` callback instead of a +/// `TabController` — simpler to wire up in a private widget that +/// already manages its own state. +class DetailTabBar extends StatelessWidget { + final List tabs; + final int currentIndex; + final ValueChanged onSelect; + + const DetailTabBar({ + super.key, + required this.tabs, + required this.currentIndex, + required this.onSelect, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final accent = ColorTokens.primary; + return Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < tabs.length; i++) + _buildSegment(tabs[i], i, isDark, accent), + ], + ), + ); + } + + Widget _buildSegment(String label, int index, bool isDark, Color accent) { + final selected = index == currentIndex; + return GestureDetector( + onTap: () => onSelect(index), + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: selected + ? (isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.white) + : Colors.transparent, + borderRadius: BorderRadius.circular(6), + border: selected + ? Border.all( + color: isDark + ? accent.withValues(alpha: 0.25) + : Colors.black.withValues(alpha: 0.06), + ) + : Border.all(color: Colors.transparent), + boxShadow: selected && !isDark + ? const [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 2, + offset: Offset(0, 1), + ), + ] + : null, + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + letterSpacing: 0.2, + color: selected + ? accent + : (isDark ? Colors.grey[500] : Colors.grey[600]), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/console/presentation/detail/log_message_block.dart b/lib/features/console/presentation/detail/log_message_block.dart new file mode 100644 index 0000000..167c53a --- /dev/null +++ b/lib/features/console/presentation/detail/log_message_block.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../components/viewers/json_viewer.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../../../core/theme/theme_provider.dart'; +import '../../../../core/utils/code_generator.dart'; +import '../../../../server/providers/server_providers.dart'; +import 'plain_message_block.dart'; + +/// Renders the log message body with 3 view modes (Tree / JSON / +/// Code), same pattern as the All Events detail panel. JSON-parseable +/// messages get the toggle + viewer; plain text falls back to +/// [PlainMessageBlock] with no chrome. +class LogMessageBlock extends ConsumerWidget { + final String message; + final String deviceId; + + const LogMessageBlock({ + super.key, + required this.message, + required this.deviceId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final mode = ref.watch(bodyViewModeProvider); + + final devices = ref.watch(connectedDevicesProvider); + final platform = devices + .where((d) => d.deviceId == deviceId) + .map((d) => d.platform) + .firstOrNull ?? + 'react_native'; + final codeLang = CodeGenerator.langForPlatform(platform); + + return AsyncJsonParser( + rawData: message, + builder: (context, parsed, isJson) { + // Plain text: skip the 3-mode toggle entirely. The user gets the + // raw message without any view-mode chrome. + if (!isJson) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: isDark + ? ColorTokens.darkBackground + : const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + width: 1, + ), + ), + padding: const EdgeInsets.all(12), + child: PlainMessageBlock(text: message, isDark: isDark), + ); + } + + final body = DeferredBuilder( + key: ValueKey(mode), + builder: (_) { + switch (mode) { + case BodyViewMode.tree: + return JsonViewer(data: parsed, initiallyExpanded: true); + case BodyViewMode.json: + return JsonPrettyViewer(data: parsed); + case BodyViewMode.code: + return CodeViewer( + generated: CodeGenerator.generate(parsed, codeLang), + lang: codeLang, + languageLabel: CodeGenerator.labelFor(codeLang), + ); + } + }, + ); + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: isDark ? ColorTokens.darkBackground : const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + child: ViewModeSwitcher( + current: mode, + codeLabel: CodeGenerator.labelFor(codeLang), + onChanged: (BodyViewMode m) => + ref.read(bodyViewModeProvider.notifier).set(m), + ), + ), + Padding( + padding: const EdgeInsets.all(12), + child: body, + ), + ], + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/features/console/presentation/detail/metadata_block.dart b/lib/features/console/presentation/detail/metadata_block.dart new file mode 100644 index 0000000..cfebfa1 --- /dev/null +++ b/lib/features/console/presentation/detail/metadata_block.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../components/viewers/json_viewer.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../../../core/theme/theme_provider.dart'; +import '../../../../core/utils/code_generator.dart'; +import '../../../../server/providers/server_providers.dart'; + +/// 3-mode (Tree / JSON / Code) toggle + viewer for the log metadata +/// map — same pattern as the message block but driven by the +/// separate `metadataViewModeProvider` so the metadata and message +/// can be in different modes simultaneously. +class MetadataBlock extends ConsumerWidget { + final Map data; + final String deviceId; + + const MetadataBlock({ + super.key, + required this.data, + required this.deviceId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final mode = ref.watch(metadataViewModeProvider); + final devices = ref.watch(connectedDevicesProvider); + final platform = devices + .where((d) => d.deviceId == deviceId) + .map((d) => d.platform) + .firstOrNull ?? + 'react_native'; + final codeLang = CodeGenerator.langForPlatform(platform); + final codeLabel = CodeGenerator.labelFor(codeLang); + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark ? ColorTokens.darkBackground : const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: SizedBox( + width: double.infinity, + child: ViewModeSwitcher( + current: mode, + codeLabel: codeLabel, + onChanged: (BodyViewMode m) => + ref.read(metadataViewModeProvider.notifier).state = m, + ), + ), + ), + DeferredBuilder( + key: ValueKey(mode), + builder: (_) { + switch (mode) { + case BodyViewMode.tree: + return JsonViewer(data: data, initiallyExpanded: true); + case BodyViewMode.json: + return JsonPrettyViewer(data: data); + case BodyViewMode.code: + return CodeViewer( + generated: CodeGenerator.generate(data, codeLang), + lang: codeLang, + languageLabel: codeLabel, + ); + } + }, + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/console/presentation/detail/plain_message_block.dart b/lib/features/console/presentation/detail/plain_message_block.dart new file mode 100644 index 0000000..92653b1 --- /dev/null +++ b/lib/features/console/presentation/detail/plain_message_block.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +import '../../../../components/text/text_component.dart'; +import '../../../../core/constants/app_constants.dart'; + +/// Plain-text rendering of a log message body — used by +/// [LogMessageBlock] when the message is not JSON-parseable. Skips +/// the 3-mode view toggle entirely. +class PlainMessageBlock extends StatelessWidget { + final String text; + final bool isDark; + + const PlainMessageBlock({ + super.key, + required this.text, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + return TextComponent( + text, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + height: 1.5, + color: isDark + ? const Color(0xFFCCCCCC) + : const Color(0xFF333333), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/console/presentation/detail/screenshot_builder.dart b/lib/features/console/presentation/detail/screenshot_builder.dart new file mode 100644 index 0000000..5327a19 --- /dev/null +++ b/lib/features/console/presentation/detail/screenshot_builder.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../components/misc/status_badge.dart'; +import '../../../../components/viewers/json_viewer.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../../../core/utils/screenshot_filename.dart'; +import '../../../../core/utils/screenshot_utils.dart'; +import '../../../../models/log/log_entry.dart'; +import '../shared/level_color.dart'; + +/// Builds the full-detail capture widget used by the detail panel's +/// "Capture as image" button. Renders the level + timestamp header, +/// tag chip, message block, metadata JsonViewer, and a level-tinted +/// stack trace block — all in one PNG-sized Column. +Widget buildLogDetailScreenshot({ + required BuildContext context, + required LogEntry entry, + required bool isDark, +}) { + final color = levelColor(entry.level); + final time = DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( + DateTime.fromMillisecondsSinceEpoch(entry.timestamp), + ); + + // Build a descriptive filename: log___full.png + final fileName = buildRichScreenshotName( + type: 'log', + subject: entry.tag ?? entry.level.name, + suffix: '_full', + ); + + captureWidgetAsImage( + context, + Container( + color: isDark ? ColorTokens.darkSurface : Colors.white, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + color: isDark ? ColorTokens.darkBackground : ColorTokens.lightSurface, + child: Row( + children: [ + Icon(LucideIcons.terminal, size: 16, color: ColorTokens.primary), + const SizedBox(width: 8), + LogLevelBadge(level: entry.level.name), + const SizedBox(width: 10), + Text( + time, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: Colors.grey[500], + ), + ), + ], + ), + ), + const Divider(height: 1), + // Tag + if (entry.tag != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('TAG', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.grey[500])), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: isDark ? Colors.white.withValues(alpha: 0.06) : Colors.black.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(4), + ), + child: Text(entry.tag!, style: TextStyle(fontFamily: AppConstants.monoFontFamily, fontSize: 10, color: Colors.grey[500])), + ), + ], + ), + ), + // Message + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('MESSAGE', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.grey[500])), + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark ? ColorTokens.darkBackground : const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isDark ? Colors.white.withValues(alpha: 0.06) : Colors.black.withValues(alpha: 0.06), + ), + ), + child: Text( + entry.message, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + height: 1.6, + color: isDark ? ColorTokens.lightBackground : ColorTokens.darkNeutral, + ), + ), + ), + ], + ), + ), + // Metadata + if (entry.metadata != null && entry.metadata!.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('METADATA', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.grey[500])), + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark ? ColorTokens.darkBackground : const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(8), + ), + child: JsonViewer(data: entry.metadata, initiallyExpanded: true), + ), + ], + ), + ), + // Stack trace + if (entry.stackTrace != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('STACK TRACE', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.grey[500])), + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withValues(alpha: 0.15)), + ), + child: Text( + entry.stackTrace!, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: color.withValues(alpha: 0.9), + height: 1.5, + ), + ), + ), + ], + ), + ), + ], + ), + ), + width: 600, + fileName: fileName, + ); + // The side-effect already ran; return a placeholder so callers can + // compose if needed. + return const SizedBox.shrink(); +} \ No newline at end of file diff --git a/lib/features/console/presentation/event_row/log_entry_content.dart b/lib/features/console/presentation/event_row/log_entry_content.dart new file mode 100644 index 0000000..2b0a33e --- /dev/null +++ b/lib/features/console/presentation/event_row/log_entry_content.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../../../components/misc/status_badge.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../../../core/utils/log_message_summary.dart'; +import '../../../../models/log/log_entry.dart'; +import '../shared/level_color.dart'; +import '../shared/tag_badge.dart'; + +/// One log row inside the streaming list — 3px colored left edge +/// (level accent) + mono timestamp + optional platform badge + +/// `LogLevelBadge` + optional tag + a single-line summarized message. +/// +/// The outer 8px-rounded container decoration is supplied by the +/// page via [StableListView]'s `decorationBuilder`; this widget just +/// renders the inner content with the same rounded corners via the +/// top-left radius on the colored edge bar. +class LogEntryContent extends StatelessWidget { + final LogEntry entry; + final String? platform; + + const LogEntryContent({ + super.key, + required this.entry, + this.platform, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final color = levelColor(entry.level); + final time = DateFormat('HH:mm:ss.SSS').format( + DateTime.fromMillisecondsSinceEpoch(entry.timestamp), + ); + + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + width: 3, + decoration: BoxDecoration( + color: color, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(8), + bottomLeft: Radius.circular(8), + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + children: [ + Text( + time, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 10, + color: Colors.grey[500], + ), + ), + const SizedBox(width: 8), + if (platform != null) ...[ + PlatformBadge(platform: platform!), + const SizedBox(width: 6), + ], + LogLevelBadge(level: entry.level.name), + if (entry.tag != null) ...[ + const SizedBox(width: 6), + TagBadge(tag: entry.tag!), + ], + ], + ), + const SizedBox(height: 6), + Text( + summarizeLogMessage(entry.message), + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + height: 1.4, + color: isDark + ? ColorTokens.lightBackground + : ColorTokens.darkNeutral, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/console/presentation/header/icon_btn.dart b/lib/features/console/presentation/header/icon_btn.dart new file mode 100644 index 0000000..58c02f7 --- /dev/null +++ b/lib/features/console/presentation/header/icon_btn.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/theme/color_tokens.dart'; + +/// 28×28 icon button used in the console toolbar's action group. +/// Branch order `isActive → isDanger+hover → hover → muted` is +/// load-bearing — keep `isActive` short-circuit before `isDanger` +/// so an active state never tints red. +class IconBtn extends StatefulWidget { + final IconData icon; + final String tooltip; + final bool isActive; + final bool isDanger; + final VoidCallback onTap; + + const IconBtn({ + super.key, + required this.icon, + required this.tooltip, + this.isActive = false, + this.isDanger = false, + required this.onTap, + }); + + @override + State createState() => _IconBtnState(); +} + +class _IconBtnState extends State { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + Color iconColor; + Color bgColor; + + if (widget.isActive) { + iconColor = ColorTokens.primary; + bgColor = ColorTokens.primary.withValues(alpha: 0.15); + } else if (widget.isDanger && _hovered) { + iconColor = ColorTokens.error; + bgColor = ColorTokens.error.withValues(alpha: 0.12); + } else if (_hovered) { + iconColor = isDark ? Colors.grey[300]! : Colors.grey[700]!; + bgColor = isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.06); + } else { + iconColor = Colors.grey[500]!; + bgColor = Colors.transparent; + } + + return Tooltip( + message: widget.tooltip, + child: GestureDetector( + onTap: widget.onTap, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 28, + height: 28, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(7), + ), + child: Icon( + widget.icon, + size: 14, + color: iconColor, + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/console/presentation/header/level_filter_chip.dart b/lib/features/console/presentation/header/level_filter_chip.dart new file mode 100644 index 0000000..a1eca28 --- /dev/null +++ b/lib/features/console/presentation/header/level_filter_chip.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +/// Rounded "DEBUG / INFO / WARN / ERROR" pill used in the console +/// toolbar. Active state tints the chip's bg + border with the +/// level's accent color; hover state tints the chip subtly without +/// the user committing to it. +class LevelFilterChip extends StatefulWidget { + final String label; + final bool isActive; + final Color color; + final VoidCallback onTap; + + const LevelFilterChip({ + super.key, + required this.label, + required this.isActive, + required this.color, + required this.onTap, + }); + + @override + State createState() => _LevelFilterChipState(); +} + +class _LevelFilterChipState extends State { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final bg = widget.isActive + ? widget.color.withValues(alpha: 0.15) + : _hovered + ? widget.color.withValues(alpha: 0.07) + : Colors.transparent; + + final borderColor = widget.isActive + ? widget.color.withValues(alpha: 0.4) + : _hovered + ? widget.color.withValues(alpha: 0.25) + : Colors.grey.withValues(alpha: 0.2); + + return Tooltip( + message: '${widget.isActive ? "Hide" : "Show"} ${widget.label} logs', + child: GestureDetector( + onTap: widget.onTap, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: borderColor, width: 1), + ), + child: Text( + widget.label, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + color: widget.isActive ? widget.color : Colors.grey[500], + ), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/console/presentation/header/toolbar.dart b/lib/features/console/presentation/header/toolbar.dart new file mode 100644 index 0000000..088c74c --- /dev/null +++ b/lib/features/console/presentation/header/toolbar.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../components/inputs/search_field.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../../../core/theme/theme_provider.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../models/log/log_entry.dart'; +import '../../provider/console_providers.dart'; +import '../shared/count_pill.dart'; +import '../shared/level_color.dart'; +import 'icon_btn.dart'; +import 'level_filter_chip.dart'; + +/// Top toolbar of the Console page — title + count pill, level +/// filter chips (DEBUG/INFO/WARN/ERROR), search field, action group +/// (auto-scroll, sort direction, clear). +class Toolbar extends ConsumerWidget { + final ValueNotifier entryCount; + final bool autoScroll; + final VoidCallback onToggleAutoScroll; + final VoidCallback onClear; + + const Toolbar({ + super.key, + required this.entryCount, + required this.autoScroll, + required this.onToggleAutoScroll, + required this.onClear, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final activeFilters = ref.watch(consoleFilterProvider); + + return Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: isDark ? ColorTokens.darkBackground : Colors.white, + ), + child: Row( + children: [ + // Title section + Icon(LucideIcons.terminal, size: 16, color: ColorTokens.primary), + const SizedBox(width: 8), + Text('Console', style: theme.textTheme.titleMedium), + const SizedBox(width: 8), + ValueListenableBuilder( + valueListenable: entryCount, + builder: (_, count, _) => CountPill(count: count), + ), + const SizedBox(width: 16), + + // Level filter chips + ...LogLevel.values.map((level) { + final isActive = activeFilters.contains(level); + return Padding( + padding: const EdgeInsets.only(right: 4), + child: LevelFilterChip( + label: level.name.toUpperCase(), + isActive: isActive, + color: levelColor(level), + onTap: () { + final current = ref.read(consoleFilterProvider); + if (isActive) { + ref.read(consoleFilterProvider.notifier).state = + current.difference({level}); + } else { + ref.read(consoleFilterProvider.notifier).state = { + ...current, + level, + }; + } + }, + ), + ); + }), + + const Spacer(), + + // Search + SizedBox( + width: 220, + child: SearchField( + hintText: S.of(context).searchLogs, + onChanged: (value) { + ref.read(consoleSearchProvider.notifier).state = value; + }, + ), + ), + const SizedBox(width: 12), + + // ── Action group ── + Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.04) + : Colors.black.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconBtn( + icon: LucideIcons.arrowDownToLine, + tooltip: S.of(context).autoScroll, + isActive: autoScroll, + onTap: onToggleAutoScroll, + ), + const SizedBox(width: 2), + Consumer( + builder: (context, ref, _) { + final dir = ref.watch(scrollDirectionProvider); + final isTop = dir == ScrollDirection.top; + return IconBtn( + icon: isTop + ? LucideIcons.arrowUpNarrowWide + : LucideIcons.arrowDownNarrowWide, + tooltip: isTop ? S.of(context).newestFirst : S.of(context).oldestFirst, + isActive: isTop, + onTap: () => + ref.read(scrollDirectionProvider.notifier).state = + isTop ? ScrollDirection.bottom : ScrollDirection.top, + ); + }, + ), + const SizedBox(width: 2), + Container( + width: 1, + height: 18, + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.08), + ), + const SizedBox(width: 2), + IconBtn( + icon: LucideIcons.trash2, + tooltip: S.of(context).clearConsole, + isDanger: true, + onTap: onClear, + ), + ], + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/console/presentation/pages/console_page.dart b/lib/features/console/presentation/pages/console_page.dart index 856005f..ce1bf8d 100644 --- a/lib/features/console/presentation/pages/console_page.dart +++ b/lib/features/console/presentation/pages/console_page.dart @@ -1,42 +1,27 @@ import 'package:flutter/material.dart'; -import '../../../../l10n/app_localizations.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; -import '../../../../components/text/text_component.dart'; -import '../../../../core/utils/log_message_summary.dart'; -import '../../../../core/constants/app_constants.dart'; import '../../../../components/feedback/empty_state.dart'; -import '../../../../components/inputs/search_field.dart'; import '../../../../components/lists/stable_list_view.dart'; -import '../../../../components/misc/status_badge.dart'; import '../../../../components/misc/jump_to_latest_fab.dart'; -import '../../../../components/viewers/json_viewer.dart'; import '../../../../core/theme/color_tokens.dart'; import '../../../../core/theme/theme_provider.dart'; -import '../../../../core/utils/code_generator.dart'; -import '../../../../server/providers/server_providers.dart'; -import '../../../../core/utils/screenshot_utils.dart'; -import '../../../../core/utils/screenshot_filename.dart'; -import '../../../../models/log/log_entry.dart'; -import '../../../../core/utils/toast_utils.dart'; import '../../../../core/utils/smooth_scroll_controller.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../models/log/log_entry.dart'; +import '../../../../server/providers/server_providers.dart'; import '../../provider/console_providers.dart'; +import '../detail/detail_panel.dart'; +import '../event_row/log_entry_content.dart'; +import '../header/toolbar.dart'; -Color _levelColor(LogLevel level) { - switch (level) { - case LogLevel.debug: - return ColorTokens.logDebug; - case LogLevel.info: - return ColorTokens.logInfo; - case LogLevel.warn: - return ColorTokens.logWarn; - case LogLevel.error: - return ColorTokens.logError; - } -} +/// ═══════════════════════════════════════════════════════════════════ +/// Console Page — streaming log entries with optional right-pane +/// detail panel. Composes [Toolbar] (top) + a `StableListView` of +/// [LogEntryContent] rows inside a `JumpToLatestFab`-decorated +/// Stack. +/// ═══════════════════════════════════════════════════════════════════ class ConsolePage extends ConsumerStatefulWidget { const ConsolePage({super.key}); @@ -173,160 +158,6 @@ class _ConsolePageState extends ConsumerState { super.dispose(); } - void _takeDetailScreenshot(BuildContext context, LogEntry entry) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final color = _levelColor(entry.level); - final time = DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch(entry.timestamp), - ); - - // Build a descriptive filename: log___full.png - final fileName = buildRichScreenshotName( - type: 'log', - subject: entry.tag ?? entry.level.name, - suffix: '_full', - ); - - captureWidgetAsImage( - context, - Container( - color: isDark ? ColorTokens.darkSurface : Colors.white, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - color: isDark ? ColorTokens.darkBackground : ColorTokens.lightSurface, - child: Row( - children: [ - Icon(LucideIcons.terminal, size: 16, color: ColorTokens.primary), - const SizedBox(width: 8), - LogLevelBadge(level: entry.level.name), - const SizedBox(width: 10), - Text( - time, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: Colors.grey[500], - ), - ), - ], - ), - ), - const Divider(height: 1), - // Tag - if (entry.tag != null) - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(S.of(context).tag, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.grey[500])), - const SizedBox(height: 4), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: isDark ? Colors.white.withValues(alpha: 0.06) : Colors.black.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(4), - ), - child: Text(entry.tag!, style: TextStyle(fontFamily: AppConstants.monoFontFamily, fontSize: 10, color: Colors.grey[500])), - ), - ], - ), - ), - // Message - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(S.of(context).message, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.grey[500])), - const SizedBox(height: 6), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : const Color(0xFFF0F0F0), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: isDark ? Colors.white.withValues(alpha: 0.06) : Colors.black.withValues(alpha: 0.06), - ), - ), - child: Text( - entry.message, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 12, - height: 1.6, - color: isDark ? ColorTokens.lightBackground : ColorTokens.darkNeutral, - ), - ), - ), - ], - ), - ), - // Metadata - if (entry.metadata != null && entry.metadata!.isNotEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(S.of(context).metadata, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.grey[500])), - const SizedBox(height: 6), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : const Color(0xFFF0F0F0), - borderRadius: BorderRadius.circular(8), - ), - child: JsonViewer(data: entry.metadata, initiallyExpanded: true), - ), - ], - ), - ), - // Stack trace - if (entry.stackTrace != null) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(S.of(context).stackTrace, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.grey[500])), - const SizedBox(height: 6), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withValues(alpha: 0.15)), - ), - child: Text( - entry.stackTrace!, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: color.withValues(alpha: 0.9), - height: 1.5, - ), - ), - ), - ], - ), - ), - ], - ), - ), - width: 600, - fileName: fileName, - ); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -335,7 +166,7 @@ class _ConsolePageState extends ConsumerState { return Column( children: [ - _ConsoleToolbar( + Toolbar( entryCount: _entryCount, autoScroll: _autoScroll, onToggleAutoScroll: () { @@ -405,7 +236,7 @@ class _ConsolePageState extends ConsumerState { contentBuilder: (context, entry) { final devices = ref.read(connectedDevicesProvider); final device = devices.where((d) => d.deviceId == entry.deviceId).firstOrNull; - return _LogEntryContent( + return LogEntryContent( entry: entry, platform: device?.platform, ); @@ -445,12 +276,10 @@ class _ConsolePageState extends ConsumerState { ), SizedBox( width: MediaQuery.of(context).size.width * 0.35, - child: _LogDetailPanel( + child: LogDetailPanel( key: ValueKey(selected.id), entry: selected, onClose: () => _selectedId.value = null, - onScreenshot: () => - _takeDetailScreenshot(context, selected), ), ), ], @@ -469,984 +298,4 @@ class _ConsolePageState extends ConsumerState { ], ); } -} - -// --------------------------------------------------------------------------- -// Toolbar -// --------------------------------------------------------------------------- - -class _ConsoleToolbar extends ConsumerWidget { - final ValueNotifier entryCount; - final bool autoScroll; - final VoidCallback onToggleAutoScroll; - final VoidCallback onClear; - - const _ConsoleToolbar({ - required this.entryCount, - required this.autoScroll, - required this.onToggleAutoScroll, - required this.onClear, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - final activeFilters = ref.watch(consoleFilterProvider); - - return Container( - height: 48, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : Colors.white, - ), - child: Row( - children: [ - // Title section - Icon(LucideIcons.terminal, size: 16, color: ColorTokens.primary), - const SizedBox(width: 8), - Text('Console', style: theme.textTheme.titleMedium), - const SizedBox(width: 8), - ValueListenableBuilder( - valueListenable: entryCount, - builder: (_, count, __) => _CountPill(count: count), - ), - const SizedBox(width: 16), - - // Level filter chips - ...LogLevel.values.map((level) { - final isActive = activeFilters.contains(level); - return Padding( - padding: const EdgeInsets.only(right: 4), - child: _LevelFilterChip( - label: level.name.toUpperCase(), - isActive: isActive, - color: _levelColor(level), - onTap: () { - final current = ref.read(consoleFilterProvider); - if (isActive) { - ref.read(consoleFilterProvider.notifier).state = - current.difference({level}); - } else { - ref.read(consoleFilterProvider.notifier).state = { - ...current, - level, - }; - } - }, - ), - ); - }), - - const Spacer(), - - // Search - SizedBox( - width: 220, - child: SearchField( - hintText: S.of(context).searchLogs, - onChanged: (value) { - ref.read(consoleSearchProvider.notifier).state = value; - }, - ), - ), - const SizedBox(width: 12), - - // ── Action group ── - Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: isDark - ? Colors.white.withValues(alpha: 0.04) - : Colors.black.withValues(alpha: 0.04), - borderRadius: BorderRadius.circular(10), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _IconBtn( - icon: LucideIcons.arrowDownToLine, - tooltip: S.of(context).autoScroll, - isActive: autoScroll, - onTap: onToggleAutoScroll, - ), - const SizedBox(width: 2), - Consumer( - builder: (context, ref, _) { - final dir = ref.watch(scrollDirectionProvider); - final isTop = dir == ScrollDirection.top; - return _IconBtn( - icon: isTop - ? LucideIcons.arrowUpNarrowWide - : LucideIcons.arrowDownNarrowWide, - tooltip: isTop ? S.of(context).newestFirst : S.of(context).oldestFirst, - isActive: isTop, - onTap: () => - ref.read(scrollDirectionProvider.notifier).state = - isTop ? ScrollDirection.bottom : ScrollDirection.top, - ); - }, - ), - const SizedBox(width: 2), - Container( - width: 1, - height: 18, - color: isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.black.withValues(alpha: 0.08), - ), - const SizedBox(width: 2), - _IconBtn( - icon: LucideIcons.trash2, - tooltip: S.of(context).clearConsole, - isDanger: true, - onTap: onClear, - ), - ], - ), - ), - ], - ), - ); - } -} - -// --------------------------------------------------------------------------- -// Count pill -// --------------------------------------------------------------------------- - -class _CountPill extends StatelessWidget { - final int count; - - const _CountPill({required this.count}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.black.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - '$count', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - fontFamily: AppConstants.monoFontFamily, - color: isDark ? Colors.grey[400] : Colors.grey[600], - ), - ), - ); - } -} - -// --------------------------------------------------------------------------- -// Level filter chip -// --------------------------------------------------------------------------- - -class _LevelFilterChip extends StatefulWidget { - final String label; - final bool isActive; - final Color color; - final VoidCallback onTap; - - const _LevelFilterChip({ - required this.label, - required this.isActive, - required this.color, - required this.onTap, - }); - - @override - State<_LevelFilterChip> createState() => _LevelFilterChipState(); -} - -class _LevelFilterChipState extends State<_LevelFilterChip> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - final bg = widget.isActive - ? widget.color.withValues(alpha: 0.15) - : _hovered - ? widget.color.withValues(alpha: 0.07) - : Colors.transparent; - - final borderColor = widget.isActive - ? widget.color.withValues(alpha: 0.4) - : _hovered - ? widget.color.withValues(alpha: 0.25) - : Colors.grey.withValues(alpha: 0.2); - - return Tooltip( - message: '${widget.isActive ? "Hide" : "Show"} ${widget.label} logs', - child: GestureDetector( - onTap: widget.onTap, - child: MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: borderColor, width: 1), - ), - child: Text( - widget.label, - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w700, - letterSpacing: 0.5, - color: widget.isActive ? widget.color : Colors.grey[500], - ), - ), - ), - ), - ), - ); - } -} - -// --------------------------------------------------------------------------- -// Icon button (matches All Events style) -// --------------------------------------------------------------------------- - -class _IconBtn extends StatefulWidget { - final IconData icon; - final String tooltip; - final bool isActive; - final bool isDanger; - final VoidCallback onTap; - - const _IconBtn({ - required this.icon, - required this.tooltip, - this.isActive = false, - this.isDanger = false, - required this.onTap, - }); - - @override - State<_IconBtn> createState() => _IconBtnState(); -} - -class _IconBtnState extends State<_IconBtn> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - - Color iconColor; - Color bgColor; - - if (widget.isActive) { - iconColor = ColorTokens.primary; - bgColor = ColorTokens.primary.withValues(alpha: 0.15); - } else if (widget.isDanger && _hovered) { - iconColor = ColorTokens.error; - bgColor = ColorTokens.error.withValues(alpha: 0.12); - } else if (_hovered) { - iconColor = isDark ? Colors.grey[300]! : Colors.grey[700]!; - bgColor = isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.black.withValues(alpha: 0.06); - } else { - iconColor = Colors.grey[500]!; - bgColor = Colors.transparent; - } - - return Tooltip( - message: widget.tooltip, - child: GestureDetector( - onTap: widget.onTap, - child: MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - width: 28, - height: 28, - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(7), - ), - child: Icon( - widget.icon, - size: 14, - color: iconColor, - ), - ), - ), - ), - ); - } -} - -// --------------------------------------------------------------------------- -// Log entry card -// --------------------------------------------------------------------------- - -class _LogEntryContent extends StatelessWidget { - final LogEntry entry; - final String? platform; - - const _LogEntryContent({ - required this.entry, - this.platform, - }); - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final color = _levelColor(entry.level); - final time = DateFormat('HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch(entry.timestamp), - ); - - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Container( - width: 3, - decoration: BoxDecoration( - color: color, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(8), - bottomLeft: Radius.circular(8), - ), - ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( - children: [ - Text( - time, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 10, - color: Colors.grey[500], - ), - ), - const SizedBox(width: 8), - if (platform != null) ...[ - PlatformBadge(platform: platform!), - const SizedBox(width: 6), - ], - LogLevelBadge(level: entry.level.name), - if (entry.tag != null) ...[ - const SizedBox(width: 6), - _TagBadge(tag: entry.tag!), - ], - ], - ), - const SizedBox(height: 6), - Text( - summarizeLogMessage(entry.message), - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 12, - height: 1.4, - color: isDark - ? ColorTokens.lightBackground - : ColorTokens.darkNeutral, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ), - ], - ), - ); - } -} - -// --------------------------------------------------------------------------- -// Tag badge -// --------------------------------------------------------------------------- - -class _TagBadge extends StatelessWidget { - final String tag; - - const _TagBadge({required this.tag}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.black.withValues(alpha: 0.08), - width: 0.5, - ), - ), - child: Text( - tag, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 10, - color: isDark ? Colors.grey[400] : Colors.grey[600], - ), - ), - ); - } -} - -// --------------------------------------------------------------------------- -// Log detail panel -// --------------------------------------------------------------------------- - -class _LogDetailPanel extends StatefulWidget { - final LogEntry entry; - final VoidCallback onClose; - final VoidCallback onScreenshot; - - const _LogDetailPanel({ - super.key, - required this.entry, - required this.onClose, - required this.onScreenshot, - }); - - @override - State<_LogDetailPanel> createState() => _LogDetailPanelState(); -} - -class _LogDetailPanelState extends State<_LogDetailPanel> { - final _scrollController = SmoothScrollController(); - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final entry = widget.entry; - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - final color = _levelColor(entry.level); - final time = DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch(entry.timestamp), - ); - - return Container( - color: isDark ? ColorTokens.darkSurface : ColorTokens.lightSurface, - child: Column( - children: [ - // Header bar - Container( - height: 44, - padding: const EdgeInsets.symmetric(horizontal: 14), - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : Colors.white, - border: Border( - bottom: BorderSide( - color: theme.dividerColor.withValues(alpha: 0.5), - width: 1, - ), - ), - ), - child: Row( - children: [ - LogLevelBadge(level: entry.level.name), - const SizedBox(width: 10), - Expanded( - child: TextComponent( - time, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: Colors.grey[500], - ), - ), - ), - // Copy button - Tooltip( - message: S.of(context).copyMessage, - child: GestureDetector( - onTap: () { - Clipboard.setData( - ClipboardData(text: entry.message)); - showCopiedToast(context, label: S.of(context).logCopied); - }, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Container( - width: 30, - height: 30, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - LucideIcons.copy, - size: 14, - color: Colors.grey[500], - ), - ), - ), - ), - ), - const SizedBox(width: 4), - // Screenshot button - Tooltip( - message: 'Capture detail as image', - child: GestureDetector( - onTap: widget.onScreenshot, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Container( - width: 30, - height: 30, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - LucideIcons.camera, - size: 14, - color: Colors.grey[500], - ), - ), - ), - ), - ), - const SizedBox(width: 4), - // Close button - Tooltip( - message: S.of(context).closePanel, - child: GestureDetector( - onTap: widget.onClose, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Container( - width: 30, - height: 30, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - LucideIcons.x, - size: 16, - color: Colors.grey[500], - ), - ), - ), - ), - ), - ], - ), - ), - // Scrollable content - Expanded( - child: SingleChildScrollView( - controller: _scrollController, - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Tag - if (entry.tag != null) ...[ - _SectionLabel(label: S.of(context).tag), - const SizedBox(height: 6), - _TagBadge(tag: entry.tag!), - const SizedBox(height: 16), - ], - - // Message — with 3 view modes (Tree / JSON / Code), same - // pattern as the All Events detail panel. - _SectionLabel(label: S.of(context).message), - const SizedBox(height: 6), - _LogMessageBlock( - message: entry.message, - deviceId: entry.deviceId, - ), - - // Metadata - if (entry.metadata != null && - entry.metadata!.isNotEmpty) ...[ - const SizedBox(height: 20), - _SectionLabel(label: S.of(context).metadata), - const SizedBox(height: 6), - _MetadataBlock( - data: entry.metadata!, - deviceId: entry.deviceId, - ), - ], - - // Stack trace - if (entry.stackTrace != null) ...[ - const SizedBox(height: 20), - _SectionLabel(label: S.of(context).stackTrace), - const SizedBox(height: 6), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: color.withValues(alpha: 0.15), - width: 1, - ), - ), - child: TextComponent( - entry.stackTrace!, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: color.withValues(alpha: 0.9), - height: 1.5, - ), - ), - ), - ], - ], - ), - ), - ), - ], - ), - ); - } -} - -// --------------------------------------------------------------------------- -// Metadata block — same 3-mode toggle as the message block, so the user can -// flip between Tree / JSON / Code without leaving the panel. -// --------------------------------------------------------------------------- - -class _MetadataBlock extends ConsumerWidget { - final Map data; - final String deviceId; - - const _MetadataBlock({ - required this.data, - required this.deviceId, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final mode = ref.watch(metadataViewModeProvider); - final devices = ref.watch(connectedDevicesProvider); - final platform = devices - .where((d) => d.deviceId == deviceId) - .map((d) => d.platform) - .firstOrNull ?? - 'react_native'; - final codeLang = CodeGenerator.langForPlatform(platform); - final codeLabel = CodeGenerator.labelFor(codeLang); - - return Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : const Color(0xFFF0F0F0), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06), - width: 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: SizedBox( - width: double.infinity, - child: ViewModeSwitcher( - current: mode, - codeLabel: codeLabel, - onChanged: (BodyViewMode m) => - ref.read(metadataViewModeProvider.notifier).state = m, - ), - ), - ), - DeferredBuilder( - key: ValueKey(mode), - builder: (_) { - switch (mode) { - case BodyViewMode.tree: - return JsonViewer(data: data, initiallyExpanded: true); - case BodyViewMode.json: - return JsonPrettyViewer(data: data); - case BodyViewMode.code: - return CodeViewer( - generated: CodeGenerator.generate(data, codeLang), - lang: codeLang, - languageLabel: codeLabel, - ); - } - }, - ), - ], - ), - ); - } -} - -// --------------------------------------------------------------------------- -// Section label used in the detail panel -// --------------------------------------------------------------------------- - -class _SectionLabel extends StatelessWidget { - final String label; - - const _SectionLabel({required this.label}); - - @override - Widget build(BuildContext context) { - return TextComponent( - label, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - color: Colors.grey[500], - ), - ); - } -} - - -/// 3-mode view toggle (Tree / JSON / Code) for the log message body — same -/// pattern as the All Events detail panel. -class _LogMessageBlock extends ConsumerWidget { - final String message; - final String deviceId; - - const _LogMessageBlock({ - required this.message, - required this.deviceId, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final mode = ref.watch(bodyViewModeProvider); - - final devices = ref.watch(connectedDevicesProvider); - final platform = devices - .where((d) => d.deviceId == deviceId) - .map((d) => d.platform) - .firstOrNull ?? - 'react_native'; - final codeLang = CodeGenerator.langForPlatform(platform); - - return AsyncJsonParser( - rawData: message, - builder: (context, parsed, isJson) { - // Plain text: skip the 3-mode toggle entirely. The user gets the - // raw message without any view-mode chrome. - if (!isJson) { - return Container( - width: double.infinity, - decoration: BoxDecoration( - color: isDark - ? ColorTokens.darkBackground - : const Color(0xFFF0F0F0), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06), - width: 1, - ), - ), - padding: const EdgeInsets.all(12), - child: _PlainMessageBlock(text: message, isDark: isDark), - ); - } - - final body = DeferredBuilder( - key: ValueKey(mode), - builder: (_) { - switch (mode) { - case BodyViewMode.tree: - return JsonViewer(data: parsed, initiallyExpanded: true); - case BodyViewMode.json: - return JsonPrettyViewer(data: parsed); - case BodyViewMode.code: - return CodeViewer( - generated: CodeGenerator.generate(parsed, codeLang), - lang: codeLang, - languageLabel: CodeGenerator.labelFor(codeLang), - ); - } - }, - ); - - return Container( - width: double.infinity, - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : const Color(0xFFF0F0F0), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06), - width: 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), - child: ViewModeSwitcher( - current: mode, - codeLabel: CodeGenerator.labelFor(codeLang), - onChanged: (BodyViewMode m) => - ref.read(bodyViewModeProvider.notifier).set(m), - ), - ), - Padding( - padding: const EdgeInsets.all(12), - child: body, - ), - ], - ), - ); - }, - ); - } -} - -class _PlainMessageBlock extends StatelessWidget { - final String text; - final bool isDark; - - const _PlainMessageBlock({required this.text, required this.isDark}); - - @override - Widget build(BuildContext context) { - return TextComponent( - text, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 12, - height: 1.5, - color: isDark - ? const Color(0xFFCCCCCC) - : const Color(0xFF333333), - ), - ); - } -} - -/// Compact pill-style tab bar for the message view-mode toggle. Same visual -/// pattern as `_DetailTabBar` in `all_events_page.dart` but with a -/// `currentIndex + onSelect` callback instead of a `TabController` — simpler -/// to wire up in a private widget that already manages its own state. -class _DetailTabBar extends StatelessWidget { - final List tabs; - final int currentIndex; - final ValueChanged onSelect; - - const _DetailTabBar({ - required this.tabs, - required this.currentIndex, - required this.onSelect, - }); - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final accent = ColorTokens.primary; - return Container( - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: isDark - ? Colors.white.withValues(alpha: 0.05) - : Colors.black.withValues(alpha: 0.04), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (var i = 0; i < tabs.length; i++) - _buildSegment(tabs[i], i, isDark, accent), - ], - ), - ); - } - - Widget _buildSegment(String label, int index, bool isDark, Color accent) { - final selected = index == currentIndex; - return GestureDetector( - onTap: () => onSelect(index), - behavior: HitTestBehavior.opaque, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), - decoration: BoxDecoration( - color: selected - ? (isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.white) - : Colors.transparent, - borderRadius: BorderRadius.circular(6), - border: selected - ? Border.all( - color: isDark - ? accent.withValues(alpha: 0.25) - : Colors.black.withValues(alpha: 0.06), - ) - : Border.all(color: Colors.transparent), - boxShadow: selected && !isDark - ? const [ - BoxShadow( - color: Color(0x14000000), - blurRadius: 2, - offset: Offset(0, 1), - ), - ] - : null, - ), - child: Text( - label, - style: TextStyle( - fontSize: 11, - fontWeight: selected ? FontWeight.w600 : FontWeight.w500, - letterSpacing: 0.2, - color: selected - ? accent - : (isDark ? Colors.grey[500] : Colors.grey[600]), - ), - ), - ), - ); - } -} +} \ No newline at end of file diff --git a/lib/features/console/presentation/shared/count_pill.dart b/lib/features/console/presentation/shared/count_pill.dart new file mode 100644 index 0000000..a4ebf23 --- /dev/null +++ b/lib/features/console/presentation/shared/count_pill.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/constants/app_constants.dart'; + +/// Neutral grey "count" pill used in the console toolbar (vs the +/// colored count pill in error_inspector). Local copy — color/alpha +/// palette differs from `error_inspector/shared/count_pill.dart`. +class CountPill extends StatelessWidget { + final int count; + + const CountPill({super.key, required this.count}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '$count', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: AppConstants.monoFontFamily, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/console/presentation/shared/level_color.dart b/lib/features/console/presentation/shared/level_color.dart new file mode 100644 index 0000000..873659b --- /dev/null +++ b/lib/features/console/presentation/shared/level_color.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/theme/color_tokens.dart'; +import '../../../../models/log/log_entry.dart'; + +/// Accent color for each log level. Centralized here so the toolbar +/// filter chip + the entry row's left border + the detail panel's +/// stack trace tint all stay in sync. +Color levelColor(LogLevel level) { + switch (level) { + case LogLevel.debug: + return ColorTokens.logDebug; + case LogLevel.info: + return ColorTokens.logInfo; + case LogLevel.warn: + return ColorTokens.logWarn; + case LogLevel.error: + return ColorTokens.logError; + } +} \ No newline at end of file diff --git a/lib/features/console/presentation/shared/section_label.dart b/lib/features/console/presentation/shared/section_label.dart new file mode 100644 index 0000000..48ea7a4 --- /dev/null +++ b/lib/features/console/presentation/shared/section_label.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +import '../../../../components/text/text_component.dart'; + +/// Small uppercase "section header" used inside the detail panel +/// ("Tag" / "Message" / "Metadata" / "Stack trace"). 11pt w600, +/// letter-spacing 0.5, muted grey — keeps each block visually +/// separated without adding a divider line. +class SectionLabel extends StatelessWidget { + final String label; + + const SectionLabel({super.key, required this.label}); + + @override + Widget build(BuildContext context) { + return TextComponent( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + color: Colors.grey[500], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/console/presentation/shared/tag_badge.dart b/lib/features/console/presentation/shared/tag_badge.dart new file mode 100644 index 0000000..b58f0a5 --- /dev/null +++ b/lib/features/console/presentation/shared/tag_badge.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/constants/app_constants.dart'; + +/// Small mono-font tag chip — rendered inline on log entries and +/// at the top of the detail panel's "Tag" section. +class TagBadge extends StatelessWidget { + final String tag; + + const TagBadge({super.key, required this.tag}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.08), + width: 0.5, + ), + ), + child: Text( + tag, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 10, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/error_inspector/presentation/detail/detail_row.dart b/lib/features/error_inspector/presentation/detail/detail_row.dart new file mode 100644 index 0000000..40e7ac6 --- /dev/null +++ b/lib/features/error_inspector/presentation/detail/detail_row.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +import '../../../../components/text/text_component.dart'; +import '../../../../core/constants/app_constants.dart'; + +/// Two-column "label / value" row used in the Error Inspector's +/// Details tab. Label sits in a fixed 80-px gutter (muted grey, w600, +/// 11pt) so all rows align down the column; value is selectable mono +/// at 11pt so users can copy individual fields without selecting the +/// whole payload. +class DetailRow extends StatelessWidget { + final String label; + final String value; + + const DetailRow({super.key, required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 80, + child: TextComponent( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.grey[500], + ), + ), + ), + Expanded( + child: TextComponent( + value, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: isDark ? Colors.white70 : Colors.black87, + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/error_inspector/presentation/detail/error_detail_panel.dart b/lib/features/error_inspector/presentation/detail/error_detail_panel.dart new file mode 100644 index 0000000..e45094c --- /dev/null +++ b/lib/features/error_inspector/presentation/detail/error_detail_panel.dart @@ -0,0 +1,627 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../components/text/text_component.dart'; +import '../../../../components/viewers/json_viewer.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../../../core/theme/theme_provider.dart'; +import '../../../../core/utils/screenshot_filename.dart'; +import '../../../../core/utils/screenshot_utils.dart'; +import '../../../../core/utils/smooth_scroll_controller.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../models/log/error_event.dart'; +import '../shared/error_tokens.dart' show severityColor; +import '../shared/platform_badge.dart'; +import '../shared/severity_badge.dart'; +import 'detail_row.dart'; + +/// Right-pane detail panel that swaps content based on the selected +/// error. Owns the per-tab screenshot machinery (full + current tab) +/// via [captureWidgetAsImage]. Routes per-tab content under: +/// 1. **Message** — full message in mono scrollable block +/// 2. **Stack Trace** — full stack trace (or empty state) +/// 3. **Details** — key/value list (platform, severity, source, etc.) +class ErrorDetailPanel extends ConsumerStatefulWidget { + final ErrorEvent entry; + final VoidCallback onClose; + + const ErrorDetailPanel({ + super.key, + required this.entry, + required this.onClose, + }); + + @override + ConsumerState createState() => _ErrorDetailPanelState(); +} + +class _ErrorDetailPanelState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + final _messageScrollController = SmoothScrollController(); + final _stackTraceScrollController = SmoothScrollController(); + final _detailsScrollController = SmoothScrollController(); + + @override + void initState() { + super.initState(); + _tabController = TabController( + length: 3, + vsync: this, + animationDuration: ref.read(tabAnimationProvider), + ); + } + + @override + void dispose() { + _tabController.dispose(); + _messageScrollController.dispose(); + _stackTraceScrollController.dispose(); + _detailsScrollController.dispose(); + super.dispose(); + } + + void _rebuildController() { + final oldIndex = _tabController.index; + _tabController.dispose(); + _tabController = TabController( + length: 3, + vsync: this, + animationDuration: ref.read(tabAnimationProvider), + initialIndex: oldIndex, + ); + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final entry = widget.entry; + final severityClr = severityColor(entry.severity); + + ref.listen(tabAnimationProvider, (prev, next) { + if (prev != next) _rebuildController(); + }); + + return Column( + children: [ + // Header + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: isDark ? ColorTokens.darkBackground : Colors.white, + border: Border( + bottom: BorderSide( + color: isDark ? Colors.white10 : Colors.black12, + ), + ), + ), + child: Row( + children: [ + Icon(LucideIcons.alertTriangle, size: 16, color: severityClr), + const SizedBox(width: 8), + SeverityBadge(severity: entry.severity), + const SizedBox(width: 8), + PlatformBadge(platform: entry.platform), + const SizedBox(width: 8), + TextComponent( + DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( + DateTime.fromMillisecondsSinceEpoch(entry.timestamp), + ), + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: Colors.grey[500], + ), + ), + const Spacer(), + Tooltip( + message: 'Capture full detail as image', + waitDuration: const Duration(milliseconds: 400), + child: GestureDetector( + onTap: _takeFullScreenshot, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + width: 30, + height: 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + LucideIcons.camera, + size: 14, + color: Colors.grey[500], + ), + ), + ), + ), + ), + const SizedBox(width: 2), + Tooltip( + message: 'Capture current tab only', + waitDuration: const Duration(milliseconds: 400), + child: GestureDetector( + onTap: _takeTabScreenshot, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + width: 30, + height: 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + LucideIcons.scanLine, + size: 14, + color: Colors.grey[500], + ), + ), + ), + ), + ), + const SizedBox(width: 4), + Tooltip( + message: 'Close panel', + child: GestureDetector( + onTap: widget.onClose, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + width: 30, + height: 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + LucideIcons.x, + size: 16, + color: Colors.grey[500], + ), + ), + ), + ), + ), + ], + ), + ), + // Tabs + Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(8), + ), + child: TabBar( + controller: _tabController, + labelStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + unselectedLabelStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + ), + labelColor: ColorTokens.primary, + unselectedLabelColor: isDark ? Colors.grey[500] : Colors.grey[600], + indicatorSize: TabBarIndicatorSize.tab, + indicator: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.white, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isDark + ? ColorTokens.primary.withValues(alpha: 0.25) + : Colors.black.withValues(alpha: 0.06), + ), + boxShadow: isDark + ? null + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + indicatorPadding: EdgeInsets.zero, + dividerHeight: 0, + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateProperty.all(Colors.transparent), + padding: EdgeInsets.zero, + labelPadding: EdgeInsets.zero, + tabs: [ + Tab(height: 28, text: S.of(context).message), + Tab(height: 28, text: 'Stack Trace'), + Tab(height: 28, text: S.of(context).details), + ], + ), + ), + // Tab content + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + // Message tab + LazyTab( + controller: _tabController, + index: 0, + builder: (_) => SingleChildScrollView( + controller: _messageScrollController, + padding: const EdgeInsets.all(16), + child: TextComponent( + entry.message, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 13, + color: isDark ? Colors.white : Colors.black87, + ), + ), + ), + ), + // Stack trace tab + LazyTab( + controller: _tabController, + index: 1, + builder: (_) => entry.stackTrace != null + ? SingleChildScrollView( + controller: _stackTraceScrollController, + padding: const EdgeInsets.all(16), + child: TextComponent( + entry.stackTrace!, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: isDark ? Colors.white70 : Colors.black87, + ), + ), + ) + : Center( + child: TextComponent(S.of(context).noStackTrace), + ), + ), + // Details tab + LazyTab( + controller: _tabController, + index: 2, + builder: (_) => SingleChildScrollView( + controller: _detailsScrollController, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DetailRow(label: S.of(context).platform, value: entry.platform.name), + DetailRow(label: S.of(context).severity, value: entry.severity.name), + // Null-coalesce fallbacks for optional fields — leave + // verbatim from the original to preserve behavior when + // upstream SDKs omit them. + DetailRow(label: S.of(context).source, value: entry.source ?? 'unknown'), + DetailRow(label: S.of(context).deviceId, value: entry.deviceId), + DetailRow(label: S.of(context).deviceInfo, value: entry.deviceInfo ?? 'unknown'), + if (entry.metadata != null) ...[ + const SizedBox(height: 12), + TextComponent( + 'Metadata', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.grey[500], + ), + ), + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark ? Colors.black26 : Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: TextComponent( + entry.metadata.toString(), + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: isDark ? Colors.white70 : Colors.black87, + ), + ), + ), + ], + ], + ), + ), + ), + ], + ), + ), + ], + ); + } + + // ---- Screenshot ---- + + Future _takeFullScreenshot() async { + final isDark = Theme.of(context).brightness == Brightness.dark; + final entry = widget.entry; + final subject = entry.source ?? entry.severity.name; + final fileName = buildRichScreenshotName( + type: 'error', + subject: subject, + suffix: '_full', + ); + await captureWidgetAsImage( + context, + _buildFullScreenshotWidget(isDark), + fileName: fileName, + ); + } + + Future _takeTabScreenshot() async { + final isDark = Theme.of(context).brightness == Brightness.dark; + final entry = widget.entry; + final subject = entry.source ?? entry.severity.name; + final fileName = buildRichScreenshotName( + type: 'error', + subject: subject, + suffix: '_tab', + ); + await captureWidgetAsImage( + context, + _buildTabScreenshotWidget(isDark, _tabController.index), + fileName: fileName, + ); + } + + Widget _buildFullScreenshotWidget(bool isDark) { + final entry = widget.entry; + final severityClr = severityColor(entry.severity); + final time = DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( + DateTime.fromMillisecondsSinceEpoch(entry.timestamp), + ); + + return Container( + color: isDark ? ColorTokens.darkSurface : Colors.white, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Container( + padding: const EdgeInsets.all(12), + color: isDark ? ColorTokens.darkBackground : Colors.white, + child: Row( + children: [ + Icon(LucideIcons.alertTriangle, size: 16, color: severityClr), + const SizedBox(width: 8), + SeverityBadge(severity: entry.severity), + const SizedBox(width: 8), + PlatformBadge(platform: entry.platform), + const SizedBox(width: 8), + TextComponent( + time, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: Colors.grey[500], + ), + ), + ], + ), + ), + const Divider(height: 1), + // Message section + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextComponent( + 'Message', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.grey[500], + ), + ), + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark ? ColorTokens.darkBackground : const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + ), + ), + child: TextComponent( + entry.message, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + color: isDark ? Colors.white : Colors.black87, + ), + ), + ), + ], + ), + ), + // Stack trace section + if (entry.stackTrace != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextComponent( + 'Stack Trace', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.grey[500], + ), + ), + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: severityClr.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: severityClr.withValues(alpha: 0.15), + ), + ), + child: TextComponent( + entry.stackTrace!, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: isDark ? Colors.white70 : Colors.black87, + ), + ), + ), + ], + ), + ), + // Details section + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextComponent( + 'Details', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.grey[500], + ), + ), + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark ? ColorTokens.darkBackground : const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _screenshotDetailRow('Platform', entry.platform.name, isDark), + _screenshotDetailRow('Severity', entry.severity.name, isDark), + _screenshotDetailRow('Source', entry.source ?? 'unknown', isDark), + _screenshotDetailRow('Device ID', entry.deviceId, isDark), + ], + ), + ), + ], + ), + ), + ], + ), + width: 600, + ); + } + + Widget _buildTabScreenshotWidget(bool isDark, int tabIndex) { + final entry = widget.entry; + final severityClr = severityColor(entry.severity); + final time = DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( + DateTime.fromMillisecondsSinceEpoch(entry.timestamp), + ); + + // Bounds check — if `tabIndex` is out of range, fall back to the + // first tab's label. Prevents a screenshot crash if the tab + // controller is in an unexpected state when the user clicks + // "Capture tab". + const tabLabels = ['Message', 'Stack Trace', 'Details']; + final tabLabel = tabIndex >= 0 && tabIndex < tabLabels.length + ? tabLabels[tabIndex] + : 'Message'; + + return Container( + color: isDark ? ColorTokens.darkSurface : Colors.white, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Container( + padding: const EdgeInsets.all(12), + color: isDark ? ColorTokens.darkBackground : Colors.white, + child: Row( + children: [ + Icon(LucideIcons.alertTriangle, size: 16, color: severityClr), + const SizedBox(width: 8), + SeverityBadge(severity: entry.severity), + const SizedBox(width: 8), + PlatformBadge(platform: entry.platform), + const SizedBox(width: 8), + TextComponent(time, style: TextStyle(fontFamily: AppConstants.monoFontFamily, fontSize: 11, color: Colors.grey[500])), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: ColorTokens.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: TextComponent(tabLabel, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: ColorTokens.primary)), + ), + ], + ), + ), + const Divider(height: 1), + // Tab content + Padding( + padding: const EdgeInsets.all(16), + child: tabIndex == 0 + ? TextComponent(entry.message, style: TextStyle(fontFamily: AppConstants.monoFontFamily, fontSize: 12, color: isDark ? Colors.white : Colors.black87)) + : tabIndex == 1 + ? (entry.stackTrace != null + ? TextComponent(entry.stackTrace!, style: TextStyle(fontFamily: AppConstants.monoFontFamily, fontSize: 11, color: isDark ? Colors.white70 : Colors.black87)) + : const TextComponent('No stack trace available')) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _screenshotDetailRow('Platform', entry.platform.name, isDark), + _screenshotDetailRow('Severity', entry.severity.name, isDark), + _screenshotDetailRow('Source', entry.source ?? 'unknown', isDark), + _screenshotDetailRow('Device ID', entry.deviceId, isDark), + ], + ), + ), + ], + ), + width: 600, + ); + } + + Widget _screenshotDetailRow(String label, String value, bool isDark) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 70, + child: TextComponent(label, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: Colors.grey[500])), + ), + Expanded( + child: TextComponent(value, style: TextStyle(fontFamily: AppConstants.monoFontFamily, fontSize: 10, color: isDark ? Colors.white70 : Colors.black87)), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/error_inspector/presentation/event_row/error_list_item.dart b/lib/features/error_inspector/presentation/event_row/error_list_item.dart new file mode 100644 index 0000000..26f5e13 --- /dev/null +++ b/lib/features/error_inspector/presentation/event_row/error_list_item.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../core/constants/app_constants.dart'; +import '../../../../models/log/error_event.dart'; +import '../shared/error_tokens.dart' show severityColor; +import '../shared/platform_badge.dart'; +import '../shared/severity_badge.dart'; + +/// One row in the Error Inspector's streaming error list. +/// +/// Visual structure (left → right): +/// 1. **Severity column** — [SeverityBadge] (top) + [PlatformBadge] +/// (bottom), each tinted with the platform's accent color +/// 2. **Message column** — mono-font message (max 2 lines, ellipsis) +/// + the first line of the stack trace (if any) in muted grey +/// 3. **Time + copy** — timestamp + copy-icon button at the end +/// +/// Left border carries the severity's accent color (3px) — the same +/// visual cue as `EventRow` in `all_events` so users familiar with +/// that page recognize the pattern immediately. +class ErrorListItem extends StatelessWidget { + final ErrorEvent entry; + final bool isSelected; + final VoidCallback onTap; + final VoidCallback onCopy; + + const ErrorListItem({ + super.key, + required this.entry, + required this.isSelected, + required this.onTap, + required this.onCopy, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final severityClr = severityColor(entry.severity); + final time = DateFormat('HH:mm:ss.SSS').format( + DateTime.fromMillisecondsSinceEpoch(entry.timestamp), + ); + + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: isSelected + ? (isDark ? Colors.white10 : Colors.black.withValues(alpha: 0.05)) + : null, + border: Border( + left: BorderSide( + color: severityClr, + width: 3, + ), + bottom: BorderSide( + color: isDark ? Colors.white10 : Colors.black12, + ), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Severity & Platform + Column( + children: [ + SeverityBadge(severity: entry.severity), + const SizedBox(height: 4), + PlatformBadge(platform: entry.platform), + ], + ), + const SizedBox(width: 12), + // Message + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.message, + style: TextStyle( + fontSize: 13, + fontFamily: AppConstants.monoFontFamily, + color: isDark ? Colors.white.withValues(alpha: 0.87) : Colors.black87, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (entry.stackTrace != null) ...[ + const SizedBox(height: 4), + Text( + entry.stackTrace!.split('\n').first, + style: TextStyle( + fontSize: 11, + fontFamily: AppConstants.monoFontFamily, + color: Colors.grey[500], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + // Time & Actions + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + time, + style: TextStyle( + fontSize: 11, + fontFamily: AppConstants.monoFontFamily, + color: Colors.grey[500], + ), + ), + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(LucideIcons.copy, size: 14), + onPressed: onCopy, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Copy', + ), + ], + ), + ], + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/error_inspector/presentation/header/icon_btn.dart b/lib/features/error_inspector/presentation/header/icon_btn.dart new file mode 100644 index 0000000..21df4df --- /dev/null +++ b/lib/features/error_inspector/presentation/header/icon_btn.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/theme/color_tokens.dart'; + +/// Square 28×28 icon button used in the Error Inspector's action group +/// (auto-scroll, sort, trash). +/// +/// Three visual states: +/// - **active** (primary tint) — sort/auto-scroll toggles that are on +/// - **danger-on-hover** (error tint) — for clear-all trash +/// - **muted-on-hover** — neutral hover for inactive icons +/// +/// Local copy of `IconBtn` in `all_events/presentation/buttons/` and +/// `network_inspector/presentation/toolbar/`. Kept separate per the +/// "no cross-feature imports" refactor convention. +class IconBtn extends StatefulWidget { + final IconData icon; + final String tooltip; + final bool isActive; + final bool isDanger; + final VoidCallback onTap; + + const IconBtn({ + super.key, + required this.icon, + required this.tooltip, + this.isActive = false, + this.isDanger = false, + required this.onTap, + }); + + @override + State createState() => _IconBtnState(); +} + +class _IconBtnState extends State { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + Color iconColor; + Color bgColor; + + // Branch order matters: `isActive` must short-circuit before + // `isDanger` so an active sort/auto-scroll toggle never tints red + // on hover. + if (widget.isActive) { + iconColor = ColorTokens.primary; + bgColor = ColorTokens.primary.withValues(alpha: 0.15); + } else if (widget.isDanger && _hovered) { + iconColor = ColorTokens.error; + bgColor = ColorTokens.error.withValues(alpha: 0.12); + } else if (_hovered) { + iconColor = isDark ? Colors.grey[300]! : Colors.grey[700]!; + bgColor = isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.06); + } else { + iconColor = isDark ? Colors.grey[500]! : Colors.grey[500]!; + bgColor = Colors.transparent; + } + + return Tooltip( + message: widget.tooltip, + child: GestureDetector( + onTap: widget.onTap, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 28, + height: 28, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(7), + ), + child: Icon( + widget.icon, + size: 14, + color: iconColor, + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/error_inspector/presentation/header/info_bar.dart b/lib/features/error_inspector/presentation/header/info_bar.dart new file mode 100644 index 0000000..9f58d4e --- /dev/null +++ b/lib/features/error_inspector/presentation/header/info_bar.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../core/constants/app_constants.dart'; +import '../../../../models/log/error_event.dart'; +import '../../provider/error_providers.dart'; +import '../shared/count_up.dart'; +import '../shared/error_tokens.dart' show platformColor, platformLabel; + +/// Stats strip rendered below the toolbar — "Total / Fatal" on the +/// left, then a [PlatformCountCell] per [ErrorPlatform] (JS / Native / +/// Android / iOS) on the right. +/// +/// Visual treatment: +/// - No card container; just a 24-px-tall row of monochrome cells +/// separated by 1px dividers — fits the "data zone" feel of the page +/// - Each per-platform cell has a 6-px color dot (saturated when +/// count > 0, dimmed when 0) so platforms with no errors read as +/// "muted/zero" rather than "missing" +class InfoBar extends ConsumerWidget { + const InfoBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final total = ref.watch(errorCountProvider); + final fatal = ref.watch(fatalErrorCountProvider); + final counts = ref.watch(errorCountByPlatformProvider); + + return Row( + children: [ + _Cell( + icon: LucideIcons.activity, + label: 'TOTAL', + value: total, + color: Colors.grey, + isDark: isDark, + ), + _Divider(isDark: isDark), + _Cell( + icon: LucideIcons.zap, + label: 'FATAL', + value: fatal, + color: Colors.red, + isDark: isDark, + ), + for (final platform in ErrorPlatform.values) ...[ + _Divider(isDark: isDark), + _PlatformCountCell(platform: platform, count: counts[platform] ?? 0), + ], + ], + ); + } +} + +class _Cell extends StatelessWidget { + final IconData icon; + final String label; + final int value; + final Color color; + final bool isDark; + + const _Cell({ + required this.icon, + required this.label, + required this.value, + required this.color, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(icon, size: 11, color: color), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + color: color, + ), + ), + const SizedBox(width: 8), + CountUp( + value: value, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + fontFamily: AppConstants.monoFontFamily, + letterSpacing: -0.3, + color: value > 0 + ? (isDark ? Colors.grey[200] : Colors.grey[800]) + : (isDark ? Colors.grey[500] : Colors.grey[600]), + ), + ), + ], + ), + ); + } +} + +/// One per-platform cell. Renders the color dot + uppercase platform +/// label + animated count. When the count is zero, the dot fades and +/// the text drops to muted grey so platforms with no errors read as +/// "muted/zero" rather than "missing". +class _PlatformCountCell extends StatelessWidget { + final ErrorPlatform platform; + final int count; + + const _PlatformCountCell({required this.platform, required this.count}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final color = platformColor(platform); + return Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: count > 0 ? color : color.withValues(alpha: 0.3), + ), + ), + const SizedBox(width: 8), + Text( + platformLabel(platform).toUpperCase(), + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + color: count > 0 + ? (isDark ? Colors.grey[200] : Colors.grey[800]) + : (isDark ? Colors.grey[500] : Colors.grey[600]), + ), + ), + const SizedBox(width: 8), + CountUp( + value: count, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + fontFamily: AppConstants.monoFontFamily, + letterSpacing: -0.3, + color: count > 0 + ? (isDark ? Colors.grey[200] : Colors.grey[800]) + : (isDark ? Colors.grey[500] : Colors.grey[600]), + ), + ), + ], + ), + ); + } +} + +/// 1×24-px vertical divider that separates [InfoBar] cells. +class _Divider extends StatelessWidget { + final bool isDark; + const _Divider({required this.isDark}); + + @override + Widget build(BuildContext context) { + return Container( + width: 1, + height: 24, + margin: const EdgeInsets.symmetric(horizontal: 20), + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + ); + } +} \ No newline at end of file diff --git a/lib/features/error_inspector/presentation/header/platform_filter_chip.dart b/lib/features/error_inspector/presentation/header/platform_filter_chip.dart new file mode 100644 index 0000000..0e41d01 --- /dev/null +++ b/lib/features/error_inspector/presentation/header/platform_filter_chip.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; + +/// Tinted tap-to-compress chip for the Error Inspector's per-platform +/// filter row. +/// +/// Visual treatment: +/// - Active → filled tint at 14–18%, colored border at 35%, saturated +/// text, leading dot at 100% +/// - Hover (inactive) → faint tint at 5–8%, neutral border, leading +/// dot at 85% +/// - Default → transparent + neutral border, dot at 50% +/// +/// On tap the chip briefly compresses from scale 1.0 → 0.92 over +/// 110ms (the GSAP `power3.out` equivalent) and springs back — a +/// tactile "I clicked it" confirmation without the heavyweight +/// `PressableButton` chain. +class PlatformFilterChip extends StatefulWidget { + final String label; + final bool isActive; + final Color color; + final VoidCallback onTap; + + const PlatformFilterChip({ + super.key, + required this.label, + required this.isActive, + required this.color, + required this.onTap, + }); + + @override + State createState() => _PlatformFilterChipState(); +} + +class _PlatformFilterChipState extends State + with SingleTickerProviderStateMixin { + bool _hovered = false; + + // Short pop on tap — gsap.to(scale: 0.92, duration: 0.11) → scale: 1 + // (drives ScaleTransition below). + late final AnimationController _tapCtrl; + late final Animation _tapScale; + + @override + void initState() { + super.initState(); + _tapCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 110), + ); + // We drive `_tapCtrl` from 0 (no compression) to 1 (fully compressed) + // and map to scale 1.0 → 0.92 with easeOut. + _tapScale = Tween(begin: 1.0, end: 0.92).animate( + CurvedAnimation(parent: _tapCtrl, curve: Curves.easeOut), + ); + } + + @override + void dispose() { + _tapCtrl.dispose(); + super.dispose(); + } + + Future _onTap() async { + // 0 → 1: scale shrinks to 0.92; on completion, reverse back to 1. + _tapCtrl.forward(from: 0); + widget.onTap(); + await Future.delayed(const Duration(milliseconds: 110)); + if (!mounted) return; + _tapCtrl.reverse(); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final c = widget.color; + + // Background: active → filled tint, hover → faint tint, default → transparent + final bg = widget.isActive + ? c.withValues(alpha: isDark ? 0.18 : 0.14) + : _hovered + ? c.withValues(alpha: isDark ? 0.08 : 0.05) + : Colors.transparent; + + // Text color: active → saturated, hover → 85%, default → 55% muted + final textColor = widget.isActive + ? c + : _hovered + ? c.withValues(alpha: 0.85) + : c.withValues(alpha: 0.55); + + return Tooltip( + message: '${widget.isActive ? "Hide" : "Show"} ${widget.label} errors', + child: GestureDetector( + onTap: _onTap, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(8), + border: widget.isActive + ? Border.all(color: c.withValues(alpha: 0.35), width: 1) + : Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + width: 1, + ), + ), + child: ScaleTransition( + scale: _tapScale, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Color dot — pulses when active (perpetual motion) + AnimatedContainer( + duration: const Duration(milliseconds: 220), + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.isActive + ? c + : c.withValues(alpha: _hovered ? 0.85 : 0.5), + ), + ), + const SizedBox(width: 7), + Text( + widget.label, + style: TextStyle( + fontSize: 11, + fontWeight: + widget.isActive ? FontWeight.w700 : FontWeight.w500, + color: textColor, + letterSpacing: 0.2, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/error_inspector/presentation/header/toolbar.dart b/lib/features/error_inspector/presentation/header/toolbar.dart new file mode 100644 index 0000000..3230d1c --- /dev/null +++ b/lib/features/error_inspector/presentation/header/toolbar.dart @@ -0,0 +1,254 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../components/inputs/search_field.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../../../core/theme/theme_provider.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../models/log/error_event.dart'; +import '../../provider/error_providers.dart'; +import '../shared/count_up.dart'; +import '../shared/error_tokens.dart' show platformColor, platformLabel; +import '../shared/pulsing_dot.dart'; +import 'info_bar.dart'; +import 'icon_btn.dart'; +import 'platform_filter_chip.dart'; + +/// Top toolbar of the Error Inspector page. +/// +/// Two stacked regions, top → bottom: +/// +/// 1. **Header bar** — title, error count (with pulsing red dot when +/// count > 0), platform filter chips, search field, action group +/// (auto-scroll, sort, clear). +/// 2. **Info bar** — "Total / Fatal" + per-platform count cells. +/// +/// Anti-card-overuse: both regions use just `border-b` dividers, no +/// background containers (matches the rest of the app). +class Toolbar extends ConsumerWidget { + final ValueNotifier entryCount; + final TextEditingController searchController; + final bool autoScroll; + final VoidCallback onToggleAutoScroll; + + const Toolbar({ + super.key, + required this.entryCount, + required this.searchController, + required this.autoScroll, + required this.onToggleAutoScroll, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final activeFilters = ref.watch(errorFilterProvider); + + return Column( + children: [ + // ── Header bar ──────────────────────────────────────────────────── + Container( + height: 56, + padding: const EdgeInsets.fromLTRB(20, 0, 12, 0), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isDark ? Colors.white10 : Colors.black.withValues(alpha: 0.06), + ), + ), + ), + child: Row( + children: [ + // Title — display weight, tight tracking + Icon(LucideIcons.alertTriangle, size: 16, color: ColorTokens.logError), + const SizedBox(width: 10), + Text( + S.of(context).errors, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + color: isDark + ? ColorTokens.lightBackground + : ColorTokens.darkNeutral, + ), + ), + const SizedBox(width: 10), + // Count pill with pulsing red dot when errors > 0 + ValueListenableBuilder( + valueListenable: entryCount, + builder: (context, count, _) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (count > 0) + Padding( + padding: const EdgeInsets.only(right: 4), + child: PulsingDot( + color: ColorTokens.logError, + size: 7, + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: count > 0 + ? ColorTokens.logError.withValues(alpha: 0.12) + : (isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.04)), + borderRadius: BorderRadius.circular(10), + ), + child: CountUp( + value: count, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + fontFamily: AppConstants.monoFontFamily, + color: count > 0 + ? ColorTokens.logError + : (isDark + ? Colors.grey[400] + : Colors.grey[600]), + ), + ), + ), + ], + ); + }, + ), + + const SizedBox(width: 16), + + // Platform filter chips — compact, color-tinted + ...ErrorPlatform.values.map((platform) { + final isActive = activeFilters.contains(platform); + return Padding( + padding: const EdgeInsets.only(right: 4), + child: PlatformFilterChip( + label: platformLabel(platform), + isActive: isActive, + color: platformColor(platform), + onTap: () { + final current = ref.read(errorFilterProvider); + if (isActive) { + ref.read(errorFilterProvider.notifier).state = + current.difference({platform}); + } else { + ref.read(errorFilterProvider.notifier).state = { + ...current, + platform, + }; + } + }, + ), + ); + }), + + const Spacer(), + + // Search + SizedBox( + width: 220, + child: SearchField( + hintText: S.of(context).searchErrors, + controller: searchController, + onClear: () { + searchController.clear(); + ref.read(errorSearchProvider.notifier).state = ''; + }, + onChanged: (v) => ref.read(errorSearchProvider.notifier).state = v, + ), + ), + const SizedBox(width: 12), + + // Action group + Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.04) + : Colors.black.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconBtn( + icon: LucideIcons.arrowDownToLine, + tooltip: autoScroll + ? S.of(context).autoScroll + : S.of(context).stop, + isActive: autoScroll, + onTap: onToggleAutoScroll, + ), + const SizedBox(width: 2), + Consumer( + builder: (context, ref, _) { + final dir = ref.watch(scrollDirectionProvider); + final isTop = dir == ScrollDirection.top; + return IconBtn( + icon: isTop + ? LucideIcons.arrowUpNarrowWide + : LucideIcons.arrowDownNarrowWide, + tooltip: isTop + ? S.of(context).newestFirst + : S.of(context).oldestFirst, + isActive: isTop, + onTap: () => ref + .read(scrollDirectionProvider.notifier) + .state = isTop + ? ScrollDirection.bottom + : ScrollDirection.top, + ); + }, + ), + const SizedBox(width: 2), + Container( + width: 1, + height: 18, + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.08), + ), + const SizedBox(width: 2), + IconBtn( + icon: LucideIcons.trash2, + tooltip: S.of(context).clearErrors, + isDanger: true, + onTap: () => ref.read(errorEntriesProvider.notifier).clear(), + ), + ], + ), + ), + ], + ), + ), + + // ── Unified info bar ───────────────────────────────────────────── + Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 20), + decoration: BoxDecoration( + color: ref.watch(errorCountProvider) > 0 + ? ColorTokens.logError.withValues(alpha: 0.04) + : Colors.transparent, + border: Border( + bottom: BorderSide( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.04), + ), + ), + ), + child: const InfoBar(), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/features/error_inspector/presentation/pages/error_inspector_page.dart b/lib/features/error_inspector/presentation/pages/error_inspector_page.dart index 21a53db..73aa6c6 100644 --- a/lib/features/error_inspector/presentation/pages/error_inspector_page.dart +++ b/lib/features/error_inspector/presentation/pages/error_inspector_page.dart @@ -1,63 +1,39 @@ import 'package:flutter/material.dart'; -import '../../../../l10n/app_localizations.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:intl/intl.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; -import '../../../../components/text/text_component.dart'; -import '../../../../core/constants/app_constants.dart'; -import '../../../../components/inputs/search_field.dart'; import '../../../../components/lists/stable_list_view.dart'; -import '../../../../core/theme/color_tokens.dart'; +import '../../../../components/misc/jump_to_latest_fab.dart'; import '../../../../core/theme/theme_provider.dart'; -import '../../../../core/utils/screenshot_utils.dart'; -import '../../../../core/utils/screenshot_filename.dart'; -import '../../../../components/viewers/json_viewer.dart'; -import '../../../../core/utils/toast_utils.dart'; -import '../../../../server/providers/server_providers.dart'; +import '../../../../core/utils/position_retained_scroll_physics.dart'; import '../../../../core/utils/smooth_scroll_controller.dart'; +import '../../../../core/utils/toast_utils.dart'; import '../../../../models/log/error_event.dart'; import '../../provider/error_providers.dart'; - -Color _severityColor(ErrorSeverity severity) { - switch (severity) { - case ErrorSeverity.fatal: - return Colors.red.shade900; - case ErrorSeverity.crash: - return Colors.red; - case ErrorSeverity.error: - return ColorTokens.logError; - case ErrorSeverity.warning: - return ColorTokens.logWarn; - case ErrorSeverity.info: - return ColorTokens.logInfo; - } -} - -String _platformLabel(ErrorPlatform platform) { - switch (platform) { - case ErrorPlatform.js: return 'JS'; - case ErrorPlatform.native: return 'Native'; - case ErrorPlatform.android: return 'Android'; - case ErrorPlatform.ios: return 'iOS'; - } -} - -Color _platformColor(ErrorPlatform platform) { - switch (platform) { - case ErrorPlatform.js: return Colors.blue; - case ErrorPlatform.native: return Colors.purple; - case ErrorPlatform.android: return Colors.green; - case ErrorPlatform.ios: return Colors.orange; - } -} - +import '../detail/error_detail_panel.dart'; +import '../event_row/error_list_item.dart'; +import '../header/toolbar.dart'; +import '../shared/empty_state_with_pulse.dart'; + +/// Top-level Error Inspector page. Composes: +/// +/// - [Toolbar] — title, error count + pulsing dot, platform filter chips, +/// search field, action group, plus the unified info bar (Total / Fatal / +/// per-platform counts). +/// - [ErrorListItem] list — the streaming error events +/// - [ErrorDetailPanel] — slides in when a row is selected +/// - [EmptyStateWithPulse] — the breathing-shield rich empty state +/// (replaces the bland `EmptyState` icon-only component) +/// +/// Owns the page-level state: scroll controller, selection, auto-scroll, +/// search controller, and the in-memory entry cache populated from +/// [filteredErrorEntriesProvider]. class ErrorInspectorPage extends ConsumerStatefulWidget { const ErrorInspectorPage({super.key}); @override - ConsumerState createState() => _ErrorInspectorPageState(); + ConsumerState createState() => + _ErrorInspectorPageState(); } class _ErrorInspectorPageState extends ConsumerState { @@ -79,7 +55,9 @@ class _ErrorInspectorPageState extends ConsumerState { filteredErrorEntriesProvider, (previous, next) { final prevLen = _entries.length; - if (next.length > prevLen && previous != null && next.length - prevLen == next.length - previous.length) { + if (next.length > prevLen && + previous != null && + next.length - prevLen == next.length - previous.length) { _entries.addAll(next.sublist(prevLen)); _entryCount.value = _entries.length; if (!_autoScroll) return; @@ -185,2007 +163,154 @@ class _ErrorInspectorPageState extends ConsumerState { super.dispose(); } - void _takeDetailScreenshot(BuildContext context, ErrorEvent entry) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final color = _severityColor(entry.severity); - final time = DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch(entry.timestamp), - ); - - // Resolve a descriptive file name: error___full.png - final fileName = buildRichScreenshotName( - type: 'error', - subject: entry.source ?? entry.severity.name, - suffix: '_full', - ); - - captureWidgetAsImage( - context, - Container( - color: isDark ? ColorTokens.darkSurface : Colors.white, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - color: isDark ? ColorTokens.darkBackground : ColorTokens.lightSurface, - child: Row( - children: [ - Icon(LucideIcons.alertTriangle, size: 16, color: color), - const SizedBox(width: 8), - _SeverityBadge(severity: entry.severity), - const SizedBox(width: 10), - _PlatformBadge(platform: entry.platform), - const SizedBox(width: 10), - Text( - time, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: Colors.grey[500], - ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - entry.message, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 13, - color: isDark ? Colors.white : Colors.black, - ), - ), - if (entry.stackTrace != null) ...[ - const SizedBox(height: 12), - Text( - S.of(context).stackTrace, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Colors.grey[500], - ), - ), - const SizedBox(height: 4), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isDark ? Colors.black26 : Colors.grey.shade100, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - entry.stackTrace!, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: isDark ? Colors.white70 : Colors.black87, - ), - ), - ), - ], - ], - ), - ), - ], - ), - ), - fileName: fileName, - ); - } - @override Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final errorCount = ref.watch(errorCountProvider); - final fatalCount = ref.watch(fatalErrorCountProvider); - final activeFilters = ref.watch(errorFilterProvider); + final theme = Theme.of(context); + final scrollDir = ref.watch(scrollDirectionProvider); + final isReversed = scrollDir == ScrollDirection.top; + final isDark = theme.brightness == Brightness.dark; return Column( children: [ - // ── Header bar ──────────────────────────────────────────────────── - // Anti-card-overuse: just a `border-b` divider, no background - // container. Title left-aligned (variance 8) with the count pill - // glowing red + pulsing when there are active errors (perpetual - // motion; GSAP `repeat: -1` analog). - Container( - height: 56, - padding: const EdgeInsets.fromLTRB(20, 0, 12, 0), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: isDark ? Colors.white10 : Colors.black.withValues(alpha: 0.06), - ), - ), - ), - child: Row( - children: [ - // Title — display weight, tight tracking - Icon(LucideIcons.alertTriangle, size: 16, color: ColorTokens.logError), - const SizedBox(width: 10), - Text( - S.of(context).errors, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - letterSpacing: -0.3, - color: isDark - ? ColorTokens.lightBackground - : ColorTokens.darkNeutral, - ), - ), - const SizedBox(width: 10), - // Count pill with pulsing red dot when errors > 0 - ValueListenableBuilder( - valueListenable: _entryCount, - builder: (context, count, _) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (count > 0) - Padding( - padding: const EdgeInsets.only(right: 4), - child: _PulsingDot( - color: ColorTokens.logError, - size: 7, - ), - ), - AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: count > 0 - ? ColorTokens.logError.withValues(alpha: 0.12) - : (isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.04)), - borderRadius: BorderRadius.circular(10), - ), - child: _CountUp( - value: count, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - fontFamily: AppConstants.monoFontFamily, - color: count > 0 - ? ColorTokens.logError - : (isDark - ? Colors.grey[400] - : Colors.grey[600]), - ), - ), - ), - ], - ); - }, - ), - - const SizedBox(width: 16), - - // Platform filter chips — compact, color-tinted - ...ErrorPlatform.values.map((platform) { - final isActive = activeFilters.contains(platform); - return Padding( - padding: const EdgeInsets.only(right: 4), - child: _PlatformFilterChip( - label: _platformLabel(platform), - isActive: isActive, - color: _platformColor(platform), - onTap: () { - final current = ref.read(errorFilterProvider); - if (isActive) { - ref.read(errorFilterProvider.notifier).state = - current.difference({platform}); - } else { - ref.read(errorFilterProvider.notifier).state = { - ...current, - platform, - }; - } - }, - ), - ); - }), - - const Spacer(), - - // Search - SizedBox( - width: 220, - child: SearchField( - hintText: S.of(context).searchErrors, - controller: _searchController, - onClear: () { - _searchController.clear(); - ref.read(errorSearchProvider.notifier).state = ''; - }, - onChanged: (v) => ref.read(errorSearchProvider.notifier).state = v, - ), - ), - const SizedBox(width: 12), - - // Action group - Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: isDark - ? Colors.white.withValues(alpha: 0.04) - : Colors.black.withValues(alpha: 0.04), - borderRadius: BorderRadius.circular(10), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _IconBtn( - icon: LucideIcons.arrowDownToLine, - tooltip: _autoScroll ? S.of(context).autoScroll : S.of(context).stop, - isActive: _autoScroll, - onTap: () => setState(() => _autoScroll = !_autoScroll), - ), - const SizedBox(width: 2), - Consumer( - builder: (context, ref, _) { - final dir = ref.watch(scrollDirectionProvider); - final isTop = dir == ScrollDirection.top; - return _IconBtn( - icon: isTop - ? LucideIcons.arrowUpNarrowWide - : LucideIcons.arrowDownNarrowWide, - tooltip: isTop ? S.of(context).newestFirst : S.of(context).oldestFirst, - isActive: isTop, - onTap: () => ref.read(scrollDirectionProvider.notifier).state = - isTop ? ScrollDirection.bottom : ScrollDirection.top, - ); - }, - ), - const SizedBox(width: 2), - Container( - width: 1, - height: 18, - color: isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.black.withValues(alpha: 0.08), - ), - const SizedBox(width: 2), - _IconBtn( - icon: LucideIcons.trash2, - tooltip: S.of(context).clearErrors, - isDanger: true, - onTap: () => ref.read(errorEntriesProvider.notifier).clear(), - ), - ], - ), - ), - ], - ), + Toolbar( + entryCount: _entryCount, + searchController: _searchController, + autoScroll: _autoScroll, + onToggleAutoScroll: () { + if (_autoScroll) { + _autoScroll = false; + _programmaticScroll = false; + if (_scrollController.hasClients) { + _scrollController.jumpTo(_scrollController.offset); + } + } else { + _autoScroll = true; + _visibleCount = _entries.length; + _autoScrollIfNeeded(); + } + setState(() {}); + }, ), - - // ── Unified info bar (replaces 2 cards + 4 platform chips) ──── - // No card containers, just a `border-b` + `divide-x` for visual - // structure. Single accent color (red) for totals, monochrome for - // platform counts. Tinted background only when count > 0 to - // communicate "no errors" without shouting. - Container( - height: 44, - padding: const EdgeInsets.symmetric(horizontal: 20), - decoration: BoxDecoration( - color: errorCount > 0 - ? ColorTokens.logError.withValues(alpha: 0.04) - : Colors.transparent, - border: Border( - bottom: BorderSide( - color: isDark ? Colors.white.withValues(alpha: 0.06) : Colors.black.withValues(alpha: 0.04), - ), - ), - ), - child: Row( - children: [ - // Total — left-aligned, big monospace count, display weight - Expanded( - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _buildBarLabel( - icon: LucideIcons.alertTriangle, - label: S.of(context).totalErrors.toUpperCase(), - color: errorCount > 0 - ? ColorTokens.logError - : (isDark ? Colors.grey.shade500 : Colors.grey.shade600), - ), - const SizedBox(width: 10), - _CountUp( - value: errorCount, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - fontFamily: AppConstants.monoFontFamily, - letterSpacing: -0.5, - color: errorCount > 0 - ? ColorTokens.logError - : (isDark ? Colors.grey[300] : Colors.grey[700]), - ), - ), - ], - ), - ), - _buildDivider(isDark), - Expanded( - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _buildBarLabel( - icon: LucideIcons.skull, - label: S.of(context).fatalCrash.toUpperCase(), - color: fatalCount > 0 - ? Colors.red - : (isDark ? Colors.grey.shade500 : Colors.grey.shade600), - ), - const SizedBox(width: 10), - _CountUp( - value: fatalCount, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - fontFamily: AppConstants.monoFontFamily, - letterSpacing: -0.5, - color: fatalCount > 0 - ? Colors.red - : (isDark ? Colors.grey[300] : Colors.grey[700]), - ), - ), - ], - ), - ), - // Per-platform mini-bars (method prepends its own dividers) - ..._buildPlatformCountBars(isDark), - ], - ), - ), - - // ── Main content: list + detail panel ──────────────────────────── Expanded( child: _entries.isEmpty - ? _EmptyStateWithPulse( - title: S.of(context).noErrorsCaptured, - subtitle: S.of(context).errorsAppearHere, + ? const EmptyStateWithPulse( + title: 'No errors recorded', + subtitle: + "When the connected app reports an error, it'll show up here.", ) - : Row( + : Stack( children: [ - Expanded( - child: StableListView( - controller: _scrollController, - reverse: ref.read(scrollDirectionProvider) == ScrollDirection.top, - generation: _generation, - childCount: _visibleCount, - padding: const EdgeInsets.symmetric(vertical: 6), - entries: _entries, - itemExtent: 80, - idOf: (e) => e.id, - selectedId: _selectedId, - onSelect: (entry) { - final wasSelected = _selectedId.value == entry.id; - _selectedId.value = wasSelected ? null : entry.id; - if (!wasSelected && _autoScroll) { - _autoScroll = false; - _programmaticScroll = false; - if (_scrollController.hasClients) { - _scrollController.jumpTo(_scrollController.offset); - } - setState(() {}); - } - }, - contentBuilder: (context, entry) { - return _ErrorListItem( - entry: entry, - isSelected: _selectedId.value == entry.id, - onTap: () { - final wasSelected = _selectedId.value == entry.id; - _selectedId.value = wasSelected ? null : entry.id; - }, - onCopy: () { - Clipboard.setData(ClipboardData(text: entry.stackTrace ?? entry.message)); - showCopiedToast(context, label: S.of(context).stackTraceCopied); - }, - ); - }, - decorationBuilder: (isSelected, isDark) { - return BoxDecoration( - color: isSelected - ? ColorTokens.selectedBg(isDark) - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: isSelected - ? ColorTokens.selectedBorder(isDark) - : (isDark - ? Colors.white.withValues(alpha: 0.04) - : Colors.black.withValues(alpha: 0.04)), - width: 1, - ), - ); - }, - ), - ), - // Detail panel - ValueListenableBuilder( - valueListenable: _selectedId, - builder: (context, selectedIdValue, _) { - final selected = selectedIdValue != null - ? _entries.where((e) => e.id == selectedIdValue).firstOrNull - : null; - if (selected == null) return const SizedBox.shrink(); - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - VerticalDivider(width: 1, color: isDark ? Colors.white10 : Colors.black12), - SizedBox( - width: MediaQuery.of(context).size.width * 0.35, - child: _ErrorDetailPanel( - key: ValueKey(selected.id), - entry: selected, - onClose: () => _selectedId.value = null, - onScreenshot: () => _takeDetailScreenshot(context, selected), - ), - ), - ], - ); - }, - ), - ], - ), - ), - ], - ); - } - - // ── Tiny helpers (no card containers) ─────────────────────────────── - - Widget _buildBarLabel({ - required IconData icon, - required String label, - required Color color, - }) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 11, color: color), - const SizedBox(width: 6), - Text( - label, - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w700, - letterSpacing: 0.6, - color: color, - ), - ), - ], - ); - } - - Widget _buildDivider(bool isDark) { - return Container( - width: 1, - height: 24, - margin: const EdgeInsets.symmetric(horizontal: 20), - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06), - ); - } - - /// Replaces the old row-of-`_CountChip` widgets with a unified - /// horizontal strip — each platform gets the same compact cell as - /// total/fatal, just rendered with its own color dot. - List _buildPlatformCountBars(bool isDark) { - final counts = ref.watch(errorCountByPlatformProvider); - final widgets = []; - for (final platform in ErrorPlatform.values) { - widgets.add(_buildDivider(isDark)); - final count = counts[platform] ?? 0; - final color = _platformColor(platform); - widgets.add( - Expanded( - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 6, - height: 6, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: count > 0 - ? color - : color.withValues(alpha: 0.3), - ), - ), - const SizedBox(width: 8), - Text( - _platformLabel(platform).toUpperCase(), - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w700, - letterSpacing: 0.6, - color: count > 0 - ? (isDark ? Colors.grey[200] : Colors.grey[800]) - : (isDark ? Colors.grey[500] : Colors.grey[600]), - ), - ), - const SizedBox(width: 8), - _CountUp( - value: count, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - fontFamily: AppConstants.monoFontFamily, - letterSpacing: -0.3, - color: count > 0 - ? (isDark ? Colors.grey[200] : Colors.grey[800]) - : (isDark ? Colors.grey[500] : Colors.grey[600]), - ), - ), - ], - ), - ), - ); - } - return widgets; - } - - Widget _buildPlatformCounts() { - final counts = ref.watch(errorCountByPlatformProvider); - return Row( - children: [ - _CountChip(label: 'JS', count: counts[ErrorPlatform.js] ?? 0, color: Colors.blue), - const SizedBox(width: 8), - _CountChip(label: 'Native', count: counts[ErrorPlatform.native] ?? 0, color: Colors.purple), - const SizedBox(width: 8), - _CountChip(label: 'Android', count: counts[ErrorPlatform.android] ?? 0, color: Colors.green), - const SizedBox(width: 8), - _CountChip(label: 'iOS', count: counts[ErrorPlatform.ios] ?? 0, color: Colors.orange), - ], - ); - } -} - -// --------------------------------------------------------------------------- -// Count Pill -// --------------------------------------------------------------------------- - -class _CountPill extends StatelessWidget { - final int count; - - const _CountPill({required this.count}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.black.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - '$count', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - fontFamily: AppConstants.monoFontFamily, - color: isDark ? Colors.grey[400] : Colors.grey[600], - ), - ), - ); - } -} - -// --------------------------------------------------------------------------- -// PulsingDot — small status indicator with perpetual motion -// (GSAP equivalent: a recursive tween with yoyo reverse) -// -// Used to draw the eye to a "live" status without being loud. Two -// stacked layers (expanding ring + solid dot) using `Curves.easeOutCubic` -// for the smooth deceleration GSAP ships out of the box. -// --------------------------------------------------------------------------- - -class _PulsingDot extends StatefulWidget { - final Color color; - final double size; - final Duration period; - - const _PulsingDot({ - required this.color, - this.size = 8, - this.period = const Duration(milliseconds: 1800), - }); - - @override - State<_PulsingDot> createState() => _PulsingDotState(); -} - -class _PulsingDotState extends State<_PulsingDot> - with SingleTickerProviderStateMixin { - late final AnimationController _ctrl; - - @override - void initState() { - super.initState(); - _ctrl = AnimationController(vsync: this, duration: widget.period)..repeat(); - } - - @override - void dispose() { - _ctrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return SizedBox( - width: widget.size * 3, - height: widget.size * 3, - child: Stack( - alignment: Alignment.center, - children: [ - // Outer expanding ring (perpetual "breathing" pulse). - // Animate the color's alpha directly instead of wrapping in an - // `Opacity` widget — that avoids intermediate offscreen render - // passes on every animation tick (the inner Container is a - // simple solid-color circle, so the Opacity layer is pure - // overhead). - AnimatedBuilder( - animation: _ctrl, - builder: (context, _) { - return Transform.scale( - scale: 0.6 + 0.8 * _ctrl.value, - child: Container( - width: widget.size, - height: widget.size, - decoration: BoxDecoration( - shape: BoxShape.circle, - // Color alpha rides the same progress: starts at 0.6, - // fades to 0 as the ring expands. - color: widget.color.withValues( - alpha: 0.6 * (1 - _ctrl.value), - ), - ), - ), - ); - }, - ), - // Solid dot (anchor) - Container( - width: widget.size, - height: widget.size, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: widget.color, - ), - ), - ], - ), - ); - } -} - -// --------------------------------------------------------------------------- -// CountUp — animates a number to its target value -// (GSAP equivalent: `gsap.to(target, {val: newVal, duration: 0.6, ease: "power3.out"})`) -// --------------------------------------------------------------------------- - -class _CountUp extends StatelessWidget { - final int value; - final TextStyle? style; - final Duration duration; - final String Function(int)? formatter; - - const _CountUp({ - required this.value, - this.style, - this.duration = const Duration(milliseconds: 700), - this.formatter, - }); - - @override - Widget build(BuildContext context) { - return TweenAnimationBuilder( - tween: Tween(begin: 0, end: value.toDouble()), - duration: duration, - curve: Curves.easeOutCubic, - builder: (context, v, _) { - final n = v.toInt(); - return Text( - formatter != null ? formatter!(n) : '$n', - style: style, - ); - }, - ); - } -} - -// --------------------------------------------------------------------------- -// EmptyStateWithPulse — rich empty state with breathing shield + rings -// Replaces the bland `EmptyState(icon: checkCircle, …)` with a layout -// that has actual motion: a stacked shield with two concentric -// breathing rings, dotted-grid backdrop hint, and a title that -// fades + slides in. -// --------------------------------------------------------------------------- - -class _EmptyStateWithPulse extends StatefulWidget { - final String title; - final String subtitle; - - const _EmptyStateWithPulse({ - required this.title, - required this.subtitle, - }); - - @override - State<_EmptyStateWithPulse> createState() => _EmptyStateWithPulseState(); -} - -class _EmptyStateWithPulseState extends State<_EmptyStateWithPulse> - with TickerProviderStateMixin { - late final AnimationController _breathCtrl; - late final AnimationController _entryCtrl; - late final Animation _entryOpacity; - late final Animation _entrySlide; - - @override - void initState() { - super.initState(); - // Slow breathing loop for the rings (perpetual motion, no user trigger) - _breathCtrl = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 3200), - )..repeat(reverse: true); - // One-shot entrance animation for the title + icon - _entryCtrl = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 600), - ); - _entryOpacity = CurvedAnimation( - parent: _entryCtrl, - curve: Curves.easeOutCubic, - ); - _entrySlide = Tween( - begin: const Offset(0, 0.05), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _entryCtrl, - curve: Curves.easeOutCubic, - )); - _entryCtrl.forward(); - } - - @override - void dispose() { - _breathCtrl.dispose(); - _entryCtrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - return Center( - child: FadeTransition( - opacity: _entryOpacity, - child: SlideTransition( - position: _entrySlide, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Animated shield with breathing rings (perpetual motion) - SizedBox( - width: 120, - height: 120, - child: AnimatedBuilder( - animation: _breathCtrl, - builder: (context, _) { - return Stack( - alignment: Alignment.center, + Row( children: [ - // Outer ring - Transform.scale( - scale: 0.85 + 0.15 * _breathCtrl.value, - child: Container( - width: 110, - height: 110, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: ColorTokens.logError - .withValues(alpha: 0.12 + 0.08 * _breathCtrl.value), - width: 1.5, - ), + Expanded( + child: ListView.custom( + controller: _scrollController, + reverse: isReversed, + physics: isReversed + ? const PositionRetainedScrollPhysics() + : null, + padding: const EdgeInsets.symmetric(vertical: 6), + itemExtent: 76, + childrenDelegate: StableBuilderDelegate( + generation: _generation, + childCount: _visibleCount, + findChildIndexCallback: (key) { + if (key is ValueKey) { + final idx = _entries + .indexWhere((e) => e.id == key.value); + return idx == -1 ? null : idx; + } + return null; + }, + builder: (context, index) { + final entry = _entries[index]; + return RepaintBoundary( + key: ValueKey(entry.id), + child: Consumer( + builder: (context, ref, _) { + final selectedIdValue = + _selectedId.value; + final isSelected = + selectedIdValue == entry.id; + return ErrorListItem( + entry: entry, + isSelected: isSelected, + onTap: () { + _selectedId.value = + isSelected ? null : entry.id; + if (!isSelected && _autoScroll) { + _autoScroll = false; + _programmaticScroll = false; + if (_scrollController.hasClients) { + _scrollController.jumpTo( + _scrollController.offset); + } + setState(() {}); + } + }, + onCopy: () => _copy( + context, + entry.message, + ), + ); + }, + ), + ); + }, ), ), ), - // Inner ring - Transform.scale( - scale: 0.55 + 0.10 * (1 - _breathCtrl.value), - child: Container( - width: 80, - height: 80, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: ColorTokens.logError - .withValues(alpha: 0.20 + 0.10 * (1 - _breathCtrl.value)), - width: 1.5, - ), - ), - ), - ), - // Shield icon - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: ColorTokens.logError.withValues(alpha: 0.10), - border: Border.all( - color: ColorTokens.logError.withValues(alpha: 0.30), - width: 1, - ), - ), - child: Icon( - LucideIcons.shieldCheck, - size: 28, - color: ColorTokens.logError, - ), + // Detail panel + ValueListenableBuilder( + valueListenable: _selectedId, + builder: (context, selectedIdValue, _) { + final selected = selectedIdValue != null + ? _entries + .where((e) => e.id == selectedIdValue) + .firstOrNull + : null; + if (selected == null) { + return const SizedBox.shrink(); + } + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + VerticalDivider( + width: 1, + color: isDark + ? Colors.white10 + : Colors.black12), + SizedBox( + width: + MediaQuery.of(context).size.width * 0.35, + child: ErrorDetailPanel( + key: ValueKey(selected.id), + entry: selected, + onClose: () => + _selectedId.value = null, + ), + ), + ], + ); + }, ), ], - ); - }, - ), - ), - const SizedBox(height: 24), - Text( - widget.title, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - letterSpacing: -0.3, - color: isDark - ? ColorTokens.lightBackground - : ColorTokens.darkNeutral, - ), - ), - const SizedBox(height: 6), - Text( - widget.subtitle, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 12, - color: isDark ? Colors.grey[500] : Colors.grey[600], - height: 1.4, - ), - ), - ], - ), - ), - ), - ); - } -} - -// --------------------------------------------------------------------------- -// Platform filter chip -// --------------------------------------------------------------------------- - -class _PlatformFilterChip extends StatefulWidget { - final String label; - final bool isActive; - final Color color; - final VoidCallback onTap; - - const _PlatformFilterChip({ - required this.label, - required this.isActive, - required this.color, - required this.onTap, - }); - - @override - State<_PlatformFilterChip> createState() => _PlatformFilterChipState(); -} - -class _PlatformFilterChipState extends State<_PlatformFilterChip> - with SingleTickerProviderStateMixin { - bool _hovered = false; - - // Short pop on tap — gsap.to(scale: 0.92, duration: 0.11) → scale: 1 - // (drives ScaleTransition below). - late final AnimationController _tapCtrl; - late final Animation _tapScale; - - @override - void initState() { - super.initState(); - _tapCtrl = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 110), - ); - // We drive `_tapCtrl` from 0 (no compression) to 1 (fully compressed) - // and map to scale 1.0 → 0.92 with easeOut. - _tapScale = Tween(begin: 1.0, end: 0.92).animate( - CurvedAnimation(parent: _tapCtrl, curve: Curves.easeOut), - ); - } - - @override - void dispose() { - _tapCtrl.dispose(); - super.dispose(); - } - - Future _onTap() async { - // 0 → 1: scale shrinks to 0.92; on completion, reverse back to 1. - _tapCtrl.forward(from: 0); - widget.onTap(); - await Future.delayed(const Duration(milliseconds: 110)); - if (!mounted) return; - _tapCtrl.reverse(); - } - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final c = widget.color; - - // Background: active → filled tint, hover → faint tint, default → transparent - final bg = widget.isActive - ? c.withValues(alpha: isDark ? 0.18 : 0.14) - : _hovered - ? c.withValues(alpha: isDark ? 0.08 : 0.05) - : Colors.transparent; - - // Text color: active → saturated, hover → 60%, default → 45% muted - final textColor = widget.isActive - ? c - : _hovered - ? c.withValues(alpha: 0.85) - : c.withValues(alpha: 0.55); - - return Tooltip( - message: '${widget.isActive ? "Hide" : "Show"} ${widget.label} errors', - child: GestureDetector( - onTap: _onTap, - child: MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(8), - border: widget.isActive - ? Border.all(color: c.withValues(alpha: 0.35), width: 1) - : Border.all( - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06), - width: 1, - ), - ), - child: ScaleTransition( - scale: _tapScale, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Color dot — pulses when active (perpetual motion) - AnimatedContainer( - duration: const Duration(milliseconds: 220), - width: 6, - height: 6, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: widget.isActive - ? c - : c.withValues(alpha: _hovered ? 0.85 : 0.5), - ), - ), - const SizedBox(width: 7), - Text( - widget.label, - style: TextStyle( - fontSize: 11, - fontWeight: - widget.isActive ? FontWeight.w700 : FontWeight.w500, - color: textColor, - letterSpacing: 0.2, - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} - -// --------------------------------------------------------------------------- -// Icon Button -// --------------------------------------------------------------------------- - -class _IconBtn extends StatefulWidget { - final IconData icon; - final String tooltip; - final bool isActive; - final bool isDanger; - final VoidCallback onTap; - - const _IconBtn({ - required this.icon, - required this.tooltip, - this.isActive = false, - this.isDanger = false, - required this.onTap, - }); - - @override - State<_IconBtn> createState() => _IconBtnState(); -} - -class _IconBtnState extends State<_IconBtn> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - - Color iconColor; - Color bgColor; - - if (widget.isActive) { - iconColor = ColorTokens.primary; - bgColor = ColorTokens.primary.withValues(alpha: 0.15); - } else if (widget.isDanger && _hovered) { - iconColor = ColorTokens.error; - bgColor = ColorTokens.error.withValues(alpha: 0.12); - } else if (_hovered) { - iconColor = isDark ? Colors.grey[300]! : Colors.grey[700]!; - bgColor = isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.black.withValues(alpha: 0.06); - } else { - iconColor = isDark ? Colors.grey[500]! : Colors.grey[500]!; - bgColor = Colors.transparent; - } - - return Tooltip( - message: widget.tooltip, - child: GestureDetector( - onTap: widget.onTap, - child: MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - width: 28, - height: 28, - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(7), - ), - child: Icon( - widget.icon, - size: 14, - color: iconColor, - ), - ), - ), - ), - ); - } -} - -class _SummaryCard extends StatelessWidget { - final String label; - final String value; - final IconData icon; - final Color color; - - const _SummaryCard({ - required this.label, - required this.value, - required this.icon, - required this.color, - }); - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkSurface : Colors.white, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: isDark ? Colors.white12 : Colors.black12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16, color: color), - const SizedBox(width: 8), - Text( - value, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: color, - ), - ), - const SizedBox(width: 8), - Text( - label, - style: TextStyle( - fontSize: 12, - color: Colors.grey[500], - ), - ), - ], - ), - ); - } -} - -class _CountChip extends StatelessWidget { - final String label; - final int count; - final Color color; - - const _CountChip({ - required this.label, - required this.count, - required this.color, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - '$label: $count', - style: TextStyle(fontSize: 11, color: color, fontWeight: FontWeight.w500), - ), - ); - } -} - -class _SeverityBadge extends StatelessWidget { - final ErrorSeverity severity; - - const _SeverityBadge({required this.severity}); - - @override - Widget build(BuildContext context) { - final color = _severityColor(severity); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - severity.name.toUpperCase(), - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: color, - ), - ), - ); - } -} - -class _PlatformBadge extends StatelessWidget { - final ErrorPlatform platform; - - const _PlatformBadge({required this.platform}); - - @override - Widget build(BuildContext context) { - final color = _platformColor(platform); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - _platformLabel(platform), - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: color, - ), - ), - ); - } -} - -class _ErrorListItem extends StatelessWidget { - final ErrorEvent entry; - final bool isSelected; - final VoidCallback onTap; - final VoidCallback onCopy; - - const _ErrorListItem({ - required this.entry, - required this.isSelected, - required this.onTap, - required this.onCopy, - }); - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final severityColor = _severityColor(entry.severity); - final time = DateFormat('HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch(entry.timestamp), - ); - - return InkWell( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - decoration: BoxDecoration( - color: isSelected - ? (isDark ? Colors.white10 : Colors.black.withValues(alpha: 0.05)) - : null, - border: Border( - left: BorderSide( - color: severityColor, - width: 3, - ), - bottom: BorderSide( - color: isDark ? Colors.white10 : Colors.black12, - ), - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Severity & Platform - Column( - children: [ - _SeverityBadge(severity: entry.severity), - const SizedBox(height: 4), - _PlatformBadge(platform: entry.platform), - ], - ), - const SizedBox(width: 12), - // Message - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - entry.message, - style: TextStyle( - fontSize: 13, - fontFamily: AppConstants.monoFontFamily, - color: isDark ? Colors.white.withValues(alpha: 0.87) : Colors.black87, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - if (entry.stackTrace != null) ...[ - const SizedBox(height: 4), - Text( - entry.stackTrace!.split('\n').first, - style: TextStyle( - fontSize: 11, - fontFamily: AppConstants.monoFontFamily, - color: Colors.grey[500], - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), - ], - ], - ), - ), - // Time & Actions - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - time, - style: TextStyle( - fontSize: 11, - fontFamily: AppConstants.monoFontFamily, - color: Colors.grey[500], - ), - ), - const SizedBox(height: 4), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(LucideIcons.copy, size: 14), - onPressed: onCopy, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - tooltip: 'Copy', + PositionedJumpToLatestFab( + scrollController: _scrollController, + reversed: isReversed, ), ], ), - ], - ), - ], - ), - ), - ); - } -} - -// --------------------------------------------------------------------------- -// Error Detail Panel -// --------------------------------------------------------------------------- - -class _ErrorDetailPanel extends ConsumerStatefulWidget { - final ErrorEvent entry; - final VoidCallback onClose; - final VoidCallback onScreenshot; - - const _ErrorDetailPanel({ - super.key, - required this.entry, - required this.onClose, - required this.onScreenshot, - }); - - @override - ConsumerState<_ErrorDetailPanel> createState() => _ErrorDetailPanelState(); -} - -class _ErrorDetailPanelState extends ConsumerState<_ErrorDetailPanel> - with SingleTickerProviderStateMixin { - late TabController _tabController; - final _messageScrollController = SmoothScrollController(); - final _stackTraceScrollController = SmoothScrollController(); - final _detailsScrollController = SmoothScrollController(); - - @override - void initState() { - super.initState(); - _tabController = TabController( - length: 3, - vsync: this, - animationDuration: ref.read(tabAnimationProvider), - ); - } - - @override - void dispose() { - _tabController.dispose(); - _messageScrollController.dispose(); - _stackTraceScrollController.dispose(); - _detailsScrollController.dispose(); - super.dispose(); - } - - void _rebuildController() { - final oldIndex = _tabController.index; - _tabController.dispose(); - _tabController = TabController( - length: 3, - vsync: this, - animationDuration: ref.read(tabAnimationProvider), - initialIndex: oldIndex, - ); - setState(() {}); - } - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final entry = widget.entry; - final severityColor = _severityColor(entry.severity); - - ref.listen(tabAnimationProvider, (prev, next) { - if (prev != next) _rebuildController(); - }); - - return Column( - children: [ - // Header - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : Colors.white, - border: Border( - bottom: BorderSide( - color: isDark ? Colors.white10 : Colors.black12, - ), - ), - ), - child: Row( - children: [ - Icon(LucideIcons.alertTriangle, size: 16, color: severityColor), - const SizedBox(width: 8), - _SeverityBadge(severity: entry.severity), - const SizedBox(width: 8), - _PlatformBadge(platform: entry.platform), - const SizedBox(width: 8), - TextComponent( - DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch(entry.timestamp), - ), - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: Colors.grey[500], - ), - ), - const Spacer(), - Tooltip( - message: 'Capture full detail as image', - waitDuration: const Duration(milliseconds: 400), - child: GestureDetector( - onTap: _takeFullScreenshot, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Container( - width: 30, - height: 30, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - LucideIcons.camera, - size: 14, - color: Colors.grey[500], - ), - ), - ), - ), - ), - const SizedBox(width: 2), - Tooltip( - message: 'Capture current tab only', - waitDuration: const Duration(milliseconds: 400), - child: GestureDetector( - onTap: _takeTabScreenshot, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Container( - width: 30, - height: 30, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - LucideIcons.scanLine, - size: 14, - color: Colors.grey[500], - ), - ), - ), - ), - ), - const SizedBox(width: 4), - Tooltip( - message: 'Close panel', - child: GestureDetector( - onTap: widget.onClose, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Container( - width: 30, - height: 30, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - LucideIcons.x, - size: 16, - color: Colors.grey[500], - ), - ), - ), - ), - ), - ], - ), - ), - // Tabs - Container( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: isDark - ? Colors.white.withValues(alpha: 0.05) - : Colors.black.withValues(alpha: 0.04), - borderRadius: BorderRadius.circular(8), - ), - child: TabBar( - controller: _tabController, - labelStyle: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - letterSpacing: 0.2, - ), - unselectedLabelStyle: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w500, - ), - labelColor: ColorTokens.primary, - unselectedLabelColor: isDark ? Colors.grey[500] : Colors.grey[600], - indicatorSize: TabBarIndicatorSize.tab, - indicator: BoxDecoration( - color: isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.white, - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: isDark - ? ColorTokens.primary.withValues(alpha: 0.25) - : Colors.black.withValues(alpha: 0.06), - ), - boxShadow: isDark - ? null - : [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 2, - offset: const Offset(0, 1), - ), - ], - ), - indicatorPadding: EdgeInsets.zero, - dividerHeight: 0, - splashFactory: NoSplash.splashFactory, - overlayColor: WidgetStateProperty.all(Colors.transparent), - padding: EdgeInsets.zero, - labelPadding: EdgeInsets.zero, - tabs: [ - Tab(height: 28, text: S.of(context).message), - Tab(height: 28, text: 'Stack Trace'), - Tab(height: 28, text: S.of(context).details), - ], - ), - ), - // Tab content - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - // Message tab - LazyTab( - controller: _tabController, - index: 0, - builder: (_) => SingleChildScrollView( - controller: _messageScrollController, - padding: const EdgeInsets.all(16), - child: TextComponent( - entry.message, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 13, - color: isDark ? Colors.white : Colors.black87, - ), - ), - ), - ), - // Stack trace tab - LazyTab( - controller: _tabController, - index: 1, - builder: (_) => entry.stackTrace != null - ? SingleChildScrollView( - controller: _stackTraceScrollController, - padding: const EdgeInsets.all(16), - child: TextComponent( - entry.stackTrace!, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: isDark ? Colors.white70 : Colors.black87, - ), - ), - ) - : Center( - child: TextComponent(S.of(context).noStackTrace), - ), - ), - // Details tab - LazyTab( - controller: _tabController, - index: 2, - builder: (_) => SingleChildScrollView( - controller: _detailsScrollController, - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _DetailRow(label: S.of(context).platform, value: entry.platform.name), - _DetailRow(label: S.of(context).severity, value: entry.severity.name), - _DetailRow(label: S.of(context).source, value: entry.source ?? 'unknown'), - _DetailRow(label: S.of(context).deviceId, value: entry.deviceId), - _DetailRow(label: S.of(context).deviceInfo, value: entry.deviceInfo ?? 'unknown'), - if (entry.metadata != null) ...[ - const SizedBox(height: 12), - TextComponent( - 'Metadata', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Colors.grey[500], - ), - ), - const SizedBox(height: 4), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isDark ? Colors.black26 : Colors.grey.shade100, - borderRadius: BorderRadius.circular(8), - ), - child: TextComponent( - entry.metadata.toString(), - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: isDark ? Colors.white70 : Colors.black87, - ), - ), - ), - ], - ], - ), - ), - ), - ], - ), ), ], ); } - // ---- Screenshot ---- - - Future _takeFullScreenshot() async { - final isDark = Theme.of(context).brightness == Brightness.dark; - final entry = widget.entry; - final subject = entry.source ?? entry.severity.name; - final fileName = buildRichScreenshotName( - type: 'error', - subject: subject, - suffix: '_full', - ); - await captureWidgetAsImage( - context, - _buildFullScreenshotWidget(isDark), - fileName: fileName, - ); - } - - Future _takeTabScreenshot() async { - final isDark = Theme.of(context).brightness == Brightness.dark; - final entry = widget.entry; - final subject = entry.source ?? entry.severity.name; - final fileName = buildRichScreenshotName( - type: 'error', - subject: subject, - suffix: '_tab', - ); - await captureWidgetAsImage( - context, - _buildTabScreenshotWidget(isDark, _tabController.index), - fileName: fileName, - ); - } - - Widget _buildFullScreenshotWidget(bool isDark) { - final entry = widget.entry; - final severityColor = _severityColor(entry.severity); - final time = DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch(entry.timestamp), - ); - - return Container( - color: isDark ? ColorTokens.darkSurface : Colors.white, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Container( - padding: const EdgeInsets.all(12), - color: isDark ? ColorTokens.darkBackground : Colors.white, - child: Row( - children: [ - Icon(LucideIcons.alertTriangle, size: 16, color: severityColor), - const SizedBox(width: 8), - _SeverityBadge(severity: entry.severity), - const SizedBox(width: 8), - _PlatformBadge(platform: entry.platform), - const SizedBox(width: 8), - TextComponent( - time, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: Colors.grey[500], - ), - ), - ], - ), - ), - const Divider(height: 1), - // Message section - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextComponent( - 'Message', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Colors.grey[500], - ), - ), - const SizedBox(height: 6), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : const Color(0xFFF0F0F0), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06), - ), - ), - child: TextComponent( - entry.message, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 12, - color: isDark ? Colors.white : Colors.black87, - ), - ), - ), - ], - ), - ), - // Stack trace section - if (entry.stackTrace != null) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextComponent( - 'Stack Trace', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Colors.grey[500], - ), - ), - const SizedBox(height: 6), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: severityColor.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: severityColor.withValues(alpha: 0.15), - ), - ), - child: TextComponent( - entry.stackTrace!, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: isDark ? Colors.white70 : Colors.black87, - ), - ), - ), - ], - ), - ), - // Details section - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextComponent( - 'Details', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Colors.grey[500], - ), - ), - const SizedBox(height: 6), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : const Color(0xFFF0F0F0), - borderRadius: BorderRadius.circular(8), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _screenshotDetailRow('Platform', entry.platform.name, isDark), - _screenshotDetailRow('Severity', entry.severity.name, isDark), - _screenshotDetailRow('Source', entry.source ?? 'unknown', isDark), - _screenshotDetailRow('Device ID', entry.deviceId, isDark), - ], - ), - ), - ], - ), - ), - ], - ), - width: 600, - ); - } - - Widget _buildTabScreenshotWidget(bool isDark, int tabIndex) { - final entry = widget.entry; - final severityColor = _severityColor(entry.severity); - final time = DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch(entry.timestamp), - ); - - final tabLabels = ['Message', 'Stack Trace', 'Details']; - final tabLabel = tabIndex >= 0 && tabIndex < tabLabels.length - ? tabLabels[tabIndex] - : 'Message'; - - return Container( - color: isDark ? ColorTokens.darkSurface : Colors.white, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Container( - padding: const EdgeInsets.all(12), - color: isDark ? ColorTokens.darkBackground : Colors.white, - child: Row( - children: [ - Icon(LucideIcons.alertTriangle, size: 16, color: severityColor), - const SizedBox(width: 8), - _SeverityBadge(severity: entry.severity), - const SizedBox(width: 8), - _PlatformBadge(platform: entry.platform), - const SizedBox(width: 8), - TextComponent(time, style: TextStyle(fontFamily: AppConstants.monoFontFamily, fontSize: 11, color: Colors.grey[500])), - const Spacer(), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: ColorTokens.primary.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(4), - ), - child: TextComponent(tabLabel, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: ColorTokens.primary)), - ), - ], - ), - ), - const Divider(height: 1), - // Tab content - Padding( - padding: const EdgeInsets.all(16), - child: tabIndex == 0 - ? TextComponent(entry.message, style: TextStyle(fontFamily: AppConstants.monoFontFamily, fontSize: 12, color: isDark ? Colors.white : Colors.black87)) - : tabIndex == 1 - ? (entry.stackTrace != null - ? TextComponent(entry.stackTrace!, style: TextStyle(fontFamily: AppConstants.monoFontFamily, fontSize: 11, color: isDark ? Colors.white70 : Colors.black87)) - : const TextComponent('No stack trace available')) - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _screenshotDetailRow('Platform', entry.platform.name, isDark), - _screenshotDetailRow('Severity', entry.severity.name, isDark), - _screenshotDetailRow('Source', entry.source ?? 'unknown', isDark), - _screenshotDetailRow('Device ID', entry.deviceId, isDark), - ], - ), - ), - ], - ), - width: 600, - ); - } - - Widget _screenshotDetailRow(String label, String value, bool isDark) { - return Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 70, - child: TextComponent(label, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: Colors.grey[500])), - ), - Expanded( - child: TextComponent(value, style: TextStyle(fontFamily: AppConstants.monoFontFamily, fontSize: 10, color: isDark ? Colors.white70 : Colors.black87)), - ), - ], - ), - ); - } -} - -class _DetailRow extends StatelessWidget { - final String label; - final String value; - - const _DetailRow({required this.label, required this.value}); - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 80, - child: TextComponent( - label, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Colors.grey[500], - ), - ), - ), - Expanded( - child: TextComponent( - value, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: isDark ? Colors.white70 : Colors.black87, - ), - ), - ), - ], - ), - ); + void _copy(BuildContext context, String text) { + // Local copy helper used by ErrorListItem.onCopy — same flow as + // other features (clipboard + showCopiedToast). + Clipboard.setData(ClipboardData(text: text)); + showCopiedToast(context); } } \ No newline at end of file diff --git a/lib/features/error_inspector/presentation/shared/count_pill.dart b/lib/features/error_inspector/presentation/shared/count_pill.dart new file mode 100644 index 0000000..7d74cf7 --- /dev/null +++ b/lib/features/error_inspector/presentation/shared/count_pill.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/constants/app_constants.dart'; + +/// Neutral rounded pill that displays a single number in a mono font. +/// Used in the Error Inspector header next to a label like "Errors" or +/// "Fatal" to surface the live count without competing with severity +/// colors elsewhere on the page. +class CountPill extends StatelessWidget { + final int count; + + const CountPill({super.key, required this.count}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '$count', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: AppConstants.monoFontFamily, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/error_inspector/presentation/shared/count_up.dart b/lib/features/error_inspector/presentation/shared/count_up.dart new file mode 100644 index 0000000..2cb365a --- /dev/null +++ b/lib/features/error_inspector/presentation/shared/count_up.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +/// Tween-animated counter that smoothly ramps from 0 → [value] over +/// [duration] (default 700ms, easeOutCubic — the GSAP `power3.out` +/// equivalent). Used in the Error Inspector's info-bar tiles so total / +/// fatal / per-platform counts animate when the live stream updates. +/// +/// [formatter] lets callers prepend suffixes (e.g. "ms") without +/// re-implementing the tween — when omitted the value is rendered as +/// a plain integer. +class CountUp extends StatelessWidget { + final int value; + final TextStyle? style; + final Duration duration; + final String Function(int)? formatter; + + const CountUp({ + super.key, + required this.value, + this.style, + this.duration = const Duration(milliseconds: 700), + this.formatter, + }); + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: value.toDouble()), + duration: duration, + curve: Curves.easeOutCubic, + builder: (context, v, _) { + final n = v.toInt(); + return Text( + formatter != null ? formatter!(n) : '$n', + style: style, + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/features/error_inspector/presentation/shared/empty_state_with_pulse.dart b/lib/features/error_inspector/presentation/shared/empty_state_with_pulse.dart new file mode 100644 index 0000000..e0a1721 --- /dev/null +++ b/lib/features/error_inspector/presentation/shared/empty_state_with_pulse.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../core/theme/color_tokens.dart'; + +/// Rich empty state with a breathing shield icon and a one-shot +/// fade+slide entrance for the title. +/// +/// Replaces the bland `EmptyState(icon: checkCircle, …)` from +/// `lib/components/feedback/empty_state.dart` with a layout that has +/// actual motion: a stacked shield with two concentric breathing rings +/// that perpetually expand + fade, plus a title that fades + slides in +/// when the widget mounts. +class EmptyStateWithPulse extends StatefulWidget { + final String title; + final String subtitle; + + const EmptyStateWithPulse({ + super.key, + required this.title, + required this.subtitle, + }); + + @override + State createState() => _EmptyStateWithPulseState(); +} + +class _EmptyStateWithPulseState extends State + with TickerProviderStateMixin { + late final AnimationController _breathCtrl; + late final AnimationController _entryCtrl; + late final Animation _entryOpacity; + late final Animation _entrySlide; + + @override + void initState() { + super.initState(); + // Slow breathing loop for the rings (perpetual motion, no user trigger) + _breathCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 3200), + )..repeat(reverse: true); + // One-shot entrance animation for the title + icon + _entryCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + _entryOpacity = CurvedAnimation( + parent: _entryCtrl, + curve: Curves.easeOutCubic, + ); + _entrySlide = Tween( + begin: const Offset(0, 0.05), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _entryCtrl, + curve: Curves.easeOutCubic, + )); + _entryCtrl.forward(); + } + + @override + void dispose() { + _breathCtrl.dispose(); + _entryCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Center( + child: FadeTransition( + opacity: _entryOpacity, + child: SlideTransition( + position: _entrySlide, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Animated shield with breathing rings (perpetual motion) + SizedBox( + width: 120, + height: 120, + child: AnimatedBuilder( + animation: _breathCtrl, + builder: (context, _) { + return Stack( + alignment: Alignment.center, + children: [ + // Outer ring + Transform.scale( + scale: 0.85 + 0.15 * _breathCtrl.value, + child: Container( + width: 110, + height: 110, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: ColorTokens.logError + .withValues(alpha: 0.12 + 0.08 * _breathCtrl.value), + width: 1.5, + ), + ), + ), + ), + // Inner ring + Transform.scale( + scale: 0.55 + 0.10 * (1 - _breathCtrl.value), + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: ColorTokens.logError + .withValues(alpha: 0.20 + 0.10 * (1 - _breathCtrl.value)), + width: 1.5, + ), + ), + ), + ), + // Shield icon + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: ColorTokens.logError.withValues(alpha: 0.10), + border: Border.all( + color: ColorTokens.logError.withValues(alpha: 0.30), + width: 1, + ), + ), + child: Icon( + LucideIcons.shieldCheck, + size: 28, + color: ColorTokens.logError, + ), + ), + ], + ); + }, + ), + ), + const SizedBox(height: 24), + Text( + widget.title, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + color: isDark + ? ColorTokens.lightBackground + : ColorTokens.darkNeutral, + ), + ), + const SizedBox(height: 6), + Text( + widget.subtitle, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.grey[500] : Colors.grey[600], + height: 1.4, + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/error_inspector/presentation/shared/error_tokens.dart b/lib/features/error_inspector/presentation/shared/error_tokens.dart new file mode 100644 index 0000000..306c0d7 --- /dev/null +++ b/lib/features/error_inspector/presentation/shared/error_tokens.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/theme/color_tokens.dart'; +import '../../../../models/log/error_event.dart'; + +/// Maps an [ErrorSeverity] to its accent color. Fatal / crash use the +/// saturated red range so they always read as critical against the +/// neutral page chrome; warning / info borrow the log palette. +Color severityColor(ErrorSeverity severity) { + switch (severity) { + case ErrorSeverity.fatal: + return Colors.red.shade900; + case ErrorSeverity.crash: + return Colors.red; + case ErrorSeverity.error: + return ColorTokens.logError; + case ErrorSeverity.warning: + return ColorTokens.logWarn; + case ErrorSeverity.info: + return ColorTokens.logInfo; + } +} + +/// Short, uppercase label for an [ErrorPlatform]. Used in badges, chips, +/// and the per-platform count cells in the info bar. +String platformLabel(ErrorPlatform platform) { + switch (platform) { + case ErrorPlatform.js: + return 'JS'; + case ErrorPlatform.native: + return 'Native'; + case ErrorPlatform.android: + return 'Android'; + case ErrorPlatform.ios: + return 'iOS'; + } +} + +/// Stable per-platform accent color used for badges, filter chips, and +/// the info-bar dots. The four platforms map to four distinct hues so +/// they read unambiguously when several are stacked next to each other. +Color platformColor(ErrorPlatform platform) { + switch (platform) { + case ErrorPlatform.js: + return Colors.blue; + case ErrorPlatform.native: + return Colors.purple; + case ErrorPlatform.android: + return Colors.green; + case ErrorPlatform.ios: + return Colors.orange; + } +} \ No newline at end of file diff --git a/lib/features/error_inspector/presentation/shared/platform_badge.dart b/lib/features/error_inspector/presentation/shared/platform_badge.dart new file mode 100644 index 0000000..d200f55 --- /dev/null +++ b/lib/features/error_inspector/presentation/shared/platform_badge.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +import '../../../../models/log/error_event.dart'; +import 'error_tokens.dart' show platformColor, platformLabel; + +/// Tinted pill that labels the source [ErrorPlatform] of an error +/// (JS / Native / Android / iOS). Uses the per-platform accent color +/// at 20% for the fill so it reads as a soft tag. +class PlatformBadge extends StatelessWidget { + final ErrorPlatform platform; + + const PlatformBadge({super.key, required this.platform}); + + @override + Widget build(BuildContext context) { + final color = platformColor(platform); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + platformLabel(platform), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/error_inspector/presentation/shared/pulsing_dot.dart b/lib/features/error_inspector/presentation/shared/pulsing_dot.dart new file mode 100644 index 0000000..ffef766 --- /dev/null +++ b/lib/features/error_inspector/presentation/shared/pulsing_dot.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +/// Live "breathing" indicator: a solid dot at the center with an +/// expanding ring that fades out and restarts every [period] ms. +/// +/// Performance note: the ring's alpha rides the controller's progress +/// directly via `widget.color.withValues(alpha: ...)` instead of wrapping +/// in an `Opacity` widget — that avoids intermediate offscreen render +/// passes on every animation tick (the inner Container is a simple +/// solid-color circle, so the Opacity layer is pure overhead). +class PulsingDot extends StatefulWidget { + final Color color; + final double size; + final Duration period; + + const PulsingDot({ + super.key, + required this.color, + this.size = 8, + this.period = const Duration(milliseconds: 1800), + }); + + @override + State createState() => _PulsingDotState(); +} + +class _PulsingDotState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController(vsync: this, duration: widget.period)..repeat(); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: widget.size * 3, + height: widget.size * 3, + child: Stack( + alignment: Alignment.center, + children: [ + // Outer expanding ring (perpetual "breathing" pulse). + // Animate the color's alpha directly instead of wrapping in an + // `Opacity` widget — that avoids intermediate offscreen render + // passes on every animation tick. + AnimatedBuilder( + animation: _ctrl, + builder: (context, _) { + return Transform.scale( + scale: 0.6 + 0.8 * _ctrl.value, + child: Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + shape: BoxShape.circle, + // Color alpha rides the same progress: starts at 0.6, + // fades to 0 as the ring expands. + color: widget.color.withValues( + alpha: 0.6 * (1 - _ctrl.value), + ), + ), + ), + ); + }, + ), + // Solid dot (anchor) + Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.color, + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/error_inspector/presentation/shared/severity_badge.dart b/lib/features/error_inspector/presentation/shared/severity_badge.dart new file mode 100644 index 0000000..26981cd --- /dev/null +++ b/lib/features/error_inspector/presentation/shared/severity_badge.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +import '../../../../models/log/error_event.dart'; +import 'error_tokens.dart' show severityColor; + +/// Tinted uppercase pill that labels an [ErrorSeverity] (FATAL / CRASH / +/// ERROR / WARNING / INFO). Uses the severity's accent color at 20% for +/// the fill so the badge reads as a soft tag rather than a solid badge. +class SeverityBadge extends StatelessWidget { + final ErrorSeverity severity; + + const SeverityBadge({super.key, required this.severity}); + + @override + Widget build(BuildContext context) { + final color = severityColor(severity); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + severity.name.toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/network_inspector/presentation/detail/blob_info.dart b/lib/features/network_inspector/presentation/detail/blob_info.dart new file mode 100644 index 0000000..a25e5ab --- /dev/null +++ b/lib/features/network_inspector/presentation/detail/blob_info.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../../../l10n/app_localizations.dart'; + +/// Empty-state-ish widget shown in place of a body viewer when the SDK +/// captured a binary blob. Renders the placeholder's label + size in a +/// neutral "data was hidden" message, so users know the request succeeded +/// but the payload is not displayable as text. +class BlobInfo extends StatelessWidget { + final String label; + final int sizeBytes; + final bool isDark; + + const BlobInfo({ + super.key, + required this.label, + required this.sizeBytes, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.package, + size: 28, color: isDark ? Colors.white38 : Colors.black38), + const SizedBox(height: 12), + Text( + S.of(context).binaryBody(label), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: isDark + ? ColorTokens.lightBackground + : ColorTokens.darkNeutral, + ), + ), + const SizedBox(height: 6), + Text( + S.of(context).binaryBodySize( + AppConstants.formatBytes(sizeBytes), + sizeBytes, + ), + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.white54 : Colors.black54, + fontFamily: AppConstants.monoFontFamily, + ), + ), + const SizedBox(height: 8), + Text( + S.of(context).binaryBodyHint, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + color: isDark ? Colors.white38 : Colors.black45, + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/network_inspector/presentation/detail/body_tab.dart b/lib/features/network_inspector/presentation/detail/body_tab.dart new file mode 100644 index 0000000..5ca180f --- /dev/null +++ b/lib/features/network_inspector/presentation/detail/body_tab.dart @@ -0,0 +1,213 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../components/feedback/empty_state.dart'; +import '../../../../components/text/text_component.dart'; +import '../../../../components/viewers/json_viewer.dart'; +import '../../../../core/theme/theme_provider.dart'; +import '../../../../core/utils/code_generator.dart'; +import '../../../../core/utils/smooth_scroll_controller.dart'; +import '../../../../core/utils/toast_utils.dart'; +import '../../../../server/providers/server_providers.dart'; +import '../shared/detect_blob_payload.dart'; +import 'blob_info.dart'; + +/// Tab body viewer for a single network request/response body. Three +/// modes (`Tree` / `JSON` / `Code`) gated behind the global +/// [bodyViewModeProvider]. When the payload isn't JSON-shaped only the +/// `JSON` segment renders. +/// +/// Pre-render short-circuits: +/// - **null / empty / whitespace body** → `EmptyState("No