import 'package:flutter/material.dart'; import 'package:html/dom.dart' as dom; import 'package:html/parser.dart' as html_parser; class AlignedHtmlContent extends StatelessWidget { final String html; final TextStyle bodyStyle; final double markerWidth; const AlignedHtmlContent({ super.key, required this.html, required this.bodyStyle, this.markerWidth = 32, }); @override Widget build(BuildContext context) { final document = html_parser.parse(html); final body = document.body; if (body == null) return const SizedBox.shrink(); return SizedBox( width: double.infinity, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: _buildNodes(body.nodes), ), ); } List _buildNodes(List nodes) { final widgets = []; for (final node in nodes) { widgets.addAll(_buildNode(node)); } return widgets; } List _buildNode(dom.Node node) { if (node is dom.Text) { final text = _normalizeWhitespace(node.text); if (text.isEmpty) return []; return [Text(text, style: bodyStyle)]; } if (node is! dom.Element) return []; switch (node.localName) { case 'p': final text = _normalizeWhitespace(node.text); if (text.isEmpty) return []; return [ Padding( padding: const EdgeInsets.only(bottom: 8), child: Text(text, style: bodyStyle), ), ]; case 'ol': return [_buildOrderedList(node, 0)]; case 'ul': return [_buildUnorderedList(node, 0)]; case 'br': return [const SizedBox(height: 8)]; default: return _buildNodes(node.nodes); } } Widget _buildOrderedList(dom.Element ol, double indent) { final items = ol.children.where((element) => element.localName == 'li'); final children = []; var index = 0; for (final item in items) { index += 1; children.add( Padding( padding: EdgeInsets.only(left: indent, bottom: 8), child: _buildOrderedListItem(item, index, indent), ), ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: children, ); } Widget _buildOrderedListItem(dom.Element li, int number, double indent) { final leadingText = _liLeadingText(li); final nestedLists = li.children.where((element) => element.localName == 'ol' || element.localName == 'ul'); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: markerWidth, child: Text( '$number.', style: bodyStyle.copyWith( fontFeatures: const [FontFeature.tabularFigures()], ), ), ), Expanded( child: Text( leadingText, style: bodyStyle, ), ), ], ), for (final nested in nestedLists) Padding( padding: const EdgeInsets.only(top: 8), child: nested.localName == 'ol' ? _buildOrderedList(nested, indent + markerWidth) : _buildUnorderedList(nested, indent + markerWidth), ), ], ); } Widget _buildUnorderedList(dom.Element ul, double indent) { final items = ul.children.where((element) => element.localName == 'li'); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final item in items) Padding( padding: EdgeInsets.only(left: indent, bottom: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: markerWidth, child: Text( '•', style: bodyStyle.copyWith( color: const Color(0xFFE26728), fontWeight: FontWeight.w600, ), ), ), Expanded( child: Text( _normalizeWhitespace(item.text), style: bodyStyle, ), ), ], ), ), ], ); } String _liLeadingText(dom.Element li) { final buffer = StringBuffer(); for (final node in li.nodes) { if (node is dom.Text) { buffer.write(node.text); } else if (node is dom.Element && node.localName != 'ol' && node.localName != 'ul') { buffer.write(node.text); } } return _normalizeWhitespace(buffer.toString()); } String _normalizeWhitespace(String value) { return value.replaceAll(RegExp(r'\s+'), ' ').trim(); } }