fix: switch to selectabletext and render links

This commit is contained in:
Matyáš Caras 2023-04-03 19:09:51 +02:00
parent e6989dc264
commit 2214eb68e7
5 changed files with 141 additions and 63 deletions

View file

@ -33,6 +33,7 @@ class MyApp extends StatelessWidget {
return DynamicColorBuilder( return DynamicColorBuilder(
builder: (lightDynamic, darkDynamic) => MaterialApp( builder: (lightDynamic, darkDynamic) => MaterialApp(
title: 'Voyage Handbook', title: 'Voyage Handbook',
debugShowCheckedModeBanner: false,
theme: ThemeData( theme: ThemeData(
useMaterial3: true, colorScheme: lightDynamic ?? lightColorScheme), useMaterial3: true, colorScheme: lightDynamic ?? lightColorScheme),
darkTheme: ThemeData( darkTheme: ThemeData(

View file

@ -34,7 +34,7 @@ Drawer genDrawer(int page, BuildContext context) => Drawer(
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
Text("Created by Matyáš Caras"), Text("Created by Matyáš Caras"),
Text("Thanks to WikiVoyage") Text("Not affiliated with WikiVoyage")
], ],
), ),
), ),

View file

@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:html/dom.dart' as dom; import 'package:html/dom.dart' as dom;
import 'package:url_launcher/url_launcher.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/api/classes.dart';
import 'package:voyagehandbook/util/styles.dart'; import 'package:voyagehandbook/util/styles.dart';
import 'package:voyagehandbook/util/widgets/warning.dart'; import 'package:voyagehandbook/util/widgets/warning.dart';
@ -44,7 +45,7 @@ class PageRenderer {
const SizedBox( const SizedBox(
height: 10, height: 10,
), ),
Text( SelectableText(
page.title, page.title,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: PageStyles.h1, style: PageStyles.h1,
@ -55,38 +56,34 @@ class PageRenderer {
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Text("From"), const SelectableText("From"),
const SizedBox( const SizedBox(
width: 5, width: 5,
), ),
GestureDetector( SelectableText(
"WikiVoyage",
onTap: () => launchUrl( onTap: () => launchUrl(
Uri.parse("https://en.wikivoyage.org/wiki/${page.key}"), Uri.parse("https://en.wikivoyage.org/wiki/${page.key}"),
mode: LaunchMode.externalApplication), mode: LaunchMode.externalApplication),
child: const Text( style: const TextStyle(
"WikiVoyage", decoration: TextDecoration.underline,
style: TextStyle( fontWeight: FontWeight.bold,
decoration: TextDecoration.underline,
fontWeight: FontWeight.bold,
),
), ),
), ),
], ],
), ),
Row( Row(
children: [ children: [
const Text("under"), const SelectableText("under"),
const SizedBox( const SizedBox(
width: 5, width: 5,
), ),
GestureDetector( SelectableText(
page.license.title,
onTap: () => launchUrl(Uri.parse(page.license.url), onTap: () => launchUrl(Uri.parse(page.license.url),
mode: LaunchMode.externalApplication), mode: LaunchMode.externalApplication),
child: Text( style: const TextStyle(
page.license.title, decoration: TextDecoration.underline,
style: const TextStyle(
decoration: TextDecoration.underline,
),
), ),
) )
], ],
@ -121,7 +118,7 @@ class PageRenderer {
height: 10, height: 10,
), ),
); );
out.add(Text( out.add(SelectableText(
sectionTitle.text, sectionTitle.text,
style: PageStyles.h2, style: PageStyles.h2,
textAlign: TextAlign.center, textAlign: TextAlign.center,
@ -131,7 +128,7 @@ class PageRenderer {
out.add( out.add(
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text( child: SelectableText(
sectionTitle.text, sectionTitle.text,
style: PageStyles.h3, style: PageStyles.h3,
), ),
@ -142,7 +139,7 @@ class PageRenderer {
out.add( out.add(
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text(sectionTitle.text, style: PageStyles.h4), child: SelectableText(sectionTitle.text, style: PageStyles.h4),
), ),
); );
break; break;
@ -150,7 +147,7 @@ class PageRenderer {
out.add( out.add(
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text(sectionTitle.text, style: PageStyles.h5), child: SelectableText(sectionTitle.text, style: PageStyles.h5),
), ),
); );
break; break;
@ -158,7 +155,7 @@ class PageRenderer {
out.add( out.add(
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text(sectionTitle.text), child: SelectableText(sectionTitle.text),
), ),
); );
break; break;
@ -190,8 +187,11 @@ class PageRenderer {
.getElementsByClassName("mw-kartographer-maplink") .getElementsByClassName("mw-kartographer-maplink")
.isNotEmpty) break; .isNotEmpty) break;
out.add( out.add(
RichText( SelectableText.rich(
text: TextSpan(children: _renderText(element.innerHtml)), TextSpan(
children: _renderText(element.innerHtml),
),
style: const TextStyle(height: 1.2),
textAlign: TextAlign.justify, textAlign: TextAlign.justify,
), ),
); // add paragraph spans as single rich text ); // add paragraph spans as single rich text
@ -222,26 +222,24 @@ class PageRenderer {
out.add( out.add(
Row( Row(
children: [ children: [
const Text( const SelectableText(
"See also:", "See also:",
style: TextStyle(), style: TextStyle(),
), ),
const SizedBox( const SizedBox(
width: 5, width: 5,
), ),
GestureDetector( SelectableText(
link.group(2)!,
onTap: () => Navigator.of(context).push( onTap: () => Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => ArticleView( builder: (_) => ArticleView(
pageKey: link.group(1)!, name: link.group(2)!), pageKey: link.group(1)!, name: link.group(2)!),
), ),
), ),
child: Text( style: const TextStyle(
link.group(2)!, fontStyle: FontStyle.italic,
style: const TextStyle( decoration: TextDecoration.underline),
fontStyle: FontStyle.italic,
decoration: TextDecoration.underline),
),
) )
], ],
), ),
@ -284,7 +282,7 @@ class PageRenderer {
); );
} else if (e.localName == "table") { } else if (e.localName == "table") {
Color? boxColor; Color? boxColor;
var text = <TextSpan>[]; var text = <InlineSpan>[];
for (var td in e for (var td in e
.getElementsByTagName("tr") .getElementsByTagName("tr")
.first .first
@ -316,8 +314,8 @@ class PageRenderer {
width: 10, width: 10,
), ),
Flexible( Flexible(
child: RichText( child: SelectableText.rich(
text: TextSpan(children: text), TextSpan(children: text),
), ),
) )
], ],
@ -360,7 +358,7 @@ class PageRenderer {
), ),
); );
out.add( out.add(
Text( SelectableText(
cap.text, cap.text,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@ -429,7 +427,7 @@ class PageRenderer {
), ),
); );
out.add( out.add(
Text( SelectableText(
caption, caption,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@ -441,7 +439,7 @@ class PageRenderer {
// used to render warning box // used to render warning box
Widget _renderWarning(dom.Element element) { Widget _renderWarning(dom.Element element) {
var content = <TextSpan>[]; var content = <InlineSpan>[];
for (var tr in element for (var tr in element
.getElementsByTagName("table") .getElementsByTagName("table")
.first .first
@ -460,15 +458,75 @@ class PageRenderer {
return Warning(content: content); return Warning(content: content);
} }
/// Used to render basic text with bold and italic formatting /// Used to render basic text with links, bold and italic text
List<TextSpan> _renderText(String innerHtml) { List<InlineSpan> _renderText(String innerHtml) {
var unescape = HtmlUnescape(); var unescape = HtmlUnescape();
innerHtml = unescape.convert(innerHtml); innerHtml = unescape.convert(innerHtml);
var content = <TextSpan>[]; var content = <InlineSpan>[];
var input = innerHtml var input = innerHtml
.replaceAll(RegExp(r"<(?!(?:\/b)|(?:\/i)|b|i).+?>", dotAll: true), "") .replaceAll(
RegExp(r"<(?!(?:\/b)|(?:\/i)|(?:\/a)|(?:b)|(?:i)|(?:a )).+?>",
dotAll: true),
"")
.replaceAll("\\n", "\n") .replaceAll("\\n", "\n")
.replaceAll("<br>", "\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)!,
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 = var noFormatting =
input.split(RegExp(r"(?:<b.*?>.+?<\/b>)|<i.*?>.+?<\/i>", dotAll: true)); input.split(RegExp(r"(?:<b.*?>.+?<\/b>)|<i.*?>.+?<\/i>", dotAll: true));
var needToFormat = var needToFormat =
@ -482,15 +540,24 @@ class PageRenderer {
var raw = s.group(0)!; var raw = s.group(0)!;
content.add( content.add(
TextSpan( TextSpan(
text: RegExp(r">(?!<)(.+?)<").firstMatch(raw)!.group(1), text: RegExp(r">(?!<)(.+?)<").firstMatch(raw)!.group(1)!.replaceAll(
RegExp(r'<(?:(?:\/b)|(?:\/i)|(?:\/a)|(?:b)|(?:i)|(?:a )).*?>'),
""), // replace stray tags
style: TextStyle( style: TextStyle(
fontWeight: (raw.contains("<b")) ? FontWeight.bold : null, fontWeight: (raw.contains("<b") || raw.contains("</b>"))
fontStyle: (raw.contains("<i")) ? FontStyle.italic : null, ? FontWeight.bold
: null,
fontStyle: (raw.contains("<i") || raw.contains("</i>"))
? FontStyle.italic
: null,
), ),
), ),
); // add styled ); // add styled
} }
content.add(TextSpan(text: noFormatting.last)); // add last content.add(TextSpan(
text: noFormatting.last.replaceAll(
RegExp(r'<(?:(?:\/b)|(?:\/i)|(?:\/a)|(?:b)|(?:i)|(?:a )).*?>'),
""))); // add last
return content; return content;
} }
@ -543,7 +610,7 @@ class PageRenderer {
borderRadius: BorderRadius.circular(8), color: bubbleColor), borderRadius: BorderRadius.circular(8), color: bubbleColor),
width: 30, width: 30,
height: 30, height: 30,
child: Text( child: SelectableText(
i.toString(), i.toString(),
style: TextStyle( style: TextStyle(
color: scheme.background, color: scheme.background,
@ -555,8 +622,8 @@ class PageRenderer {
const SizedBox( const SizedBox(
width: 10, width: 10,
), ),
RichText( SelectableText.rich(
text: TextSpan( TextSpan(
children: [ children: [
if (title != null) if (title != null)
TextSpan( TextSpan(

View file

@ -19,7 +19,7 @@ import 'package:flutter/material.dart';
class Warning extends StatelessWidget { class Warning extends StatelessWidget {
const Warning({super.key, required this.content}); const Warning({super.key, required this.content});
final List<TextSpan> content; final List<InlineSpan> content;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View file

@ -82,7 +82,7 @@ class _ArticleViewState extends State<ArticleView> {
MediaQuery.of(context).size.height, MediaQuery.of(context).size.height,
MediaQuery.of(context).size.width, MediaQuery.of(context).size.width,
context); context);
try { if (kDebugMode) {
_content = [ _content = [
SizedBox( SizedBox(
width: MediaQuery.of(context).size.width * 0.9, width: MediaQuery.of(context).size.width * 0.9,
@ -91,18 +91,28 @@ class _ArticleViewState extends State<ArticleView> {
.renderFromPageHTML(await WikiApi.getRawPage(widget.pageKey)), .renderFromPageHTML(await WikiApi.getRawPage(widget.pageKey)),
) )
]; ];
} catch (e) { } else {
if (kDebugMode) print(e); try {
_content = [ _content = [
const Text( SizedBox(
"Error while rendering:", width: MediaQuery.of(context).size.width * 0.9,
style: PageStyles.h1, height: MediaQuery.of(context).size.height,
), child: renderer
const SizedBox( .renderFromPageHTML(await WikiApi.getRawPage(widget.pageKey)),
height: 10, )
), ];
Text(e.toString()) } catch (e) {
]; _content = [
const Text(
"Error while rendering:",
style: PageStyles.h1,
),
const SizedBox(
height: 10,
),
Text(e.toString())
];
}
} }
setState(() {}); setState(() {});