From bb5443b5633926f73711353fff37b295a049a56a Mon Sep 17 00:00:00 2001 From: phibvcfc Date: Mon, 6 Jul 2026 14:50:37 +0700 Subject: [PATCH] feat: implement error event detail view for all events panel and update selection styling --- .../presentation/detail/error_detail.dart | 177 ++++++++++++++++++ .../presentation/detail/error_tokens.dart | 48 +++++ .../detail/event_detail_panel.dart | 6 + .../presentation/detail/platform_badge.dart | 33 ++++ .../presentation/detail/severity_badge.dart | 33 ++++ .../detail/error_detail_panel.dart | 90 +++++++-- .../event_row/error_list_item.dart | 17 +- .../pages/error_inspector_page.dart | 11 ++ .../presentation/shared/copy_button.dart | 57 ++++++ 9 files changed, 452 insertions(+), 20 deletions(-) create mode 100644 lib/features/all_events/presentation/detail/error_detail.dart create mode 100644 lib/features/all_events/presentation/detail/error_tokens.dart create mode 100644 lib/features/all_events/presentation/detail/platform_badge.dart create mode 100644 lib/features/all_events/presentation/detail/severity_badge.dart create mode 100644 lib/features/error_inspector/presentation/shared/copy_button.dart diff --git a/lib/features/all_events/presentation/detail/error_detail.dart b/lib/features/all_events/presentation/detail/error_detail.dart new file mode 100644 index 0000000..5196c25 --- /dev/null +++ b/lib/features/all_events/presentation/detail/error_detail.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/utils/smooth_scroll_controller.dart'; +import '../../../../core/utils/toast_utils.dart'; +import '../../../../models/log/error_event.dart'; +import '../shared/copy_button.dart'; +import '../shared/error_block.dart'; +import 'platform_badge.dart'; +import 'severity_badge.dart'; + +/// Right-pane detail for error events. Mirrors `ErrorDetailPanel` from +/// the error_inspector feature but in a single-scroll layout suitable +/// for the All Events side panel (no tabs). Local copy of the badge +/// widgets avoids a cross-feature import. +class ErrorDetail extends StatefulWidget { + final ErrorEvent entry; + + const ErrorDetail({super.key, required this.entry}); + + @override + State createState() => _ErrorDetailState(); +} + +class _ErrorDetailState extends State { + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _copyText(BuildContext context, String text, String label) { + Clipboard.setData(ClipboardData(text: text)); + showCopiedToast(context, label: '$label copied'); + } + + @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: [ + // Header row: severity + platform badges + Row( + children: [ + SeverityBadge(severity: entry.severity), + const SizedBox(width: 6), + PlatformBadge(platform: entry.platform), + ], + ), + // Message + const SizedBox(height: 16), + Row( + children: [ + Text( + 'Message', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.grey[500], + ), + ), + const Spacer(), + CopyButton( + tooltip: 'Copy message', + onTap: () => _copyText(context, entry.message, 'Message'), + ), + ], + ), + const SizedBox(height: 6), + Text( + entry.message, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + color: isDark ? Colors.white : Colors.black87, + ), + ), + // Stack trace + if (entry.stackTrace != null) ...[ + const SizedBox(height: 16), + Row( + children: [ + Text( + 'Stack Trace', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.grey[500], + ), + ), + const Spacer(), + CopyButton( + tooltip: 'Copy stack trace', + onTap: () => _copyText( + context, + entry.stackTrace!, + 'Stack trace', + ), + ), + ], + ), + const SizedBox(height: 6), + ErrorBlock(text: entry.stackTrace!, isDark: isDark), + ], + // Details + const SizedBox(height: 16), + Text( + 'Details', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.grey[500], + ), + ), + const SizedBox(height: 6), + _DetailRow('Platform', entry.platform.name), + _DetailRow('Severity', entry.severity.name), + _DetailRow('Source', entry.source ?? 'unknown'), + _DetailRow('Device ID', entry.deviceId), + if (entry.deviceInfo != null) + _DetailRow('Device Info', entry.deviceInfo!), + ], + ), + ); + } +} + +class _DetailRow extends StatelessWidget { + final String label; + final String value; + + const _DetailRow(this.label, this.value); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 80, + child: Text( + label, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Colors.grey[500], + ), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white70 + : Colors.black87, + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/detail/error_tokens.dart b/lib/features/all_events/presentation/detail/error_tokens.dart new file mode 100644 index 0000000..86e995d --- /dev/null +++ b/lib/features/all_events/presentation/detail/error_tokens.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/theme/color_tokens.dart'; +import '../../../../models/log/error_event.dart'; + +/// Maps an [ErrorSeverity] to its accent color. Local copy of +/// `error_inspector/.../shared/error_tokens.dart` — the two pages +/// must not cross-import. +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; + } +} \ 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 index f5d9e0a..b6265ef 100644 --- a/lib/features/all_events/presentation/detail/event_detail_panel.dart +++ b/lib/features/all_events/presentation/detail/event_detail_panel.dart @@ -21,6 +21,7 @@ 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/error_event.dart'; import '../../../../models/log/log_entry.dart'; import '../../../../models/network/network_entry.dart'; import '../../../../models/state/state_change.dart'; @@ -30,6 +31,7 @@ import '../../provider/all_events_provider.dart'; import '../buttons/pressable_button.dart'; import '../detail/detail_header.dart'; import '../detail/diff_row.dart'; +import '../detail/error_detail.dart'; import '../detail/fallback_detail.dart'; import '../detail/log_detail.dart'; import '../detail/network_detail.dart'; @@ -1426,7 +1428,11 @@ class _EventDetailPanelState extends ConsumerState { return FallbackDetail(event: widget.event); case EventType.display: case EventType.asyncOp: + return FallbackDetail(event: widget.event); case EventType.error: + if (widget.event.rawData is ErrorEvent) { + return ErrorDetail(entry: widget.event.rawData as ErrorEvent); + } return FallbackDetail(event: widget.event); } } diff --git a/lib/features/all_events/presentation/detail/platform_badge.dart b/lib/features/all_events/presentation/detail/platform_badge.dart new file mode 100644 index 0000000..8b05b3d --- /dev/null +++ b/lib/features/all_events/presentation/detail/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]. Local copy of +/// `error_inspector/.../shared/platform_badge.dart` — no cross-feature +/// import, by design. +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/all_events/presentation/detail/severity_badge.dart b/lib/features/all_events/presentation/detail/severity_badge.dart new file mode 100644 index 0000000..c32b3ef --- /dev/null +++ b/lib/features/all_events/presentation/detail/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]. Local copy of +/// `error_inspector/.../shared/severity_badge.dart` — no cross-feature +/// import, by design. +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/error_inspector/presentation/detail/error_detail_panel.dart b/lib/features/error_inspector/presentation/detail/error_detail_panel.dart index e45094c..5358b71 100644 --- a/lib/features/error_inspector/presentation/detail/error_detail_panel.dart +++ b/lib/features/error_inspector/presentation/detail/error_detail_panel.dart @@ -1,4 +1,5 @@ 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'; @@ -11,8 +12,10 @@ 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 '../../../../core/utils/toast_utils.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../models/log/error_event.dart'; +import '../shared/copy_button.dart'; import '../shared/error_tokens.dart' show severityColor; import '../shared/platform_badge.dart'; import '../shared/severity_badge.dart'; @@ -76,6 +79,11 @@ class _ErrorDetailPanelState extends ConsumerState setState(() {}); } + void _copyText(BuildContext context, String text, String label) { + Clipboard.setData(ClipboardData(text: text)); + showCopiedToast(context, label: '$label copied'); + } + @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; @@ -257,13 +265,40 @@ class _ErrorDetailPanelState extends ConsumerState 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, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + TextComponent( + S.of(context).message, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.grey[500], + ), + ), + const Spacer(), + CopyButton( + tooltip: 'Copy message', + onTap: () => _copyText( + context, + entry.message, + 'Message', + ), + ), + ], + ), + const SizedBox(height: 6), + TextComponent( + entry.message, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 13, + color: isDark ? Colors.white : Colors.black87, + ), + ), + ], ), ), ), @@ -275,13 +310,40 @@ class _ErrorDetailPanelState extends ConsumerState ? 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, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + TextComponent( + 'Stack Trace', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.grey[500], + ), + ), + const Spacer(), + CopyButton( + tooltip: 'Copy stack trace', + onTap: () => _copyText( + context, + entry.stackTrace!, + 'Stack trace', + ), + ), + ], + ), + const SizedBox(height: 6), + TextComponent( + entry.stackTrace!, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: isDark ? Colors.white70 : Colors.black87, + ), + ), + ], ), ) : Center( 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 index 26f5e13..ab4c6c0 100644 --- a/lib/features/error_inspector/presentation/event_row/error_list_item.dart +++ b/lib/features/error_inspector/presentation/event_row/error_list_item.dart @@ -3,6 +3,7 @@ import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../../core/constants/app_constants.dart'; +import '../../../../core/theme/color_tokens.dart'; import '../../../../models/log/error_event.dart'; import '../shared/error_tokens.dart' show severityColor; import '../shared/platform_badge.dart'; @@ -48,16 +49,20 @@ class ErrorListItem extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: BoxDecoration( color: isSelected - ? (isDark ? Colors.white10 : Colors.black.withValues(alpha: 0.05)) - : null, + ? ColorTokens.selectedBg(isDark) + : Colors.transparent, border: Border( - left: BorderSide( - color: severityClr, - width: 3, - ), bottom: BorderSide( color: isDark ? Colors.white10 : Colors.black12, ), + // Left border mirrors the storage_viewer / all_events pattern: + // tinted with the row's severity color, but flips to the + // shared teal selection accent (3px vs the default 2px) + // when the user picks the row. + left: BorderSide( + color: isSelected ? ColorTokens.selectedAccent : severityClr, + width: isSelected ? 3 : 2, + ), ), ), child: Row( 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 73aa6c6..b8dcc5e 100644 --- a/lib/features/error_inspector/presentation/pages/error_inspector_page.dart +++ b/lib/features/error_inspector/presentation/pages/error_inspector_page.dart @@ -75,6 +75,17 @@ class _ErrorInspectorPageState extends ConsumerState { }, fireImmediately: true, ); + // Selection changes must also bump generation — StableBuilderDelegate + // short-circuits shouldRebuild when generation is unchanged, which + // would otherwise leave the selected row's tint stuck on the + // previously-selected item. + _selectedId.addListener(_onSelectionChanged); + } + + void _onSelectionChanged() { + if (!mounted) return; + _generation++; + setState(() {}); } void _onScroll() { diff --git a/lib/features/error_inspector/presentation/shared/copy_button.dart b/lib/features/error_inspector/presentation/shared/copy_button.dart new file mode 100644 index 0000000..03c46fd --- /dev/null +++ b/lib/features/error_inspector/presentation/shared/copy_button.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +/// 30×30 round copy icon with hover fill + press feedback. Local copy of +/// `all_events/presentation/shared/copy_button.dart` (which uses an +/// internal `PressableButton`) — kept local to avoid a cross-feature +/// import, mirroring the pattern used by every other sub-folder here. +class CopyButton extends StatefulWidget { + final String tooltip; + final VoidCallback onTap; + final IconData icon; + + const CopyButton({ + super.key, + required this.tooltip, + required this.onTap, + this.icon = LucideIcons.copy, + }); + + @override + State createState() => _CopyButtonState(); +} + +class _CopyButtonState extends State { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: widget.tooltip, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 30, + height: 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: _hovered + ? Colors.grey.withValues(alpha: 0.12) + : Colors.transparent, + ), + child: Icon( + widget.icon, + size: 14, + color: Colors.grey[500], + ), + ), + ), + ), + ); + } +} \ No newline at end of file