From 54642131b020ca54c0b07f3f62d3e1066e0fb885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Tue, 28 Mar 2023 20:03:21 +0200 Subject: [PATCH] feat: add zoompinch and render as class --- lib/util/render.dart | 615 +++++++++++++++++++++++----------------- lib/views/pageview.dart | 8 +- pubspec.lock | 64 +++++ pubspec.yaml | 11 + 4 files changed, 428 insertions(+), 270 deletions(-) diff --git a/lib/util/render.dart b/lib/util/render.dart index 9d41aa2..f4d053e 100644 --- a/lib/util/render.dart +++ b/lib/util/render.dart @@ -7,6 +7,7 @@ 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'; +import 'package:zoom_pinch_overlay/zoom_pinch_overlay.dart'; /* Voyage Handbook - The open-source WikiVoyage reader @@ -24,294 +25,376 @@ import 'package:html_unescape/html_unescape_small.dart'; You should have received a copy of the GNU General Public License along with this program. If not, see . */ - final _ignoredTags = ["style", "script"]; -/// Used to create Widgets from raw HTML from WM API -ListView renderFromPageHTML(RawPage page, double height, double width) { - var out = [ - 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; -} +class PageRenderer { + final ColorScheme scheme; + final double height; + final double width; -List _renderSection(dom.Element sec, double height, double width) { - var out = []; - // 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( + PageRenderer(this.scheme, this.height, this.width); + + /// Used to create Widgets from raw HTML from WM API + ListView renderFromPageHTML(RawPage page) { + var out = [ + 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( - height: 10, + width: 5, ), - ); - 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://"), - ), + 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, ), ), - //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( + ], + ), + Row( + children: [ + const Text("under"), const SizedBox( - height: 10, + width: 5, ), - ); - 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)); + 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)); + } + } + var l = ListView.builder( + padding: EdgeInsets.zero, + itemBuilder: (c, i) => out[i], + itemCount: out.length, + ); + return l; + } + + List _renderSection(dom.Element sec) { + var out = []; + // 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, ), ); - } - break; - default: - break; + 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, + // ), + // ); } - element.remove(); + + // 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": + out.addAll(_renderImageFigure(element)); + out.add( + const SizedBox( + height: 10, + ), + ); + break; + case "section": + out.addAll(_renderSection(element)); + 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(_renderImageFigure(e)); + out.add( + const SizedBox( + height: 5, + ), + ); + } else if (e.localName == "table") { + Color? boxColor; + var text = []; + 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: RichText( + text: TextSpan(children: text), + ), + ) + ], + ), + ); + } + } + out.add( + const SizedBox( + height: 10, + ), + ); + } + break; + default: + break; + } + element.remove(); + } + + out.add( + const SizedBox( + height: 5, + ), + ); + return out; } - out.add( - const SizedBox( - height: 5, - ), - ); - return out; -} + List _renderImageFigure(dom.Element element) { + var out = []; -// used to render warning box -Widget _renderWarning(dom.Element element) { - var content = []; - 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''), ""), + /// 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 + } + out.add(const SizedBox( + height: 10, + )); + out.add( + SizedBox( + // TODO: open wikimedia page? + width: width * 0.8, + height: height * 0.3, + child: ZoomOverlay( + child: CachedNetworkImage( + imageUrl: img.attributes["src"]!.replaceAll("//", "https://"), + progressIndicatorBuilder: (context, url, downloadProgress) => + LinearProgressIndicator(value: downloadProgress.progress), + errorWidget: (context, url, error) => Icon( + Icons.error, + color: scheme.error, + ), + ), + ), + ), + ); // load image + if (caption != null) { + // Add caption when available + out.add( + const SizedBox( + height: 3, + ), + ); + out.add( + Text( + caption, + textAlign: TextAlign.center, ), ); } - } - return Warning(content: content); -} -/// Used to render basic text with bold and italic formatting -List _renderText(String innerHtml) { - var unescape = HtmlUnescape(); - innerHtml = unescape.convert(innerHtml); - var content = []; - var input = innerHtml - .replaceAll(RegExp(r"<(?!(?:\/b)|(?:\/i)|b|i).+?>", dotAll: true), "") - .replaceAll("\\n", "\r"); - var noFormatting = - input.split(RegExp(r"(?:.+?<\/b>)|.+?<\/i>", dotAll: true)); - var needToFormat = RegExp(r"(?:.+?<\/b>)|.+?<\/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("[]; + 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''), ""), + ), + ); + } + } + return Warning(content: content); + } + + /// Used to render basic text with bold and italic formatting + List _renderText(String innerHtml) { + var unescape = HtmlUnescape(); + innerHtml = unescape.convert(innerHtml); + var content = []; + var input = innerHtml + .replaceAll(RegExp(r"<(?!(?:\/b)|(?:\/i)|b|i).+?>", dotAll: true), "") + .replaceAll("\\n", "\n") + .replaceAll("
", "\n"); + var noFormatting = + input.split(RegExp(r"(?:.+?<\/b>)|.+?<\/i>", dotAll: true)); + var needToFormat = + RegExp(r"(?:.+?<\/b>)|.+?<\/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(" { } void loadPage() async { + var renderer = PageRenderer(Theme.of(context).colorScheme, + MediaQuery.of(context).size.height, MediaQuery.of(context).size.width); try { _content = [ SizedBox( width: MediaQuery.of(context).size.width * 0.9, height: MediaQuery.of(context).size.height, - child: renderFromPageHTML( - await WikiApi.getRawPage(widget.pageKey), - MediaQuery.of(context).size.height, - MediaQuery.of(context).size.width), + child: renderer + .renderFromPageHTML(await WikiApi.getRawPage(widget.pageKey)), ) ]; } catch (e) { diff --git a/pubspec.lock b/pubspec.lock index 697a398..3615281 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.8.0" + archive: + dependency: transitive + description: + name: archive + sha256: d6347d54a2d8028e0437e3c099f66fdb8ae02c4720c1e7534c9f24c10351f85d + url: "https://pub.dev" + source: hosted + version: "3.3.6" args: dependency: transitive description: @@ -145,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: "66f86e916d285c1a93d3b79587d94bd71984a66aac4ff74e524cfa7877f1395c" + url: "https://pub.dev" + source: hosted + version: "0.3.5" clock: dependency: transitive description: @@ -278,6 +294,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "02dcaf49d405f652b7160e882bacfc02cb497041bb2eab2a49b1c393cf9aac12" + url: "https://pub.dev" + source: hosted + version: "0.12.0" flutter_lints: dependency: "direct dev" description: @@ -360,6 +384,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + image: + dependency: transitive + description: + name: image + sha256: "483a389d6ccb292b570c31b3a193779b1b0178e7eb571986d9a49904b6861227" + url: "https://pub.dev" + source: hosted + version: "4.0.15" io: dependency: transitive description: @@ -520,6 +552,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.11.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "49392a45ced973e8d94a85fdb21293fbb40ba805fc49f2965101ae748a3683b4" + url: "https://pub.dev" + source: hosted + version: "5.1.0" platform: dependency: transitive description: @@ -536,6 +576,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: c3120a968135aead39699267f4c74bc9a08e4e909e86bc1b0af5bfd78691123c + url: "https://pub.dev" + source: hosted + version: "3.7.2" pool: dependency: transitive description: @@ -877,6 +925,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + xml: + dependency: transitive + description: + name: xml + sha256: "979ee37d622dec6365e2efa4d906c37470995871fe9ae080d967e192d88286b5" + url: "https://pub.dev" + source: hosted + version: "6.2.2" yaml: dependency: transitive description: @@ -885,6 +941,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" + zoom_pinch_overlay: + dependency: "direct main" + description: + name: zoom_pinch_overlay + sha256: cad0aef0127953e3a2ad65aa51660e9c86fa11906e286297f9a70aab69163f64 + url: "https://pub.dev" + source: hosted + version: "1.4.1+3" sdks: dart: ">=2.19.4 <3.0.0" flutter: ">=3.4.0-17.0.pre" diff --git a/pubspec.yaml b/pubspec.yaml index 48fd6e5..861f667 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,10 +45,12 @@ dependencies: url_launcher: ^6.1.10 cached_network_image: ^3.2.3 html_unescape: ^2.0.0 + zoom_pinch_overlay: ^1.4.1+3 dev_dependencies: flutter_test: sdk: flutter + flutter_launcher_icons: ^0.12.0 # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is @@ -99,3 +101,12 @@ flutter: # # For details regarding fonts from package dependencies, # see https://flutter.dev/custom-fonts/#from-packages + + +flutter_icons: + android: "launcher_icon" + ios: true + image_path: "assets/icon.png" + min_sdk_android: 21 # android min sdk min:16, default 21 + adaptive_icon_background: "#FF9C432E" + remove_alpha_ios: true \ No newline at end of file