Files
zhinian_manage/lib/widgets/markdown_message.dart

309 lines
9.4 KiB
Dart

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<String, MarkdownElementBuilder> 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 = <String>[];
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 = <List<String>>[];
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<String> _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<List<String>>? 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<Color>(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<List<String>> 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,
),
),
],
),
],
),
),
);
},
),
);
}
}