diff --git a/lib/shared/widgets/markdown/code_block_header.dart b/lib/shared/widgets/markdown/code_block_header.dart deleted file mode 100644 index 000d306..0000000 --- a/lib/shared/widgets/markdown/code_block_header.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/material.dart'; -import '../../theme/theme_extensions.dart'; - -class CodeBlockHeader extends StatelessWidget { - const CodeBlockHeader({ - super.key, - required this.language, - required this.onCopy, - }); - - final String language; - final VoidCallback onCopy; - - @override - Widget build(BuildContext context) { - final theme = context.conduitTheme; - final materialTheme = Theme.of(context); - final isDark = materialTheme.brightness == Brightness.dark; - final label = language.isEmpty ? 'plaintext' : language; - - // Match GitHub/Atom theme colors - final backgroundColor = isDark - ? const Color(0xFF282c34) // Atom One Dark header - : const Color(0xFFf6f8fa); // GitHub light header - final textColor = isDark - ? const Color(0xFF9da5b4) // Muted text for dark - : const Color(0xFF57606a); // GitHub gray for light - - return Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.xs, - ), - decoration: BoxDecoration( - color: backgroundColor, - border: Border( - bottom: BorderSide( - color: theme.cardBorder.withValues(alpha: 0.15), - width: 1, - ), - ), - ), - child: Row( - children: [ - Text( - label, - style: AppTypography.codeStyle.copyWith( - color: textColor, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - const Spacer(), - Material( - color: Colors.transparent, - child: InkWell( - onTap: onCopy, - borderRadius: BorderRadius.circular(4), - child: Padding( - padding: const EdgeInsets.all(6), - child: Icon( - Icons.content_copy_rounded, - size: 16, - color: textColor, - ), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/shared/widgets/markdown/markdown_config.dart b/lib/shared/widgets/markdown/markdown_config.dart index 9642bd4..0c28f29 100644 --- a/lib/shared/widgets/markdown/markdown_config.dart +++ b/lib/shared/widgets/markdown/markdown_config.dart @@ -17,7 +17,6 @@ import 'package:conduit/l10n/app_localizations.dart'; import '../../theme/color_tokens.dart'; import '../../theme/theme_extensions.dart'; -import 'code_block_header.dart'; import 'package:conduit/core/network/self_signed_image_cache_manager.dart'; import 'package:conduit/core/network/image_header_utils.dart'; @@ -105,31 +104,46 @@ class ConduitMarkdown { final highlightLanguage = _mapLanguage(normalizedLanguage); // Use Atom One Dark for dark mode, GitHub for light mode + // These colors must match the highlight themes for visual consistency final highlightTheme = isDark ? atomOneDarkTheme : githubTheme; - - // Match theme colors for code block container final codeBackground = isDark - ? const Color(0xFF282c34) // Atom One Dark background - : const Color(0xFFfafbfc); // GitHub light background + ? const Color(0xFF282c34) // Atom One Dark + : const Color(0xFFF6F8FA); // GitHub light + + // Derive border color from background for consistency + final borderColor = isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.1); return Container( margin: const EdgeInsets.symmetric(vertical: Spacing.sm), decoration: BoxDecoration( color: codeBackground, - borderRadius: BorderRadius.circular(6), + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + border: Border.all(color: borderColor, width: BorderWidth.thin), + boxShadow: isDark + ? null + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], ), clipBehavior: Clip.antiAlias, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - CodeBlockHeader( + _CodeBlockHeader( language: normalizedLanguage, + backgroundColor: codeBackground, + borderColor: borderColor, + isDark: isDark, onCopy: () async { await Clipboard.setData(ClipboardData(text: code)); - if (!context.mounted) { - return; - } + if (!context.mounted) return; ScaffoldMessenger.of(context).hideCurrentSnackBar(); final l10n = AppLocalizations.of(context); ScaffoldMessenger.of(context).showSnackBar( @@ -143,7 +157,10 @@ class ConduitMarkdown { ), SingleChildScrollView( scrollDirection: Axis.horizontal, - padding: const EdgeInsets.all(Spacing.md), + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm + 4, + ), child: HighlightView( code, language: highlightLanguage, @@ -151,6 +168,8 @@ class ConduitMarkdown { padding: EdgeInsets.zero, textStyle: AppTypography.codeStyle.copyWith( fontFamily: AppTypography.monospaceFontFamily, + fontSize: 13, + height: 1.55, ), ), ), @@ -486,6 +505,166 @@ class ConduitMarkdown { } } +/// Internal code block header with consistent styling. +class _CodeBlockHeader extends StatefulWidget { + const _CodeBlockHeader({ + required this.language, + required this.backgroundColor, + required this.borderColor, + required this.isDark, + required this.onCopy, + }); + + final String language; + final Color backgroundColor; + final Color borderColor; + final bool isDark; + final VoidCallback onCopy; + + @override + State<_CodeBlockHeader> createState() => _CodeBlockHeaderState(); +} + +class _CodeBlockHeaderState extends State<_CodeBlockHeader> { + bool _isHovering = false; + bool _isCopied = false; + + void _handleCopy() { + widget.onCopy(); + setState(() => _isCopied = true); + Future.delayed(const Duration(seconds: 2), () { + if (mounted) setState(() => _isCopied = false); + }); + } + + @override + Widget build(BuildContext context) { + final label = widget.language.isEmpty ? 'plaintext' : widget.language; + + // Colors derived from the code block theme for consistency + final labelColor = widget.isDark + ? const Color(0xFF9DA5B4) // Atom One Dark muted + : const Color(0xFF57606A); // GitHub muted + + final iconColor = _isHovering + ? (widget.isDark ? const Color(0xFFABB2BF) : const Color(0xFF24292F)) + : labelColor; + + final successColor = widget.isDark + ? const Color(0xFF98C379) + : const Color(0xFF1A7F37); + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs + 2, + ), + decoration: BoxDecoration( + color: widget.backgroundColor, + border: Border( + bottom: BorderSide( + color: widget.borderColor, + width: BorderWidth.thin, + ), + ), + ), + child: Row( + children: [ + // Language icon + Icon( + _getLanguageIcon(label), + size: 14, + color: labelColor.withValues(alpha: 0.7), + ), + const SizedBox(width: Spacing.xs), + // Language label + Text( + label, + style: AppTypography.codeStyle.copyWith( + color: labelColor, + fontSize: 12, + fontWeight: FontWeight.w500, + letterSpacing: 0.3, + ), + ), + const Spacer(), + // Copy button with hover effect + MouseRegion( + onEnter: (_) => setState(() => _isHovering = true), + onExit: (_) => setState(() => _isHovering = false), + child: GestureDetector( + onTap: _handleCopy, + child: AnimatedContainer( + duration: AnimationDuration.fast, + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: _isHovering + ? widget.borderColor.withValues(alpha: 0.5) + : Colors.transparent, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedSwitcher( + duration: AnimationDuration.fast, + child: Icon( + _isCopied + ? Icons.check_rounded + : Icons.content_copy_rounded, + key: ValueKey(_isCopied), + size: 14, + color: _isCopied ? successColor : iconColor, + ), + ), + if (_isHovering || _isCopied) ...[ + const SizedBox(width: Spacing.xs), + AnimatedOpacity( + duration: AnimationDuration.fast, + opacity: 1.0, + child: Text( + _isCopied ? 'Copied!' : 'Copy', + style: AppTypography.codeStyle.copyWith( + color: _isCopied ? successColor : iconColor, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ], + ), + ), + ), + ), + ], + ), + ); + } + + /// Returns an appropriate icon for the language. + IconData _getLanguageIcon(String language) { + final lower = language.toLowerCase(); + return switch (lower) { + 'dart' || 'flutter' => Icons.flutter_dash_rounded, + 'python' || 'py' => Icons.code_rounded, + 'javascript' || 'js' || 'typescript' || 'ts' => Icons.javascript_rounded, + 'html' || 'css' || 'scss' => Icons.html_rounded, + 'json' || 'yaml' || 'yml' => Icons.data_object_rounded, + 'sql' || 'mysql' || 'postgresql' => Icons.storage_rounded, + 'bash' || 'shell' || 'sh' || 'zsh' => Icons.terminal_rounded, + 'markdown' || 'md' => Icons.article_rounded, + 'swift' || 'kotlin' || 'java' => Icons.phone_iphone_rounded, + 'rust' || 'go' || 'c' || 'cpp' || 'c++' => Icons.memory_rounded, + 'docker' || 'dockerfile' => Icons.cloud_rounded, + _ => Icons.code_rounded, + }; + } +} + // ChartJS diagram WebView widget class ChartJsDiagram extends StatefulWidget { const ChartJsDiagram({