refactor: enhance markdown rendering capabilities
- Introduced new builders for ordered and unordered lists, improving the rendering of list elements in markdown. - Added a table builder to support structured data presentation within markdown content. - Enhanced checkbox and radio button themes for better visual consistency and user interaction. - Updated the streaming markdown widget to utilize the new list and table builders, ensuring a cohesive markdown experience. - Improved overall maintainability and adaptability of the markdown configuration by centralizing theme-related logic.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
@@ -9,7 +10,13 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_highlight/flutter_highlight.dart';
|
import 'package:flutter_highlight/flutter_highlight.dart';
|
||||||
import 'package:flutter_highlight/themes/atom-one-dark-reasonable.dart';
|
import 'package:flutter_highlight/themes/atom-one-dark-reasonable.dart';
|
||||||
import 'package:gpt_markdown/custom_widgets/markdown_config.dart'
|
import 'package:gpt_markdown/custom_widgets/markdown_config.dart'
|
||||||
show CodeBlockBuilder, ImageBuilder;
|
show
|
||||||
|
CodeBlockBuilder,
|
||||||
|
GptMarkdownConfig,
|
||||||
|
ImageBuilder,
|
||||||
|
OrderedListBuilder,
|
||||||
|
TableBuilder,
|
||||||
|
UnOrderedListBuilder;
|
||||||
import 'package:gpt_markdown/gpt_markdown.dart';
|
import 'package:gpt_markdown/gpt_markdown.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
|
||||||
@@ -45,6 +52,11 @@ class ConduitMarkdownTheme {
|
|||||||
required this.themeData,
|
required this.themeData,
|
||||||
required this.imageBuilder,
|
required this.imageBuilder,
|
||||||
required this.codeBuilder,
|
required this.codeBuilder,
|
||||||
|
required this.orderedListBuilder,
|
||||||
|
required this.unOrderedListBuilder,
|
||||||
|
required this.tableBuilder,
|
||||||
|
required this.checkboxTheme,
|
||||||
|
required this.radioTheme,
|
||||||
this.followLinkColor = true,
|
this.followLinkColor = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,6 +64,11 @@ class ConduitMarkdownTheme {
|
|||||||
final GptMarkdownThemeData themeData;
|
final GptMarkdownThemeData themeData;
|
||||||
final ImageBuilder imageBuilder;
|
final ImageBuilder imageBuilder;
|
||||||
final CodeBlockBuilder codeBuilder;
|
final CodeBlockBuilder codeBuilder;
|
||||||
|
final OrderedListBuilder orderedListBuilder;
|
||||||
|
final UnOrderedListBuilder unOrderedListBuilder;
|
||||||
|
final TableBuilder tableBuilder;
|
||||||
|
final CheckboxThemeData checkboxTheme;
|
||||||
|
final RadioThemeData radioTheme;
|
||||||
final bool followLinkColor;
|
final bool followLinkColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +104,259 @@ class ConduitMarkdownConfig {
|
|||||||
linkHoverColor: materialTheme.colorScheme.primary.withValues(alpha: 0.8),
|
linkHoverColor: materialTheme.colorScheme.primary.withValues(alpha: 0.8),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final listMarkerStyle = AppTypography.bodyMediumStyle.copyWith(
|
||||||
|
color: theme.textSecondary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
);
|
||||||
|
final bulletColor = theme.textSecondary.withValues(alpha: 0.85);
|
||||||
|
const listIndicatorWidth = 28.0;
|
||||||
|
|
||||||
|
Widget orderedListBuilder(
|
||||||
|
BuildContext context,
|
||||||
|
String number,
|
||||||
|
Widget child,
|
||||||
|
GptMarkdownConfig listConfig,
|
||||||
|
) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsetsDirectional.only(bottom: Spacing.xs),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
textDirection: listConfig.textDirection,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: listIndicatorWidth,
|
||||||
|
child: Align(
|
||||||
|
alignment: AlignmentDirectional.topEnd,
|
||||||
|
child: Text('$number.', style: listMarkerStyle),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.sm),
|
||||||
|
Expanded(child: child),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget unOrderedListBuilder(
|
||||||
|
BuildContext context,
|
||||||
|
Widget child,
|
||||||
|
GptMarkdownConfig listConfig,
|
||||||
|
) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsetsDirectional.only(bottom: Spacing.xs),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
textDirection: listConfig.textDirection,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsetsDirectional.only(top: Spacing.xs + 2),
|
||||||
|
child: Container(
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bulletColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: theme.cardShadow.withValues(alpha: 0.18),
|
||||||
|
blurRadius: 2,
|
||||||
|
offset: const Offset(0, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.sm),
|
||||||
|
Expanded(child: child),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final tableHeaderStyle = AppTypography.bodySmallStyle.copyWith(
|
||||||
|
color: theme.textPrimary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
);
|
||||||
|
final tableCellStyle = AppTypography.bodySmallStyle.copyWith(
|
||||||
|
color: theme.textSecondary,
|
||||||
|
);
|
||||||
|
final tableBorderColor = theme.cardBorder.withValues(alpha: 0.6);
|
||||||
|
final headerBackground = theme.surfaceContainerHighest.withValues(
|
||||||
|
alpha: 0.7,
|
||||||
|
);
|
||||||
|
final stripeBackground = theme.surfaceContainer.withValues(alpha: 0.25);
|
||||||
|
final tableBackground = theme.surfaceBackground.withValues(alpha: 0.35);
|
||||||
|
|
||||||
|
Widget tableBuilder(
|
||||||
|
BuildContext context,
|
||||||
|
List<CustomTableRow> rows,
|
||||||
|
TextStyle textStyle,
|
||||||
|
GptMarkdownConfig tableConfig,
|
||||||
|
) {
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final columnCount = rows.fold<int>(
|
||||||
|
0,
|
||||||
|
(maxCount, row) => math.max(maxCount, row.fields.length),
|
||||||
|
);
|
||||||
|
if (columnCount == 0) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final columnWidths = <int, TableColumnWidth>{
|
||||||
|
for (var i = 0; i < columnCount; i++) i: const IntrinsicColumnWidth(),
|
||||||
|
};
|
||||||
|
|
||||||
|
final controller = ScrollController();
|
||||||
|
|
||||||
|
final tableRows = rows.asMap().entries.map((entry) {
|
||||||
|
final rowIndex = entry.key;
|
||||||
|
final row = entry.value;
|
||||||
|
final isHeader = row.isHeader;
|
||||||
|
final backgroundColor = isHeader
|
||||||
|
? headerBackground
|
||||||
|
: rowIndex.isOdd
|
||||||
|
? stripeBackground
|
||||||
|
: Colors.transparent;
|
||||||
|
|
||||||
|
return TableRow(
|
||||||
|
decoration: backgroundColor == Colors.transparent
|
||||||
|
? null
|
||||||
|
: BoxDecoration(color: backgroundColor),
|
||||||
|
children: List.generate(columnCount, (columnIndex) {
|
||||||
|
if (columnIndex >= row.fields.length) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final field = row.fields[columnIndex];
|
||||||
|
final cellConfig = tableConfig.copyWith(
|
||||||
|
style: isHeader ? tableHeaderStyle : tableCellStyle,
|
||||||
|
);
|
||||||
|
Widget cell = MdWidget(
|
||||||
|
context,
|
||||||
|
field.data.trim(),
|
||||||
|
false,
|
||||||
|
config: cellConfig,
|
||||||
|
);
|
||||||
|
cell = Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: Spacing.sm,
|
||||||
|
vertical: Spacing.xs,
|
||||||
|
),
|
||||||
|
child: cell,
|
||||||
|
);
|
||||||
|
|
||||||
|
Alignment alignment;
|
||||||
|
switch (field.alignment) {
|
||||||
|
case TextAlign.center:
|
||||||
|
alignment = Alignment.center;
|
||||||
|
break;
|
||||||
|
case TextAlign.right:
|
||||||
|
alignment = Alignment.centerRight;
|
||||||
|
break;
|
||||||
|
case TextAlign.left:
|
||||||
|
default:
|
||||||
|
alignment = Alignment.centerLeft;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Align(alignment: alignment, child: cell);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
final tableWidget = DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: tableBackground,
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
||||||
|
border: Border.all(color: tableBorderColor, width: BorderWidth.small),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
||||||
|
child: Table(
|
||||||
|
columnWidths: columnWidths,
|
||||||
|
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
|
||||||
|
border: TableBorder.symmetric(
|
||||||
|
inside: BorderSide(
|
||||||
|
color: tableBorderColor,
|
||||||
|
width: BorderWidth.micro,
|
||||||
|
),
|
||||||
|
outside: BorderSide.none,
|
||||||
|
),
|
||||||
|
children: tableRows,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Scrollbar(
|
||||||
|
controller: controller,
|
||||||
|
thumbVisibility: false,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
controller: controller,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: Spacing.xs),
|
||||||
|
child: tableWidget,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final primaryColor = materialTheme.colorScheme.primary;
|
||||||
|
final overlayColor = primaryColor.withValues(alpha: 0.12);
|
||||||
|
|
||||||
|
Color? resolveOverlay(Set<WidgetState> states) =>
|
||||||
|
states.contains(WidgetState.pressed) ||
|
||||||
|
states.contains(WidgetState.focused) ||
|
||||||
|
states.contains(WidgetState.hovered)
|
||||||
|
? overlayColor
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final checkboxTheme = CheckboxThemeData(
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
splashRadius: 18,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
||||||
|
),
|
||||||
|
side: BorderSide(
|
||||||
|
color: theme.cardBorder.withValues(alpha: 0.6),
|
||||||
|
width: BorderWidth.micro,
|
||||||
|
),
|
||||||
|
fillColor: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.disabled)) {
|
||||||
|
return theme.surfaceContainer.withValues(alpha: 0.4);
|
||||||
|
}
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return primaryColor;
|
||||||
|
}
|
||||||
|
return theme.surfaceBackground.withValues(alpha: 0.7);
|
||||||
|
}),
|
||||||
|
checkColor: WidgetStateProperty.all(theme.textInverse),
|
||||||
|
overlayColor: WidgetStateProperty.resolveWith(resolveOverlay),
|
||||||
|
);
|
||||||
|
|
||||||
|
final radioTheme = RadioThemeData(
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
fillColor: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.disabled)) {
|
||||||
|
return theme.surfaceContainer.withValues(alpha: 0.4);
|
||||||
|
}
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return primaryColor;
|
||||||
|
}
|
||||||
|
return theme.surfaceBackground.withValues(alpha: 0.7);
|
||||||
|
}),
|
||||||
|
overlayColor: WidgetStateProperty.resolveWith(resolveOverlay),
|
||||||
|
backgroundColor: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.disabled)) {
|
||||||
|
return theme.surfaceBackground.withValues(alpha: 0.3);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return ConduitMarkdownTheme(
|
return ConduitMarkdownTheme(
|
||||||
textStyle: bodyStyle,
|
textStyle: bodyStyle,
|
||||||
themeData: markdownThemeData,
|
themeData: markdownThemeData,
|
||||||
@@ -161,6 +431,11 @@ class ConduitMarkdownConfig {
|
|||||||
child: content,
|
child: content,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
orderedListBuilder: orderedListBuilder,
|
||||||
|
unOrderedListBuilder: unOrderedListBuilder,
|
||||||
|
tableBuilder: tableBuilder,
|
||||||
|
checkboxTheme: checkboxTheme,
|
||||||
|
radioTheme: radioTheme,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,50 +38,61 @@ class StreamingMarkdownWidget extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
final textScaler = MediaQuery.maybeOf(context)?.textScaler;
|
final textScaler = MediaQuery.maybeOf(context)?.textScaler;
|
||||||
|
|
||||||
|
final themedControls = Theme.of(context).copyWith(
|
||||||
|
checkboxTheme: markdownTheme.checkboxTheme,
|
||||||
|
radioTheme: markdownTheme.radioTheme,
|
||||||
|
);
|
||||||
|
|
||||||
return GptMarkdownTheme(
|
return GptMarkdownTheme(
|
||||||
gptThemeData: markdownTheme.themeData,
|
gptThemeData: markdownTheme.themeData,
|
||||||
child: SelectionArea(
|
child: Theme(
|
||||||
child: GptMarkdown(
|
data: themedControls,
|
||||||
normalized,
|
child: SelectionArea(
|
||||||
style: markdownTheme.textStyle,
|
child: GptMarkdown(
|
||||||
followLinkColor: markdownTheme.followLinkColor,
|
normalized,
|
||||||
textDirection: Directionality.of(context),
|
style: markdownTheme.textStyle,
|
||||||
textScaler: textScaler,
|
followLinkColor: markdownTheme.followLinkColor,
|
||||||
onLinkTap: onTapLink,
|
textDirection: Directionality.of(context),
|
||||||
codeBuilder: markdownTheme.codeBuilder,
|
textScaler: textScaler,
|
||||||
imageBuilder: markdownTheme.imageBuilder,
|
onLinkTap: onTapLink,
|
||||||
useDollarSignsForLatex: true,
|
codeBuilder: markdownTheme.codeBuilder,
|
||||||
highlightBuilder: (highlightContext, inline, baseStyle) {
|
imageBuilder: markdownTheme.imageBuilder,
|
||||||
final softened = ConduitMarkdownPreprocessor.softenInlineCode(
|
orderedListBuilder: markdownTheme.orderedListBuilder,
|
||||||
inline,
|
unOrderedListBuilder: markdownTheme.unOrderedListBuilder,
|
||||||
);
|
tableBuilder: markdownTheme.tableBuilder,
|
||||||
final theme = highlightContext.conduitTheme;
|
useDollarSignsForLatex: true,
|
||||||
final base = baseStyle;
|
highlightBuilder: (highlightContext, inline, baseStyle) {
|
||||||
final fontSize = (base.fontSize ?? 13).clamp(11, 15).toDouble();
|
final softened = ConduitMarkdownPreprocessor.softenInlineCode(
|
||||||
return Container(
|
inline,
|
||||||
padding: const EdgeInsets.symmetric(
|
);
|
||||||
horizontal: Spacing.xs,
|
final theme = highlightContext.conduitTheme;
|
||||||
vertical: Spacing.xxs,
|
final base = baseStyle;
|
||||||
),
|
final fontSize = (base.fontSize ?? 13).clamp(11, 15).toDouble();
|
||||||
decoration: BoxDecoration(
|
return Container(
|
||||||
color: theme.surfaceBackground.withValues(alpha: 0.55),
|
padding: const EdgeInsets.symmetric(
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
horizontal: Spacing.xs,
|
||||||
border: Border.all(
|
vertical: Spacing.xxs,
|
||||||
color: theme.cardBorder.withValues(alpha: 0.2),
|
|
||||||
width: BorderWidth.micro,
|
|
||||||
),
|
),
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
child: Text(
|
color: theme.surfaceBackground.withValues(alpha: 0.55),
|
||||||
softened,
|
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
||||||
style: base.copyWith(
|
border: Border.all(
|
||||||
fontFamily: AppTypography.monospaceFontFamily,
|
color: theme.cardBorder.withValues(alpha: 0.2),
|
||||||
fontSize: fontSize,
|
width: BorderWidth.micro,
|
||||||
height: 1.35,
|
),
|
||||||
color: theme.code?.color ?? theme.textSecondary,
|
|
||||||
),
|
),
|
||||||
),
|
child: Text(
|
||||||
);
|
softened,
|
||||||
},
|
style: base.copyWith(
|
||||||
|
fontFamily: AppTypography.monospaceFontFamily,
|
||||||
|
fontSize: fontSize,
|
||||||
|
height: 1.35,
|
||||||
|
color: theme.code?.color ?? theme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user