fix: switch to selectabletext and render links
This commit is contained in:
parent
e6989dc264
commit
2214eb68e7
5 changed files with 141 additions and 63 deletions
|
@ -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(
|
||||||
|
|
|
@ -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")
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -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,39 +56,35 @@ 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",
|
|
||||||
style: TextStyle(
|
|
||||||
decoration: TextDecoration.underline,
|
decoration: TextDecoration.underline,
|
||||||
fontWeight: FontWeight.bold,
|
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(
|
|
||||||
page.license.title,
|
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
decoration: TextDecoration.underline,
|
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(
|
|
||||||
link.group(2)!,
|
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontStyle: FontStyle.italic,
|
fontStyle: FontStyle.italic,
|
||||||
decoration: TextDecoration.underline),
|
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(
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -82,6 +82,16 @@ 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);
|
||||||
|
if (kDebugMode) {
|
||||||
|
_content = [
|
||||||
|
SizedBox(
|
||||||
|
width: MediaQuery.of(context).size.width * 0.9,
|
||||||
|
height: MediaQuery.of(context).size.height,
|
||||||
|
child: renderer
|
||||||
|
.renderFromPageHTML(await WikiApi.getRawPage(widget.pageKey)),
|
||||||
|
)
|
||||||
|
];
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
_content = [
|
_content = [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
|
@ -92,7 +102,6 @@ class _ArticleViewState extends State<ArticleView> {
|
||||||
)
|
)
|
||||||
];
|
];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) print(e);
|
|
||||||
_content = [
|
_content = [
|
||||||
const Text(
|
const Text(
|
||||||
"Error while rendering:",
|
"Error while rendering:",
|
||||||
|
@ -104,6 +113,7 @@ class _ArticleViewState extends State<ArticleView> {
|
||||||
Text(e.toString())
|
Text(e.toString())
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue