773 lines
25 KiB
Dart
773 lines
25 KiB
Dart
import 'dart:math';
|
|
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_map/flutter_map.dart';
|
|
import 'package:html/parser.dart';
|
|
import 'package:html/dom.dart' as dom;
|
|
import 'package:latlong2/latlong.dart';
|
|
import 'package:logger/logger.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'package:url_launcher/url_launcher_string.dart';
|
|
import 'package:voyagehandbook/api/classes.dart';
|
|
import 'package:voyagehandbook/util/storage.dart';
|
|
import 'package:voyagehandbook/util/styles.dart';
|
|
import 'package:voyagehandbook/util/widgets/warning.dart';
|
|
import 'package:html_unescape/html_unescape_small.dart';
|
|
import 'package:voyagehandbook/views/pageview.dart';
|
|
import 'package:widget_zoom/widget_zoom.dart';
|
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
|
|
|
/*
|
|
Voyage Handbook - The open-source WikiVoyage reader
|
|
Copyright (C) 2023 Matyáš Caras
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License version 3 as published by
|
|
the Free Software Foundation.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
final _ignoredTags = ["style", "script"];
|
|
final logger = Logger(printer: PrettyPrinter());
|
|
|
|
class PageRenderer {
|
|
final ColorScheme scheme;
|
|
final double height;
|
|
final double width;
|
|
final BuildContext context;
|
|
final AppLocalizations loc;
|
|
|
|
/// For offline downloads; don't bother rendering the widget tree
|
|
final bool offline;
|
|
|
|
/// HTML for offline download / caching
|
|
String outHtml = "";
|
|
String? document;
|
|
|
|
PageRenderer(this.scheme, this.height, this.width, this.context, this.loc,
|
|
{this.offline = false});
|
|
|
|
/// Used to create Widgets from raw HTML from WM API
|
|
Future<ListView> renderFromPageHTML(RawPage page) async {
|
|
var out = <Widget>[
|
|
const SizedBox(
|
|
height: 10,
|
|
),
|
|
SelectableText(
|
|
page.title,
|
|
textAlign: TextAlign.center,
|
|
style: PageStyles.h1,
|
|
),
|
|
const SizedBox(
|
|
height: 10,
|
|
),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
SelectableText(loc.attrFrom),
|
|
const SizedBox(
|
|
width: 5,
|
|
),
|
|
SelectableText(
|
|
"WikiVoyage",
|
|
onTap: () => launchUrl(
|
|
Uri.parse("https://en.wikivoyage.org/wiki/${page.key}"),
|
|
mode: LaunchMode.externalApplication),
|
|
style: const TextStyle(
|
|
decoration: TextDecoration.underline,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Row(
|
|
children: [
|
|
SelectableText(loc.attrUnder),
|
|
const SizedBox(
|
|
width: 2,
|
|
),
|
|
Flexible(
|
|
child: SelectableText(
|
|
page.license.title,
|
|
textAlign: TextAlign.center,
|
|
onTap: () => launchUrl(Uri.parse(page.license.url),
|
|
mode: LaunchMode.externalApplication),
|
|
style: const TextStyle(
|
|
decoration: TextDecoration.underline,
|
|
),
|
|
),
|
|
)
|
|
],
|
|
)
|
|
];
|
|
outHtml = "<html><head></head><body>";
|
|
var document = parse(page.html);
|
|
var sections = document.body!.getElementsByTagName("section");
|
|
this.document = page.html;
|
|
for (var sec in sections) {
|
|
if (sec.localName == "section") {
|
|
if (!offline) out.addAll(await _renderSection(sec));
|
|
outHtml += sec.outerHtml;
|
|
}
|
|
}
|
|
var l = ListView.builder(
|
|
padding: EdgeInsets.zero,
|
|
itemBuilder: (c, i) => out[i],
|
|
itemCount: out.length,
|
|
);
|
|
return l;
|
|
}
|
|
|
|
Future<List<Widget>> _renderSection(dom.Element sec) async {
|
|
var out = <Widget>[];
|
|
// Get Section Title
|
|
var headings = sec.children.where(
|
|
(element) => ["h2", "h3", "h4", "h5"].contains(element.localName));
|
|
var sectionTitle = (headings.isNotEmpty) ? headings.first : null;
|
|
if (sectionTitle != null) {
|
|
switch (sectionTitle.localName) {
|
|
case "h2":
|
|
out.add(
|
|
const SizedBox(
|
|
height: 10,
|
|
),
|
|
);
|
|
out.add(SelectableText(
|
|
sectionTitle.text,
|
|
style: PageStyles.h2,
|
|
textAlign: TextAlign.center,
|
|
));
|
|
break;
|
|
case "h3":
|
|
out.add(
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: SelectableText(
|
|
sectionTitle.text,
|
|
style: PageStyles.h3,
|
|
),
|
|
),
|
|
);
|
|
break;
|
|
case "h4":
|
|
out.add(
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: SelectableText(sectionTitle.text, style: PageStyles.h4),
|
|
),
|
|
);
|
|
break;
|
|
case "h5":
|
|
out.add(
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: SelectableText(sectionTitle.text, style: PageStyles.h5),
|
|
),
|
|
);
|
|
break;
|
|
default:
|
|
out.add(
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: SelectableText(sectionTitle.text),
|
|
),
|
|
);
|
|
break;
|
|
}
|
|
// out.add(
|
|
// const SizedBox(
|
|
// height: 5,
|
|
// ),
|
|
// );
|
|
}
|
|
|
|
// create TextSpans from text content
|
|
for (var element in sec.children) {
|
|
// Go through all section's children
|
|
for (var t in _ignoredTags) {
|
|
var ignored = element.getElementsByTagName(t);
|
|
if (ignored.isNotEmpty) {
|
|
// Remove ignored tags
|
|
for (var element in ignored) {
|
|
element.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
switch (element.localName) {
|
|
case "p":
|
|
case "link":
|
|
if (element
|
|
.getElementsByClassName("mw-kartographer-maplink")
|
|
.isNotEmpty) break;
|
|
out.add(
|
|
SelectableText.rich(
|
|
TextSpan(
|
|
children: _renderText(element.innerHtml),
|
|
),
|
|
style: const TextStyle(height: 1.2),
|
|
textAlign: TextAlign.justify,
|
|
),
|
|
); // add paragraph spans as single rich text
|
|
out.add(
|
|
const SizedBox(
|
|
height: 5,
|
|
),
|
|
); // space paragraphs
|
|
break;
|
|
case "figure":
|
|
out.addAll(await _renderImageFigure(element));
|
|
out.add(
|
|
const SizedBox(
|
|
height: 10,
|
|
),
|
|
);
|
|
break;
|
|
case "section":
|
|
out.addAll(await _renderSection(element));
|
|
break;
|
|
case "dl":
|
|
var dd = element.getElementsByTagName("dd").first;
|
|
var link = RegExp(r'<a .+? href="\.\/(.+?)".+?>(.+?)<', dotAll: true)
|
|
.firstMatch(dd.innerHtml);
|
|
if (link == null) {
|
|
break; // TODO: handle `dd` which are not "see also" links
|
|
}
|
|
out.add(
|
|
Row(
|
|
children: [
|
|
const SelectableText(
|
|
"See also:",
|
|
style: TextStyle(),
|
|
),
|
|
const SizedBox(
|
|
width: 5,
|
|
),
|
|
SelectableText(
|
|
link.group(2)!,
|
|
onTap: () => Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => ArticleView(
|
|
pageKey: link.group(1)!, name: link.group(2)!),
|
|
),
|
|
),
|
|
style: const TextStyle(
|
|
fontStyle: FontStyle.italic,
|
|
decoration: TextDecoration.underline),
|
|
)
|
|
],
|
|
),
|
|
);
|
|
out.add(
|
|
const SizedBox(
|
|
height: 5,
|
|
),
|
|
);
|
|
break;
|
|
case "ul":
|
|
out.add(_renderList(element));
|
|
out.add(
|
|
const SizedBox(
|
|
height: 10,
|
|
),
|
|
);
|
|
break;
|
|
case "div":
|
|
if (element.attributes["class"] != null &&
|
|
element.attributes["class"] == "pp_cautionbox") {
|
|
out.add(_renderWarning(element));
|
|
out.add(
|
|
const SizedBox(
|
|
height: 10,
|
|
),
|
|
);
|
|
} else if (element.id == "region_list") {
|
|
var inner = parse(
|
|
element.innerHtml.replaceAll(RegExp(r'<\/?span.+?>'), ""),
|
|
);
|
|
for (var e in inner.body!.children) {
|
|
if (e.localName == "figure") {
|
|
// render image
|
|
out.addAll(await _renderImageFigure(e));
|
|
out.add(
|
|
const SizedBox(
|
|
height: 5,
|
|
),
|
|
);
|
|
} else if (e.localName == "table") {
|
|
Color? boxColor;
|
|
var text = <InlineSpan>[];
|
|
for (var td in e
|
|
.getElementsByTagName("tr")
|
|
.first
|
|
.getElementsByTagName("td")) {
|
|
if (td.attributes["style"] != null) {
|
|
var colorMatch = RegExp(r'background-color:#(.+?);')
|
|
.firstMatch(td.attributes["style"]!);
|
|
boxColor = Color(int.parse("0xff${colorMatch!.group(1)}"));
|
|
} else {
|
|
text.addAll(_renderText(td.innerHtml));
|
|
}
|
|
}
|
|
out.add(
|
|
const SizedBox(
|
|
height: 10,
|
|
),
|
|
);
|
|
out.add(
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
if (boxColor != null)
|
|
Container(
|
|
color: boxColor,
|
|
width: 25,
|
|
height: 25,
|
|
),
|
|
const SizedBox(
|
|
width: 10,
|
|
),
|
|
Flexible(
|
|
child: SelectableText.rich(
|
|
TextSpan(children: text),
|
|
),
|
|
)
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
out.add(
|
|
const SizedBox(
|
|
height: 10,
|
|
),
|
|
);
|
|
} else if (element.classes.contains("mw-kartographer-container")) {
|
|
logger.i("Found map container");
|
|
var imgs = element
|
|
.getElementsByTagName("div")
|
|
.first
|
|
.getElementsByTagName("a")
|
|
.first
|
|
.getElementsByTagName("img");
|
|
if (imgs.isEmpty ||
|
|
(imgs.first.attributes["src"]
|
|
?.startsWith("https://maps.wikimedia.org") ??
|
|
false)) {
|
|
logger.i("Rendering with FlutterMap");
|
|
// render map using FlutterMap
|
|
var dataElement = element
|
|
.getElementsByClassName("thumbinner")
|
|
.first
|
|
.getElementsByClassName("mw-kartographer-map")
|
|
.first;
|
|
|
|
var pointsRaw = RegExp(
|
|
r"""<span class="noprint listing-coordinates".+?><span class="geo"><abbr class="latitude">(.+?)<\/abbr><abbr class="longitude">(.+?)<\/abbr>.+?}'>(\d+)<""")
|
|
.allMatches(document!); // find markers
|
|
logger.i("Found ${pointsRaw.length} markers");
|
|
if (pointsRaw.isEmpty) break;
|
|
var points = <Marker>[];
|
|
for (var point in pointsRaw) {
|
|
// convert to FlutterMap markers
|
|
if (point.groups([1, 2]).any((element) => element == null)) {
|
|
logger.w("No lat/lon found in pointer");
|
|
continue;
|
|
}
|
|
// assign marker color
|
|
var colorMatch = RegExp(r'background: #(.+?);')
|
|
.firstMatch(point.group(0)!)
|
|
?.group(1);
|
|
Color bubbleColor = (colorMatch == null)
|
|
? scheme.secondary
|
|
: Color(int.parse("0xff$colorMatch"));
|
|
|
|
points.add(
|
|
Marker(
|
|
builder: (c) => Container(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(4),
|
|
color: bubbleColor),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: Text(point.group(3) ?? "X"),
|
|
),
|
|
),
|
|
point: LatLng(
|
|
double.parse(point.group(1)!),
|
|
double.parse(point.group(2)!),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
out.add(const SizedBox(
|
|
height: 10,
|
|
));
|
|
out.add(
|
|
SizedBox(
|
|
width: MediaQuery.of(context).size.width * 0.8,
|
|
height: (MediaQuery.of(context).size.width * 0.8 / 4) * 3,
|
|
child: FlutterMap(
|
|
options: MapOptions(
|
|
center: LatLng(
|
|
double.parse(
|
|
dataElement.attributes["data-lat"] ?? "00.0"),
|
|
double.parse(
|
|
dataElement.attributes["data-lon"] ?? "00.0"),
|
|
),
|
|
zoom: double.parse(
|
|
dataElement.attributes["data-zoom"] ?? "1"),
|
|
),
|
|
nonRotatedChildren: [
|
|
RichAttributionWidget(
|
|
attributions: [
|
|
TextSourceAttribution(
|
|
"OpenStreetMap contributors",
|
|
onTap: () => launchUrlString(
|
|
"https://openstreetmap.org/copyright"),
|
|
)
|
|
],
|
|
)
|
|
],
|
|
children: [
|
|
TileLayer(
|
|
urlTemplate:
|
|
"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
userAgentPackageName: "cafe.caras.voyagehandbook",
|
|
),
|
|
MarkerLayer(
|
|
markers: points,
|
|
)
|
|
],
|
|
),
|
|
),
|
|
);
|
|
} else {
|
|
var img = imgs[0];
|
|
var cap = element.getElementsByClassName("thumbcaption")[0];
|
|
out.add(const SizedBox(
|
|
height: 10,
|
|
));
|
|
var offlineImage = await StorageAccess.getOfflineImage(img
|
|
.attributes["src"]!
|
|
.split('/')
|
|
.last
|
|
.replaceAll(RegExp(r"(?!\..+?)\?.+"), ""));
|
|
out.add(
|
|
SizedBox(
|
|
width: width * 0.8,
|
|
height: height * 0.3,
|
|
child: WidgetZoom(
|
|
zoomWidget: CachedNetworkImage(
|
|
imageUrl: img.attributes["src"]!,
|
|
errorWidget: (context, url, error) =>
|
|
(offlineImage != null)
|
|
? Image.file(offlineImage)
|
|
: Flexible(
|
|
child: Text(loc.imageError),
|
|
),
|
|
),
|
|
heroAnimationTag: 'tag',
|
|
),
|
|
),
|
|
);
|
|
out.add(
|
|
const SizedBox(
|
|
height: 3,
|
|
),
|
|
);
|
|
out.add(
|
|
SelectableText(
|
|
cap.text,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
);
|
|
out.add(
|
|
const SizedBox(
|
|
height: 10,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
element.remove();
|
|
}
|
|
|
|
out.add(
|
|
const SizedBox(
|
|
height: 5,
|
|
),
|
|
);
|
|
return out;
|
|
}
|
|
|
|
Future<List<Widget>> _renderImageFigure(dom.Element element) async {
|
|
var out = <Widget>[];
|
|
|
|
/// Image figure
|
|
var imgs = element.getElementsByTagName("img");
|
|
if (imgs.isEmpty) return [];
|
|
var img = imgs.first; // get image element
|
|
if (img.attributes["src"] == null || img.attributes["src"] == "") return [];
|
|
var figcap = element.getElementsByTagName("figcaption"); // get caption
|
|
String? caption;
|
|
if (figcap.isNotEmpty) {
|
|
caption = figcap.first.text; // TODO: handle links
|
|
}
|
|
var offlineImage = await StorageAccess.getOfflineImage(img
|
|
.attributes["src"]!
|
|
.split('/')
|
|
.last
|
|
.replaceAll(RegExp(r"(?!\..+?)\?.+"), ""));
|
|
|
|
out.add(const SizedBox(
|
|
height: 10,
|
|
));
|
|
out.add(
|
|
SizedBox(
|
|
// TODO: open wikimedia page?
|
|
width: width * 0.8,
|
|
height: height * 0.3,
|
|
child: WidgetZoom(
|
|
heroAnimationTag: 'img${Random().nextInt(999)}',
|
|
zoomWidget: CachedNetworkImage(
|
|
imageUrl: img.attributes["src"]!.replaceAll("//", "https://"),
|
|
progressIndicatorBuilder: (context, url, downloadProgress) =>
|
|
LinearProgressIndicator(value: downloadProgress.progress),
|
|
errorWidget: (context, url, error) => (offlineImage != null)
|
|
? Image.file(offlineImage)
|
|
: Flexible(
|
|
child: Text(loc.imageError),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
); // load image
|
|
if (caption != null) {
|
|
// Add caption when available
|
|
out.add(
|
|
const SizedBox(
|
|
height: 3,
|
|
),
|
|
);
|
|
out.add(
|
|
SelectableText(
|
|
caption,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
);
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
// used to render warning box
|
|
Widget _renderWarning(dom.Element element) {
|
|
var content = <InlineSpan>[];
|
|
for (var tr in element
|
|
.getElementsByTagName("table")
|
|
.first
|
|
.getElementsByTagName("tbody")
|
|
.first
|
|
.getElementsByTagName("tr")) {
|
|
for (var e in tr.getElementsByTagName("td")) {
|
|
// Get to table data
|
|
content.addAll(
|
|
_renderText(
|
|
e.innerHtml.replaceAll(RegExp(r'<img.+?\/?>'), ""),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return Warning(content: content);
|
|
}
|
|
|
|
/// Used to render basic text with links, bold and italic text
|
|
List<InlineSpan> _renderText(String innerHtml) {
|
|
var unescape = HtmlUnescape();
|
|
innerHtml = unescape.convert(innerHtml);
|
|
var content = <InlineSpan>[];
|
|
var input = innerHtml
|
|
.replaceAll(
|
|
RegExp(r"<(?!(?:\/b)|(?:\/i)|(?:\/a)|(?:b)|(?:i)|(?:a )).+?>",
|
|
dotAll: true),
|
|
"")
|
|
.replaceAll("\\n", "\n")
|
|
.replaceAll("<br>", "\n");
|
|
|
|
// first find links
|
|
var linkMatch = RegExp(
|
|
r'(?:<(?:i|b).*?>)*<a .+? href="(.+?)" .+?>(.+?)<\/a>(?:\/<(?:i|b)>)*',
|
|
dotAll: true)
|
|
.allMatches(input)
|
|
.toList();
|
|
var nonLink = input.split(RegExp(
|
|
r'(?:<(?:i|b).*?>)*<a .+? href="(.+?)" .+?>(.+?)<\/a>(?:\/<(?:i|b)>)*',
|
|
dotAll: true));
|
|
|
|
for (var match in linkMatch) {
|
|
// format text before link first
|
|
content.addAll(_formatText(nonLink[linkMatch.indexOf(match)]));
|
|
|
|
// create link
|
|
content.add(
|
|
WidgetSpan(
|
|
child: SelectableText(
|
|
match.group(2)!.replaceAll(
|
|
RegExp(r'<(?:(?:\/b)|(?:\/i)|(?:\/a)|(?:b)|(?:i)|(?:a )).*?>'),
|
|
""),
|
|
onTap: () {
|
|
if (match.group(0)!.contains('rel="mw:ExtLink')) {
|
|
// handle as an external link
|
|
launchUrlString(match.group(1)!,
|
|
mode: LaunchMode.externalApplication);
|
|
} else {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => ArticleView(
|
|
pageKey: match.group(1)!.replaceAll("./", ""),
|
|
name: match.group(2)!),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
style: TextStyle(
|
|
decoration: TextDecoration.underline,
|
|
fontWeight: (match.group(0)!.contains("<b") ||
|
|
match.group(0)!.contains("</b>"))
|
|
? FontWeight.bold
|
|
: null,
|
|
fontStyle: (match.group(0)!.contains("<i") ||
|
|
match.group(0)!.contains("</i>"))
|
|
? FontStyle.italic
|
|
: null),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
content.addAll(_formatText(nonLink.last)); // add last
|
|
return content;
|
|
}
|
|
|
|
/// Formats text (bold, italics)
|
|
List<InlineSpan> _formatText(String input) {
|
|
var content = <InlineSpan>[];
|
|
var noFormatting =
|
|
input.split(RegExp(r"(?:<b.*?>.+?<\/b>)|<i.*?>.+?<\/i>", dotAll: true));
|
|
var needToFormat =
|
|
RegExp(r"(?:<b.*?>.+?<\/b>)|<i.*?>.+?<\/i>", dotAll: true)
|
|
.allMatches(input)
|
|
.toList();
|
|
for (var s in needToFormat) {
|
|
content.add(TextSpan(
|
|
text: noFormatting[needToFormat.indexOf(s)].replaceAll(
|
|
RegExp(r'<(?:(?:\/b)|(?:\/i)|(?:\/a)|(?:b)|(?:i)|(?:a )).*?>'),
|
|
""))); // add text before styled
|
|
var raw = s.group(0)!;
|
|
content.add(
|
|
TextSpan(
|
|
text: RegExp(r">(?!<)(.+?)<").firstMatch(raw)!.group(1)!.replaceAll(
|
|
RegExp(r'<(?:(?:\/b)|(?:\/i)|(?:\/a)|(?:b)|(?:i)|(?:a )).*?>'),
|
|
""), // replace stray tags
|
|
style: TextStyle(
|
|
fontWeight: (raw.contains("<b") || raw.contains("</b>"))
|
|
? FontWeight.bold
|
|
: null,
|
|
fontStyle: (raw.contains("<i") || raw.contains("</i>"))
|
|
? FontStyle.italic
|
|
: null,
|
|
),
|
|
),
|
|
); // add styled
|
|
}
|
|
content.add(TextSpan(
|
|
text: noFormatting.last.replaceAll(
|
|
RegExp(r'<(?:(?:\/b)|(?:\/i)|(?:\/a)|(?:b)|(?:i)|(?:a )).*?>'),
|
|
""))); // add last
|
|
return content;
|
|
}
|
|
|
|
SingleChildScrollView _renderList(dom.Element element) {
|
|
var out = <Widget>[];
|
|
var i = 0;
|
|
for (var item in element.getElementsByTagName("li")) {
|
|
i++;
|
|
String? title = (item.getElementsByClassName("listing-name").isNotEmpty)
|
|
? item.getElementsByClassName("listing-name")[0].text
|
|
: null;
|
|
|
|
var colorMatch =
|
|
RegExp(r'background: #(.+?);').firstMatch(item.innerHtml)?.group(1);
|
|
Color bubbleColor = (colorMatch == null)
|
|
? scheme.secondary
|
|
: Color(int.parse("0xff$colorMatch"));
|
|
if (element.getElementsByClassName("geo").isNotEmpty) {
|
|
element.getElementsByClassName("geo").first.remove();
|
|
}
|
|
var rest =
|
|
((title != null) ? item.text.replaceAll(title, "") : item.text);
|
|
if (rest.startsWith("1")) {
|
|
rest = rest.substring(1); // TODO: figure out how to remove it better?
|
|
}
|
|
out.add(const SizedBox(
|
|
height: 5,
|
|
));
|
|
out.add(
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(8), color: bubbleColor),
|
|
width: 30,
|
|
height: 30,
|
|
child: SelectableText(
|
|
i.toString(),
|
|
style: TextStyle(
|
|
color: scheme.background,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 17),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
const SizedBox(
|
|
width: 10,
|
|
),
|
|
SelectableText.rich(
|
|
TextSpan(
|
|
children: [
|
|
if (title != null)
|
|
TextSpan(
|
|
text: title,
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
TextSpan(text: rest)
|
|
],
|
|
),
|
|
)
|
|
],
|
|
),
|
|
);
|
|
}
|
|
return SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
children: out,
|
|
),
|
|
);
|
|
}
|
|
}
|