feat: complete download

This commit is contained in:
Matyáš Caras 2023-04-07 13:02:40 +02:00
parent f91ef2e68f
commit 33839b6b72
11 changed files with 335 additions and 78 deletions

View file

@ -39,7 +39,13 @@ cd voyagehandbook
./flutterw doctor
```
3. Run on your connected device
3. Generate locales
```sh
./flutterw gen-l10n
```
4. Run on your connected device
```sh
./flutterw run

View file

@ -25,8 +25,10 @@ class SearchResponse {
final String title;
final String excerpt;
final String? description;
const SearchResponse(
this.id, this.key, this.title, this.excerpt, this.description);
@JsonKey(includeFromJson: false, includeToJson: false)
bool downloaded;
SearchResponse(this.id, this.key, this.title, this.excerpt, this.description,
{this.downloaded = false});
/// Connect the generated function to the `fromJson`
/// factory.
@ -56,11 +58,10 @@ class RawPage {
final String title;
@JsonKey(fromJson: _editedFromJson, toJson: _editedToJson, name: "latest")
final String edited;
final String html;
String html;
final LicenseAttribution license;
const RawPage(
this.id, this.key, this.title, this.edited, this.html, this.license);
RawPage(this.id, this.key, this.title, this.edited, this.html, this.license);
factory RawPage.fromJson(Map<String, dynamic> json) =>
_$RawPageFromJson(json);

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:voyagehandbook/api/classes.dart';
import 'package:voyagehandbook/util/storage.dart';
/*
Voyage Handbook - The open-source WikiVoyage reader
@ -39,8 +40,14 @@ class WikiApi {
var r = await _getRequest("search/page?q=$q&limit=$limit");
if (r.statusCode! > 399) return Future.error("API error ${r.statusCode}");
var json = jsonDecode(r.data)["pages"];
return List<SearchResponse>.generate(
var list = List<SearchResponse>.generate(
json.length, (index) => SearchResponse.fromJson(json[index]));
for (var item in list) {
if (await StorageAccess.isDownloaded(item.key)) {
list[list.indexOf(item)].downloaded = true;
}
}
return list;
}
static Future<RawPage> getRawPage(String key) async {

View file

@ -14,5 +14,13 @@
"about":"O Aplikcai",
"sourceCode":"Zdrojový kód",
"creditsCreatedBy":"Vytvořil Matyáš Caras",
"creditsAffiliation":"Není nijak spojeno s WikiVoyage"
"creditsAffiliation":"Není nijak spojeno s WikiVoyage",
"yes":"Ano",
"no":"Ne",
"ok":"Ok",
"downloading":"Stahuji, vyčkejte prosím...",
"downloadComplete":"Stahování dokončeno, najdete ho v sekci 'Stažené'.",
"error":"Nastala chyba",
"offlineError":"Vypadá to, že jste offline a tento článek nemáte stažený.",
"imageError":"Chyba při stahování obrázku, zkontrolujte připojení."
}

View file

@ -51,5 +51,22 @@
"about":"About",
"sourceCode":"Source code",
"creditsCreatedBy":"Created by Matyáš Caras",
"creditsAffiliation":"Not affiliated with WikiVoyage"
"creditsAffiliation":"Not affiliated with WikiVoyage",
"yes":"Yes",
"no":"No",
"ok":"Ok",
"downloading":"Downloading, please wait...",
"@downloading":{
"description":"Shown in a dialog when downloading something"
},
"downloadComplete":"Download complete, you will find it in the 'Downloads' section.",
"error":"An error occured",
"@error":{
"description":"Generic error dialog title"
},
"offlineError":"You seem to be offline and the curren article is not in your downloads.",
"@offlineError":{
"description":"Shown when trying to render a page offline"
},
"imageError":"Error downloading image, check network connection."
}

View file

@ -68,7 +68,7 @@ Drawer genDrawer(int page, BuildContext context) => Drawer(
ListTile(
selected: page == 3,
title: Text(AppLocalizations.of(context)!.downloadsTitle),
leading: const Icon(Icons.search),
leading: const Icon(Icons.download),
onTap: () => page == 3
? Navigator.of(context).pop()
: Navigator.of(context).push(

View file

@ -7,6 +7,7 @@ import 'package:html/dom.dart' as dom;
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';
@ -49,7 +50,7 @@ class PageRenderer {
{this.offline = false});
/// Used to create Widgets from raw HTML from WM API
ListView renderFromPageHTML(RawPage page) {
Future<ListView> renderFromPageHTML(RawPage page) async {
var out = <Widget>[
const SizedBox(
height: 10,
@ -85,15 +86,18 @@ class PageRenderer {
children: [
SelectableText(loc.attrUnder),
const SizedBox(
width: 5,
width: 2,
),
SelectableText(
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,
),
),
)
],
)
@ -103,7 +107,7 @@ class PageRenderer {
var sections = document.body!.getElementsByTagName("section");
for (var sec in sections) {
if (sec.localName == "section") {
if (!offline) out.addAll(_renderSection(sec));
if (!offline) out.addAll(await _renderSection(sec));
outHtml += sec.outerHtml;
}
}
@ -115,7 +119,7 @@ class PageRenderer {
return l;
}
List<Widget> _renderSection(dom.Element sec) {
Future<List<Widget>> _renderSection(dom.Element sec) async {
var out = <Widget>[];
// Get Section Title
var headings = sec.children.where(
@ -213,7 +217,7 @@ class PageRenderer {
); // space paragraphs
break;
case "figure":
out.addAll(_renderImageFigure(element));
out.addAll(await _renderImageFigure(element));
out.add(
const SizedBox(
height: 10,
@ -221,7 +225,7 @@ class PageRenderer {
);
break;
case "section":
out.addAll(_renderSection(element));
out.addAll(await _renderSection(element));
break;
case "dl":
var dd = element.getElementsByTagName("dd").first;
@ -285,7 +289,7 @@ class PageRenderer {
for (var e in inner.body!.children) {
if (e.localName == "figure") {
// render image
out.addAll(_renderImageFigure(e));
out.addAll(await _renderImageFigure(e));
out.add(
const SizedBox(
height: 5,
@ -352,13 +356,24 @@ class PageRenderer {
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"]!),
zoomWidget: CachedNetworkImage(
imageUrl: img.attributes["src"]!,
errorWidget: (context, url, error) => (offlineImage != null)
? Image.file(offlineImage)
: Flexible(
child: Text(loc.imageError),
),
),
heroAnimationTag: 'tag',
),
),
@ -396,7 +411,7 @@ class PageRenderer {
return out;
}
List<Widget> _renderImageFigure(dom.Element element) {
Future<List<Widget>> _renderImageFigure(dom.Element element) async {
var out = <Widget>[];
/// Image figure
@ -409,6 +424,12 @@ class PageRenderer {
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,
));
@ -423,9 +444,10 @@ class PageRenderer {
imageUrl: img.attributes["src"]!.replaceAll("//", "https://"),
progressIndicatorBuilder: (context, url, downloadProgress) =>
LinearProgressIndicator(value: downloadProgress.progress),
errorWidget: (context, url, error) => Icon(
Icons.error,
color: scheme.error,
errorWidget: (context, url, error) => (offlineImage != null)
? Image.file(offlineImage)
: Flexible(
child: Text(loc.imageError),
),
),
),
@ -501,7 +523,9 @@ class PageRenderer {
content.add(
WidgetSpan(
child: SelectableText(
match.group(2)!,
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
@ -547,8 +571,9 @@ class PageRenderer {
.toList();
for (var s in needToFormat) {
content.add(TextSpan(
text:
noFormatting[needToFormat.indexOf(s)])); // add text before styled
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(

View file

@ -2,8 +2,10 @@ import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:html/parser.dart';
import 'package:path_provider/path_provider.dart';
import 'package:voyagehandbook/api/classes.dart';
import 'package:voyagehandbook/api/wikimedia.dart';
/*
@ -89,26 +91,36 @@ class StorageAccess {
static Future<bool> isDownloaded(String pageKey) async {
var files =
Directory("${(await getApplicationDocumentsDirectory()).path}/recent");
Directory("${(await getApplicationDocumentsDirectory()).path}/offline");
if (!files.existsSync()) files.createSync();
var offlinePage = File("${files.path}/$pageKey");
return offlinePage.existsSync();
}
static Future<void> downloadArticle(String pageKey, String pageTitle) async {
var files =
Directory("${(await getApplicationDocumentsDirectory()).path}/offline");
try {
var files = Directory(
"${(await getApplicationDocumentsDirectory()).path}/offline");
if (!files.existsSync()) files.createSync();
var offlinePage = File("${files.path}/$pageKey");
var page = parse(await WikiApi.getRawPage(pageKey))
.body!
.getElementsByTagName("section");
var raw = await WikiApi.getRawPage(pageKey);
var page = parse(raw.html);
var out = "<html><head></head><body>";
for (var el in page) {
var sections = page.body!.children
.where((element) => element.localName == "section");
for (var el in sections) {
out += el.outerHtml;
var imgMatch = RegExp(r'<img src="(.+?)">').allMatches(el.innerHtml);
}
out += "</body></html>";
var imgMatch = RegExp(r'<img.+?src="(.+?)".+?>')
.allMatches(page.body!.innerHtml); // TODO: ask to overwrite
if (imgMatch.isNotEmpty) {
// download images offline
for (var match in imgMatch) {
var src = match.group(1)!;
if (!src.startsWith("https://")) {
src = src.replaceAll("//", "https://");
}
var r = await Dio().get(
src,
options: Options(
@ -119,16 +131,48 @@ class StorageAccess {
},
),
);
var img = File("${files.path}/${src.split('/').last}");
var assetDir = Directory("${files.path}/assets");
if (!assetDir.existsSync()) assetDir.createSync();
var img = File(
"${assetDir.path}/${src.split('/').last.replaceAll(RegExp(r"(?!\..+?)\?.+"), "")}");
print(img.path);
var openImg = img.openSync(mode: FileMode.write);
openImg.writeFromSync(r.data);
}
}
raw.html = out;
if (sections.isEmpty) {
return Future.error("No sections to save");
}
if (page.isEmpty) return Future.error("No sections to save");
offlinePage.writeAsStringSync(
jsonEncode({"title": pageTitle, "key": pageKey, "content": out}),
offlinePage.writeAsStringSync(jsonEncode(raw.toJson()),
mode: FileMode.writeOnly);
} catch (e) {
if (kDebugMode) {
print(e);
}
return Future.error(e);
}
}
static Future<RawPage?> getOfflinePage(String pageKey) async {
var files =
Directory("${(await getApplicationDocumentsDirectory()).path}/offline");
if (!files.existsSync()) return null;
var offlinePage = File("${files.path}/$pageKey");
if (!offlinePage.existsSync()) return null;
try {
return RawPage.fromJson(jsonDecode(offlinePage.readAsStringSync()));
} catch (e) {
if (kDebugMode) {
print(e);
}
return null;
}
}
static Future<File?> getOfflineImage(String key) async {
var files = Directory(
"${(await getApplicationDocumentsDirectory()).path}/offline/assets");
return File("${files.path}/$key");
}
}

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:voyagehandbook/util/drawer.dart';
import 'package:voyagehandbook/util/storage.dart';
import 'package:voyagehandbook/util/styles.dart';
import 'package:voyagehandbook/views/pageview.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
@ -29,16 +30,21 @@ class _DownloadsViewState extends State<DownloadsView> {
title: Text(AppLocalizations.of(context)!.downloadsTitle),
),
drawer: genDrawer(3, context),
body: SizedBox(
body: Center(
child: SizedBox(
height: MediaQuery.of(context).size.height,
width: MediaQuery.of(context).size.width * 0.9,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: _isLoading
? [const CircularProgressIndicator()]
: _content.isEmpty
? [Text(AppLocalizations.of(context)!.noDownloads)]
? [
Text(
AppLocalizations.of(context)!.noDownloads,
textAlign: TextAlign.center,
)
]
: _content,
),
),
@ -52,7 +58,7 @@ class _DownloadsViewState extends State<DownloadsView> {
files.length,
(index) => SizedBox(
width: MediaQuery.of(context).size.width * 0.9,
height: MediaQuery.of(context).size.height * 0.15,
height: MediaQuery.of(context).size.height * 0.1,
child: InkWell(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
@ -62,9 +68,13 @@ class _DownloadsViewState extends State<DownloadsView> {
),
),
),
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(files[index]["title"]),
child: Align(
alignment: Alignment.center,
child: Text(
files[index]["title"],
textAlign: TextAlign.center,
style: PageStyles.h1,
),
),
),
),

View file

@ -1,3 +1,4 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:voyagehandbook/api/wikimedia.dart';
@ -78,32 +79,52 @@ class _ArticleViewState extends State<ArticleView> {
}
void loadPage() async {
if (!mounted) return;
var renderer = PageRenderer(
Theme.of(context).colorScheme,
MediaQuery.of(context).size.height,
MediaQuery.of(context).size.width,
context,
AppLocalizations.of(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 {
_content = [
SizedBox(
width: MediaQuery.of(context).size.width * 0.9,
height: MediaQuery.of(context).size.height,
child: renderer
child: await renderer
.renderFromPageHTML(await WikiApi.getRawPage(widget.pageKey)),
)
];
} catch (e) {
if (e.toString().contains("Failed host lookup")) {
// user is offline
var offline = await StorageAccess.getOfflinePage(widget.pageKey);
if (offline == null) {
// Not downloaded, show error
if (!mounted) return;
_content = [
Text(
AppLocalizations.of(context)!.renderError,
style: PageStyles.h1,
),
const SizedBox(
height: 10,
),
Text(AppLocalizations.of(context)!.offlineError)
];
} else {
// Render offline version
if (!mounted) return;
_content = [
SizedBox(
width: MediaQuery.of(context).size.width * 0.9,
height: MediaQuery.of(context).size.height,
child: await renderer.renderFromPageHTML(offline),
)
];
}
} else {
_content = [
Text(
AppLocalizations.of(context)!.renderError,

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:voyagehandbook/api/classes.dart';
import 'package:voyagehandbook/api/wikimedia.dart';
import 'package:voyagehandbook/util/storage.dart';
import 'package:voyagehandbook/views/pageview.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
@ -145,6 +146,109 @@ class _SearchViewState extends State<SearchView> {
_searchResults[index]
.title),
),
actions: [
TextButton(
onPressed: () async {
Navigator.of(context).pop();
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => Dialog(
child: SizedBox(
height: 100,
child: Row(
children: [
const Padding(
padding:
EdgeInsets
.all(
10),
child:
CircularProgressIndicator(),
),
Text(AppLocalizations
.of(context)!
.downloading)
],
),
),
),
);
try {
await StorageAccess
.downloadArticle(
_searchResults[
index]
.key,
_searchResults[
index]
.title);
if (!mounted) return;
Navigator.of(context)
.pop();
ScaffoldMessenger.of(
context)
.clearSnackBars();
ScaffoldMessenger.of(
context)
.showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(
context)!
.downloadComplete),
duration:
const Duration(
seconds: 4),
),
);
} catch (e) {
Navigator.of(context)
.pop();
showDialog(
context: context,
builder: (_) =>
AlertDialog(
title: Text(
AppLocalizations.of(
context)!
.error),
content:
SingleChildScrollView(
child: Text(
e.toString()),
),
actions: [
TextButton(
onPressed: () =>
Navigator.of(
context)
.pop(),
child: Text(
AppLocalizations.of(
context)!
.ok),
)
],
),
);
}
},
child: Text(
AppLocalizations.of(
context)!
.yes),
),
TextButton(
onPressed: () =>
Navigator.of(context)
.pop(),
child: Text(
AppLocalizations.of(
context)!
.no),
),
],
),
);
},
@ -164,13 +268,27 @@ class _SearchViewState extends State<SearchView> {
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
Row(children: [
Text(
_searchResults[index].title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontWeight:
FontWeight.bold,
fontSize:
16), // TODO: responsive sizing
),
if (_searchResults[index]
.downloaded)
const SizedBox(
width: 15,
),
if (_searchResults[index]
.downloaded)
const Icon(
Icons.download,
size: 15,
),
]),
const SizedBox(
height: 10,
),