317 lines
8.7 KiB
Dart
317 lines
8.7 KiB
Dart
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:html/parser.dart';
|
|
import 'package:html/dom.dart' as dom;
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'package:voyagehandbook/api/classes.dart';
|
|
import 'package:voyagehandbook/util/styles.dart';
|
|
import 'package:voyagehandbook/util/widgets/warning.dart';
|
|
import 'package:html_unescape/html_unescape_small.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 as published by
|
|
the Free Software Foundation, either version 3 of the License.
|
|
|
|
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"];
|
|
|
|
/// Used to create Widgets from raw HTML from WM API
|
|
ListView renderFromPageHTML(RawPage page, double height, double width) {
|
|
var out = <Widget>[
|
|
const SizedBox(
|
|
height: 10,
|
|
),
|
|
Text(
|
|
page.title,
|
|
textAlign: TextAlign.center,
|
|
style: PageStyles.h1,
|
|
),
|
|
const SizedBox(
|
|
height: 10,
|
|
),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Text("From"),
|
|
const SizedBox(
|
|
width: 5,
|
|
),
|
|
GestureDetector(
|
|
onTap: () => launchUrl(
|
|
Uri.parse("https://en.wikivoyage.org/wiki/${page.key}"),
|
|
mode: LaunchMode.externalApplication),
|
|
child: const Text(
|
|
"WikiVoyage",
|
|
style: TextStyle(
|
|
decoration: TextDecoration.underline,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Row(
|
|
children: [
|
|
const Text("under"),
|
|
const SizedBox(
|
|
width: 5,
|
|
),
|
|
GestureDetector(
|
|
onTap: () => launchUrl(Uri.parse(page.license.url),
|
|
mode: LaunchMode.externalApplication),
|
|
child: Text(
|
|
page.license.title,
|
|
style: const TextStyle(
|
|
decoration: TextDecoration.underline,
|
|
),
|
|
),
|
|
)
|
|
],
|
|
)
|
|
];
|
|
var document = parse(page.html);
|
|
var sections = document.body!.getElementsByTagName("section");
|
|
for (var sec in sections) {
|
|
if (sec.localName == "section") {
|
|
out.addAll(_renderSection(sec, height, width));
|
|
}
|
|
}
|
|
var l = ListView.builder(
|
|
padding: EdgeInsets.zero,
|
|
itemBuilder: (c, i) => out[i],
|
|
itemCount: out.length,
|
|
);
|
|
return l;
|
|
}
|
|
|
|
List<Widget> _renderSection(dom.Element sec, double height, double width) {
|
|
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(Text(
|
|
sectionTitle.text,
|
|
style: PageStyles.h2,
|
|
textAlign: TextAlign.center,
|
|
));
|
|
break;
|
|
case "h3":
|
|
out.add(
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
sectionTitle.text,
|
|
style: PageStyles.h3,
|
|
),
|
|
),
|
|
);
|
|
break;
|
|
case "h4":
|
|
out.add(
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(sectionTitle.text, style: PageStyles.h4),
|
|
),
|
|
);
|
|
break;
|
|
case "h5":
|
|
out.add(
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(sectionTitle.text, style: PageStyles.h5),
|
|
),
|
|
);
|
|
break;
|
|
default:
|
|
out.add(
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(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":
|
|
out.add(
|
|
RichText(
|
|
text: TextSpan(children: _renderText(element.innerHtml)),
|
|
textAlign: TextAlign.justify,
|
|
),
|
|
); // add paragraph spans as single rich text
|
|
out.add(
|
|
const SizedBox(
|
|
height: 5,
|
|
),
|
|
); // space paragraphs
|
|
break;
|
|
case "figure":
|
|
|
|
/// Image figure
|
|
var imgs = element.getElementsByTagName("img");
|
|
if (imgs.isEmpty) break;
|
|
var img = imgs.first; // get image element
|
|
if (img.attributes["src"] == null) break;
|
|
var figcap = element.getElementsByTagName("figcaption"); // get caption
|
|
String? caption;
|
|
if (figcap.isNotEmpty) {
|
|
caption = figcap.first.text; // TODO: handle links
|
|
}
|
|
out.add(const SizedBox(
|
|
height: 10,
|
|
));
|
|
out.add(
|
|
Container(
|
|
// TODO: add tap detector to open wikimedia page?
|
|
width: width * 0.8,
|
|
height: height * 0.3,
|
|
foregroundDecoration: BoxDecoration(
|
|
image: DecorationImage(
|
|
fit: BoxFit.fitHeight,
|
|
image: CachedNetworkImageProvider(
|
|
img.attributes["src"]!.replaceAll("//", "https://"),
|
|
),
|
|
),
|
|
),
|
|
//height: height * 0.3,
|
|
),
|
|
); // load image
|
|
if (caption != null) {
|
|
// Add caption when available
|
|
out.add(
|
|
const SizedBox(
|
|
height: 3,
|
|
),
|
|
);
|
|
out.add(Text(
|
|
caption,
|
|
textAlign: TextAlign.center,
|
|
));
|
|
}
|
|
out.add(
|
|
const SizedBox(
|
|
height: 10,
|
|
),
|
|
);
|
|
break;
|
|
case "section":
|
|
out.addAll(_renderSection(element, height, width));
|
|
break;
|
|
case "div":
|
|
if (element.attributes["class"] != null &&
|
|
element.attributes["class"] == "pp_cautionbox") {
|
|
out.add(_renderWarning(element));
|
|
out.add(
|
|
const SizedBox(
|
|
height: 10,
|
|
),
|
|
);
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
element.remove();
|
|
}
|
|
|
|
out.add(
|
|
const SizedBox(
|
|
height: 5,
|
|
),
|
|
);
|
|
return out;
|
|
}
|
|
|
|
// used to render warning box
|
|
Widget _renderWarning(dom.Element element) {
|
|
var content = <TextSpan>[];
|
|
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 bold and italic formatting
|
|
List<TextSpan> _renderText(String innerHtml) {
|
|
var unescape = HtmlUnescape();
|
|
innerHtml = unescape.convert(innerHtml);
|
|
var content = <TextSpan>[];
|
|
var input = innerHtml
|
|
.replaceAll(RegExp(r"<(?!(?:\/b)|(?:\/i)|b|i).+?>", dotAll: true), "")
|
|
.replaceAll("\\n", "\r");
|
|
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)])); // add text before styled
|
|
var raw = s.group(0)!;
|
|
content.add(
|
|
TextSpan(
|
|
text: RegExp(r">(?!<)(.+?)<").firstMatch(raw)!.group(1),
|
|
style: TextStyle(
|
|
fontWeight: (raw.contains("<b")) ? FontWeight.bold : null,
|
|
fontStyle: (raw.contains("<i")) ? FontStyle.italic : null,
|
|
),
|
|
),
|
|
); // add styled
|
|
}
|
|
content.add(TextSpan(text: noFormatting.last)); // add last
|
|
return content;
|
|
}
|