From 33839b6b72f2d3329a10a95f30b5b727a1ee8e67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Fri, 7 Apr 2023 13:02:40 +0200 Subject: [PATCH] feat: complete download --- README.md | 8 ++- lib/api/classes.dart | 11 ++-- lib/api/wikimedia.dart | 9 ++- lib/l10n/app_cs.arb | 10 ++- lib/l10n/app_en.arb | 19 +++++- lib/util/drawer.dart | 2 +- lib/util/render.dart | 71 ++++++++++++++------- lib/util/storage.dart | 76 +++++++++++++++++----- lib/views/downloads.dart | 28 ++++++--- lib/views/pageview.dart | 47 ++++++++++---- lib/views/search.dart | 132 ++++++++++++++++++++++++++++++++++++--- 11 files changed, 335 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index f73ae98..0705a02 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/api/classes.dart b/lib/api/classes.dart index cded8e8..55dcd74 100644 --- a/lib/api/classes.dart +++ b/lib/api/classes.dart @@ -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 json) => _$RawPageFromJson(json); diff --git a/lib/api/wikimedia.dart b/lib/api/wikimedia.dart index a8e18d2..00fbe6f 100644 --- a/lib/api/wikimedia.dart +++ b/lib/api/wikimedia.dart @@ -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.generate( + var list = List.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 getRawPage(String key) async { diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index c84c222..856d1c5 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -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í." } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 19177d5..cc1db28 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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." } \ No newline at end of file diff --git a/lib/util/drawer.dart b/lib/util/drawer.dart index c5ab160..9fe300a 100644 --- a/lib/util/drawer.dart +++ b/lib/util/drawer.dart @@ -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( diff --git a/lib/util/render.dart b/lib/util/render.dart index a60adec..0031198 100644 --- a/lib/util/render.dart +++ b/lib/util/render.dart @@ -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 renderFromPageHTML(RawPage page) async { var out = [ const SizedBox( height: 10, @@ -85,14 +86,17 @@ class PageRenderer { children: [ SelectableText(loc.attrUnder), const SizedBox( - width: 5, + width: 2, ), - SelectableText( - page.license.title, - onTap: () => launchUrl(Uri.parse(page.license.url), - mode: LaunchMode.externalApplication), - style: const TextStyle( - decoration: TextDecoration.underline, + 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 _renderSection(dom.Element sec) { + Future> _renderSection(dom.Element sec) async { var out = []; // 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 _renderImageFigure(dom.Element element) { + Future> _renderImageFigure(dom.Element element) async { var out = []; /// 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,10 +444,11 @@ 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( diff --git a/lib/util/storage.dart b/lib/util/storage.dart index 2adae75..7ebeb13 100644 --- a/lib/util/storage.dart +++ b/lib/util/storage.dart @@ -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 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 downloadArticle(String pageKey, String pageTitle) async { - var files = - Directory("${(await getApplicationDocumentsDirectory()).path}/offline"); - var offlinePage = File("${files.path}/$pageKey"); - var page = parse(await WikiApi.getRawPage(pageKey)) - .body! - .getElementsByTagName("section"); - var out = ""; - for (var el in page) { - out += el.outerHtml; - var imgMatch = RegExp(r'').allMatches(el.innerHtml); + try { + var files = Directory( + "${(await getApplicationDocumentsDirectory()).path}/offline"); + if (!files.existsSync()) files.createSync(); + var offlinePage = File("${files.path}/$pageKey"); + var raw = await WikiApi.getRawPage(pageKey); + var page = parse(raw.html); + var out = ""; + var sections = page.body!.children + .where((element) => element.localName == "section"); + for (var el in sections) { + out += el.outerHtml; + } + out += ""; + var imgMatch = RegExp(r'') + .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"); + } + offlinePage.writeAsStringSync(jsonEncode(raw.toJson()), + mode: FileMode.writeOnly); + } catch (e) { + if (kDebugMode) { + print(e); + } + return Future.error(e); } - if (page.isEmpty) return Future.error("No sections to save"); - offlinePage.writeAsStringSync( - jsonEncode({"title": pageTitle, "key": pageKey, "content": out}), - mode: FileMode.writeOnly); + } + + static Future 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 getOfflineImage(String key) async { + var files = Directory( + "${(await getApplicationDocumentsDirectory()).path}/offline/assets"); + return File("${files.path}/$key"); } } diff --git a/lib/views/downloads.dart b/lib/views/downloads.dart index 4c5befa..06fea98 100644 --- a/lib/views/downloads.dart +++ b/lib/views/downloads.dart @@ -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 { title: Text(AppLocalizations.of(context)!.downloadsTitle), ), drawer: genDrawer(3, context), - body: SizedBox( - height: MediaQuery.of(context).size.height, - width: MediaQuery.of(context).size.width * 0.9, - child: Center( + body: Center( + child: SizedBox( + height: MediaQuery.of(context).size.height, + width: MediaQuery.of(context).size.width * 0.9, 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 { 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 { ), ), ), - 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, + ), ), ), ), diff --git a/lib/views/pageview.dart b/lib/views/pageview.dart index d0806f5..e793a2d 100644 --- a/lib/views/pageview.dart +++ b/lib/views/pageview.dart @@ -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 { } 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) { + + 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)), ) ]; - } else { - try { - _content = [ - SizedBox( - width: MediaQuery.of(context).size.width * 0.9, - height: MediaQuery.of(context).size.height, - child: renderer - .renderFromPageHTML(await WikiApi.getRawPage(widget.pageKey)), - ) - ]; - } catch (e) { + } 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, diff --git a/lib/views/search.dart b/lib/views/search.dart index 63df7a4..5e5d4d1 100644 --- a/lib/views/search.dart +++ b/lib/views/search.dart @@ -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 { _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 { padding: const EdgeInsets.all(8.0), child: Column( children: [ - Text( - _searchResults[index].title, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: - 16), // TODO: responsive sizing - ), + Row(children: [ + Text( + _searchResults[index].title, + style: const TextStyle( + 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, ),