diff --git a/README.md b/README.md index 33ec40d..0705a02 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,17 @@ # Voyage Handbook Access [WikiVoyage](https://en.wikivoyage.org) conveniently from your phone! +## Install +- [Google Play](https://play.google.com/store/apps/details?id=cafe.caras.voyagehandbook) +- F-Droid (soon) + ## Roadmap *(In no particular order)* - [ ] Render articles *completely* using native widgets - [ ] Navigation in articles by heading - [ ] Settings for reader -- [ ] Translate UI +- [X] Translate UI - [ ] Support different WikiVoyage languages - [ ] Bookmark articles - [ ] Download articles @@ -35,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/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..4e6692e --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,3 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart \ No newline at end of file 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 new file mode 100644 index 0000000..856d1c5 --- /dev/null +++ b/lib/l10n/app_cs.arb @@ -0,0 +1,26 @@ +{ + "home":"Domů", + "noRecents":"Nemáte žádné nedávno otevřené články, potáhněte doprava a začněte hledat.", + "recentPages":"Nedávné články", + "searchAppBarTitle":"Prohledat WikiVoyage", + "search":"Hledat", + "offlineTitle":"Offline stažení", + "offlineDialog":"Chcete stáhnout článek '{article}' pro zobrazení offline? Bude dostupný v sekci 'Stažené'.", + "renderError":"Chyba při renderování:", + "attrFrom":"Z", + "attrUnder":"pod licencí", + "downloadsTitle":"Stažené", + "noDownloads":"Nemáte žádné stažené články. Vyhledejte nějaké, a poté je dlouhým podržením stáhněte.", + "about":"O Aplikcai", + "sourceCode":"Zdrojový kód", + "creditsCreatedBy":"Vytvořil Matyáš Caras", + "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 new file mode 100644 index 0000000..cc1db28 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,72 @@ +{ + "home":"Home", + "@home":{ + "description":"As seen on the home page" + }, + "noRecents":"You haven't opened anything recently, swipe right and start searching.", + "@noRecents":{ + "description":"Shown when there are no recent pages" + }, + "recentPages":"Recent articles", + "@recentPages":{ + "description":"Title of the home page" + }, + "searchAppBarTitle":"Search WikiVoyage", + "@searchAppBarTitle":{ + "description":"Appbar title" + }, + "search":"Search", + "offlineTitle":"Offline download", + "@offlineTitle":{ + "description":"Title of the dialog that appears when downloading an article offline" + }, + "offlineDialog":"Would you like to download the article '{article}' for offline viewing? It will be available in the 'Downloads' section.", + "@offlineDialog":{ + "description":"Offline download dialog content text", + "placeholders":{ + "article":{ + "type":"String", + "example":"Rail travel in Japan", + "description":"WikiVoyage article name" + } + } + }, + "renderError":"Error while rendering:", + "@renderError":{ + "description":"Displayed when rendering of a page throws an error" + }, + "attrFrom":"From", + "@pageFrom":{ + "description":"The *From* part of 'From WikiVoyage'" + }, + "attrUnder":"under", + "@attrUnder":{ + "description":"The *under* part of license attribution, e.g. 'under CC BY-SA 3.0'" + }, + "downloadsTitle":"Downloads", + "@downloadsTitle":{ + "description":"The appbar title for the Downloads page" + }, + "noDownloads":"You don't have any articles downloaded. Search for some and then long-press them to download them offline.", + "about":"About", + "sourceCode":"Source code", + "creditsCreatedBy":"Created by Matyáš Caras", + "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/main.dart b/lib/main.dart index 191968a..35ad86f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,7 +2,7 @@ import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:voyagehandbook/util/color_schemes.g.dart'; import 'package:voyagehandbook/views/home.dart'; - +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; /* Voyage Handbook - The open-source WikiVoyage reader Copyright (C) 2023 Matyáš Caras @@ -33,6 +33,8 @@ class MyApp extends StatelessWidget { return DynamicColorBuilder( builder: (lightDynamic, darkDynamic) => MaterialApp( title: 'Voyage Handbook', + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, debugShowCheckedModeBanner: false, theme: ThemeData( useMaterial3: true, colorScheme: lightDynamic ?? lightColorScheme), diff --git a/lib/util/drawer.dart b/lib/util/drawer.dart index 43becab..6d68719 100644 --- a/lib/util/drawer.dart +++ b/lib/util/drawer.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:voyagehandbook/views/downloads.dart'; import 'package:voyagehandbook/views/home.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../views/search.dart'; /* @@ -28,19 +31,19 @@ Drawer genDrawer(int page, BuildContext context) => Drawer( children: [ DrawerHeader( child: Column( - children: const [ - Text( + children: [ + const Text( "Voyage Handbook", style: TextStyle(fontWeight: FontWeight.bold), ), - Text("Created by Matyáš Caras"), - Text("Not affiliated with WikiVoyage") + Text(AppLocalizations.of(context)!.creditsCreatedBy), + Text(AppLocalizations.of(context)!.creditsAffiliation) ], ), ), ListTile( selected: page == 1, - title: const Text("Home"), + title: Text(AppLocalizations.of(context)!.home), leading: const Icon(Icons.home), onTap: () => page == 1 ? Navigator.of(context).pop() @@ -52,7 +55,7 @@ Drawer genDrawer(int page, BuildContext context) => Drawer( ), ListTile( selected: page == 2, - title: const Text("Search"), + title: Text(AppLocalizations.of(context)!.search), leading: const Icon(Icons.search), onTap: () => page == 2 ? Navigator.of(context).pop() @@ -64,9 +67,21 @@ Drawer genDrawer(int page, BuildContext context) => Drawer( ), ListTile( selected: page == 3, - title: const Text("About"), - leading: const Icon(Icons.info_outline), + title: Text(AppLocalizations.of(context)!.downloadsTitle), + leading: const Icon(Icons.download), onTap: () => page == 3 + ? Navigator.of(context).pop() + : Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const DownloadsView(), + ), + ), + ), + ListTile( + selected: page == 99, + title: Text(AppLocalizations.of(context)!.about), + leading: const Icon(Icons.info_outline), + onTap: () => page == 99 ? Navigator.of(context).pop() : Navigator.of(context).push( MaterialPageRoute( @@ -78,6 +93,15 @@ Drawer genDrawer(int page, BuildContext context) => Drawer( ), ), ), + ListTile( + selected: page == 99, + title: Text(AppLocalizations.of(context)!.sourceCode), + leading: const Icon(Icons.code), + onTap: () => page == 99 + ? Navigator.of(context).pop() + : launchUrlString("https://git.mnau.xyz/hernik/voyagehandbook", + mode: LaunchMode.externalApplication), + ), ], ), ); diff --git a/lib/util/render.dart b/lib/util/render.dart index 20e351d..1debe38 100644 --- a/lib/util/render.dart +++ b/lib/util/render.dart @@ -10,11 +10,13 @@ import 'package:logger/logger.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/util/storage.dart'; import 'package:voyagehandbook/util/styles.dart'; import 'package:voyagehandbook/util/widgets/warning.dart'; import 'package:html_unescape/html_unescape_small.dart'; import 'package:voyagehandbook/views/pageview.dart'; import 'package:widget_zoom/widget_zoom.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; /* Voyage Handbook - The open-source WikiVoyage reader @@ -40,12 +42,20 @@ class PageRenderer { final double height; final double width; final BuildContext context; + final AppLocalizations loc; + + /// For offline downloads; don't bother rendering the widget tree + final bool offline; + + /// HTML for offline download / caching + String outHtml = ""; String? document; - PageRenderer(this.scheme, this.height, this.width, this.context); + PageRenderer(this.scheme, this.height, this.width, this.context, this.loc, + {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, @@ -61,7 +71,7 @@ class PageRenderer { Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - const SelectableText("From"), + SelectableText(loc.attrFrom), const SizedBox( width: 5, ), @@ -79,27 +89,32 @@ class PageRenderer { ), Row( children: [ - const SelectableText("under"), + 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, + ), ), ) ], ) ]; + outHtml = ""; var document = parse(page.html); var sections = document.body!.getElementsByTagName("section"); this.document = page.html; for (var sec in sections) { if (sec.localName == "section") { - out.addAll(_renderSection(sec)); + if (!offline) out.addAll(await _renderSection(sec)); + outHtml += sec.outerHtml; } } var l = ListView.builder( @@ -110,7 +125,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( @@ -208,7 +223,7 @@ class PageRenderer { ); // space paragraphs break; case "figure": - out.addAll(_renderImageFigure(element)); + out.addAll(await _renderImageFigure(element)); out.add( const SizedBox( height: 10, @@ -216,7 +231,7 @@ class PageRenderer { ); break; case "section": - out.addAll(_renderSection(element)); + out.addAll(await _renderSection(element)); break; case "dl": var dd = element.getElementsByTagName("dd").first; @@ -280,7 +295,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, @@ -440,6 +455,11 @@ 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, @@ -473,6 +493,7 @@ class PageRenderer { default: break; } + element.remove(); } @@ -484,7 +505,7 @@ class PageRenderer { return out; } - List _renderImageFigure(dom.Element element) { + Future> _renderImageFigure(dom.Element element) async { var out = []; /// Image figure @@ -497,6 +518,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, )); @@ -511,10 +538,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), + ), ), ), ), @@ -589,7 +617,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 @@ -635,8 +665,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 7c7eb58..7ebeb13 100644 --- a/lib/util/storage.dart +++ b/lib/util/storage.dart @@ -1,7 +1,12 @@ 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'; /* Voyage Handbook - The open-source WikiVoyage reader @@ -35,6 +40,19 @@ class StorageAccess { .toList(); } + /// Get files in `offline` folder, which contains recently opened pages + static Future>> get offline async { + var files = + Directory("${(await getApplicationDocumentsDirectory()).path}/offline"); + if (!files.existsSync()) files.createSync(); + return files + .listSync() + .whereType() + .toList() + .map>((e) => jsonDecode(e.readAsStringSync())) + .toList(); + } + static void addToRecents(String pageName, String pageKey) async { var files = Directory("${(await getApplicationDocumentsDirectory()).path}/recent"); @@ -70,4 +88,91 @@ class StorageAccess { recent.writeAsStringSync(jsonEncode(recentContent)); } } + + static Future isDownloaded(String pageKey) async { + var files = + 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 { + 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( + responseType: ResponseType.bytes, + followRedirects: false, + validateStatus: (status) { + return (status ?? 200) < 500; + }, + ), + ); + 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); + } + } + + 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 new file mode 100644 index 0000000..06fea98 --- /dev/null +++ b/lib/views/downloads.dart @@ -0,0 +1,85 @@ +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'; + +class DownloadsView extends StatefulWidget { + const DownloadsView({super.key}); + + @override + State createState() => _DownloadsViewState(); +} + +class _DownloadsViewState extends State { + @override + void initState() { + super.initState(); + loadDownloads(); + } + + var _content = []; + var _isLoading = true; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.downloadsTitle), + ), + drawer: genDrawer(3, context), + 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, + textAlign: TextAlign.center, + ) + ] + : _content, + ), + ), + ), + ); + } + + void loadDownloads() async { + var files = await StorageAccess.offline; + _content = List.generate( + files.length, + (index) => SizedBox( + width: MediaQuery.of(context).size.width * 0.9, + height: MediaQuery.of(context).size.height * 0.1, + child: InkWell( + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ArticleView( + pageKey: files[index]["key"], + name: files[index]["title"], + ), + ), + ), + child: Align( + alignment: Alignment.center, + child: Text( + files[index]["title"], + textAlign: TextAlign.center, + style: PageStyles.h1, + ), + ), + ), + ), + ); + _isLoading = false; + setState(() {}); + } +} diff --git a/lib/views/home.dart b/lib/views/home.dart index f4ef6e8..0b243f9 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -4,6 +4,7 @@ import 'package:voyagehandbook/util/storage.dart'; import 'package:voyagehandbook/util/styles.dart'; import 'package:voyagehandbook/views/pageview.dart'; import 'package:voyagehandbook/views/search.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; /* Voyage Handbook - The open-source WikiVoyage reader @@ -48,7 +49,7 @@ class _HomeViewState extends State { ), child: const Icon(Icons.search), ), - appBar: AppBar(title: const Text("Home")), + appBar: AppBar(title: Text(AppLocalizations.of(context)!.home)), drawer: genDrawer(1, context), body: Center( child: SizedBox( @@ -58,9 +59,8 @@ class _HomeViewState extends State { mainAxisAlignment: MainAxisAlignment.center, children: (_recents.isEmpty) ? [ - const Flexible( - child: Text( - "You haven't opened anything recently, swipe right and start searching."), + Flexible( + child: Text(AppLocalizations.of(context)!.noRecents), ) ] : _recents), @@ -79,9 +79,10 @@ class _HomeViewState extends State { DateTime.fromMillisecondsSinceEpoch(b["date"])) ? 0 : 1); + if (!mounted) return; _recents = [ - const Text( - "Recent pages", + Text( + AppLocalizations.of(context)!.recentPages, style: PageStyles.h1, ), const SizedBox( diff --git a/lib/views/pageview.dart b/lib/views/pageview.dart index 9c688ea..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'; @@ -5,6 +6,7 @@ import 'package:voyagehandbook/util/drawer.dart'; import 'package:voyagehandbook/util/render.dart'; import 'package:voyagehandbook/util/storage.dart'; import 'package:voyagehandbook/util/styles.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; /* Voyage Handbook - The open-source WikiVoyage reader @@ -77,34 +79,55 @@ 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); - if (kDebugMode) { + context, + AppLocalizations.of(context)!); + + 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 { + } 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 = [ - 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) { - _content = [ - const Text( - "Error while rendering:", + Text( + AppLocalizations.of(context)!.renderError, style: PageStyles.h1, ), const SizedBox( diff --git a/lib/views/search.dart b/lib/views/search.dart index 2e5a70f..5e5d4d1 100644 --- a/lib/views/search.dart +++ b/lib/views/search.dart @@ -1,8 +1,10 @@ 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'; import '../util/drawer.dart'; /* @@ -36,7 +38,8 @@ class _SearchViewState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text("Search WikiVoyage")), + appBar: + AppBar(title: Text(AppLocalizations.of(context)!.searchAppBarTitle)), drawer: genDrawer(2, context), body: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), @@ -59,7 +62,7 @@ class _SearchViewState extends State { _searchResults = r; setState(() {}); }, - child: const Text("Search"), + child: Text(AppLocalizations.of(context)!.search), ), const SizedBox( height: 15, @@ -129,6 +132,126 @@ class _SearchViewState extends State { child: Card( elevation: 2, child: InkWell( + onLongPress: () { + // Show download dialog + showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text( + AppLocalizations.of(context)! + .offlineTitle), + content: Text( + AppLocalizations.of(context)! + .offlineDialog( + _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), + ), + ], + ), + ); + }, onTap: () { Navigator.of(context).push( MaterialPageRoute( @@ -145,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, ), diff --git a/pubspec.yaml b/pubspec.yaml index 44d8872..aedf7b2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,6 +49,9 @@ dependencies: flutter_map: ^5.0.0 latlong2: ^0.9.0 logger: ^1.4.0 + flutter_localizations: + sdk: flutter + intl: any dev_dependencies: flutter_test: @@ -68,6 +71,7 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: + generate: true # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in