import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import '../theme.dart'; class MarkdownMessage extends StatelessWidget { final String data; final MarkdownStyleSheet styleSheet; final Map builders; const MarkdownMessage({ super.key, required this.data, required this.styleSheet, this.builders = const {}, }); @override Widget build(BuildContext context) { final segments = _split(data); return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ for (final seg in segments) if (seg.isTable) _ScrollableTable(rows: seg.rows!, styleSheet: styleSheet) else MarkdownBody( data: seg.text, styleSheet: styleSheet, builders: builders, imageBuilder: (uri, title, alt) => _MarkdownImage(uri: uri, alt: alt ?? title), shrinkWrap: true, ), ], ); } static final _tableSep = RegExp(r'^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$'); static final _tableRow = RegExp(r'^\s*\|.*\|\s*$'); List<_Segment> _split(String src) { final lines = src.split('\n'); final result = <_Segment>[]; final buffer = []; void flushText() { if (buffer.isEmpty) return; final text = buffer.join('\n').trim(); if (text.isNotEmpty) result.add(_Segment.text(text)); buffer.clear(); } for (var i = 0; i < lines.length; i++) { final line = lines[i]; final isHeader = _tableRow.hasMatch(line); final next = i + 1 < lines.length ? lines[i + 1] : ''; if (isHeader && _tableSep.hasMatch(next)) { flushText(); final rows = >[]; rows.add(_cells(line)); i += 2; while (i < lines.length && _tableRow.hasMatch(lines[i])) { rows.add(_cells(lines[i])); i++; } i--; result.add(_Segment.table(rows)); } else { buffer.add(line); } } flushText(); return result; } List _cells(String line) { var trimmed = line.trim(); if (trimmed.startsWith('|')) trimmed = trimmed.substring(1); if (trimmed.endsWith('|')) trimmed = trimmed.substring(0, trimmed.length - 1); return trimmed.split('|').map((c) => c.trim()).toList(); } } class _Segment { final String text; final List>? rows; bool get isTable => rows != null; _Segment.text(this.text) : rows = null; _Segment.table(this.rows) : text = ''; } class _MarkdownImage extends StatelessWidget { final Uri uri; final String? alt; const _MarkdownImage({required this.uri, this.alt}); @override Widget build(BuildContext context) { final url = uri.toString(); final tag = 'md-img:$url'; return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { FocusManager.instance.primaryFocus?.unfocus(); Navigator.of(context, rootNavigator: true).push( PageRouteBuilder( opaque: false, barrierColor: Colors.black, transitionDuration: const Duration(milliseconds: 240), pageBuilder: (_, __, ___) => _ImageViewer(url: url, heroTag: tag, alt: alt), ), ); }, child: Hero( tag: tag, child: ClipRRect( borderRadius: BorderRadius.circular(10), child: CachedNetworkImage( imageUrl: url, fit: BoxFit.cover, width: double.infinity, placeholder: (context, _) => AspectRatio( aspectRatio: 16 / 9, child: Container( color: AppColors.divider.withOpacity(0.3), alignment: Alignment.center, child: const SizedBox( width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2), ), ), ), errorWidget: (context, _, __) => AspectRatio( aspectRatio: 16 / 9, child: Container( color: AppColors.divider.withOpacity(0.3), alignment: Alignment.center, child: Icon( Icons.broken_image_outlined, color: AppColors.textTertiary, size: 32, ), ), ), ), ), ), ), ); } } class _ImageViewer extends StatelessWidget { final String url; final String heroTag; final String? alt; const _ImageViewer({required this.url, required this.heroTag, this.alt}); void _close(BuildContext context) { FocusManager.instance.primaryFocus?.unfocus(); Navigator.of(context).maybePop(); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, body: GestureDetector( onTap: () => _close(context), behavior: HitTestBehavior.opaque, child: SafeArea( child: Stack( children: [ Center( child: Hero( tag: heroTag, child: InteractiveViewer( minScale: 1, maxScale: 4, child: CachedNetworkImage( imageUrl: url, fit: BoxFit.contain, placeholder: (context, _) => const Center( child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white), ), ), errorWidget: (context, _, __) => const Icon( Icons.broken_image_outlined, color: Colors.white54, size: 64, ), ), ), ), ), Positioned( top: 8, right: 8, child: IconButton( icon: const Icon(Icons.close, color: Colors.white), onPressed: () => _close(context), ), ), if (alt != null && alt!.isNotEmpty) Positioned( left: 16, right: 16, bottom: 16, child: Text( alt!, textAlign: TextAlign.center, style: const TextStyle( color: Colors.white70, fontSize: 13, ), ), ), ], ), ), ), ); } } class _ScrollableTable extends StatelessWidget { final List> rows; final MarkdownStyleSheet styleSheet; const _ScrollableTable({required this.rows, required this.styleSheet}); @override Widget build(BuildContext context) { if (rows.isEmpty) return const SizedBox.shrink(); final header = rows.first; final body = rows.skip(1).toList(); final cols = header.length; final padding = styleSheet.tableCellsPadding ?? const EdgeInsets.all(8); final headStyle = styleSheet.tableHead ?? const TextStyle(fontWeight: FontWeight.w600); final bodyStyle = styleSheet.tableBody ?? const TextStyle(); final border = styleSheet.tableBorder ?? TableBorder.all(color: AppColors.divider, width: 1); return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( scrollDirection: Axis.horizontal, physics: const BouncingScrollPhysics(), child: ConstrainedBox( constraints: BoxConstraints(minWidth: constraints.maxWidth), child: Table( defaultColumnWidth: const IntrinsicColumnWidth(), border: border, children: [ TableRow( decoration: BoxDecoration( color: AppColors.primary.withOpacity(0.06), ), children: [ for (var c = 0; c < cols; c++) Padding( padding: padding, child: Text( c < header.length ? header[c] : '', style: headStyle, ), ), ], ), for (final row in body) TableRow( children: [ for (var c = 0; c < cols; c++) Padding( padding: padding, child: Text( c < row.length ? row[c] : '', style: bodyStyle, ), ), ], ), ], ), ), ); }, ), ); } }