From f91ef2e68f62edbf6ee3a173c2567c7309d22ee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Thu, 6 Apr 2023 22:46:40 +0200 Subject: [PATCH] feat: support l10n and work on offline downloads --- README.md | 6 +++- l10n.yaml | 3 ++ lib/l10n/app_cs.arb | 18 ++++++++++ lib/l10n/app_en.arb | 55 +++++++++++++++++++++++++++++ lib/main.dart | 4 ++- lib/util/drawer.dart | 38 ++++++++++++++++---- lib/util/render.dart | 20 ++++++++--- lib/util/storage.dart | 61 ++++++++++++++++++++++++++++++++ lib/views/downloads.dart | 75 ++++++++++++++++++++++++++++++++++++++++ lib/views/home.dart | 13 +++---- lib/views/pageview.dart | 8 +++-- lib/views/search.dart | 23 ++++++++++-- pubspec.lock | 13 +++++++ pubspec.yaml | 4 +++ 14 files changed, 317 insertions(+), 24 deletions(-) create mode 100644 l10n.yaml create mode 100644 lib/l10n/app_cs.arb create mode 100644 lib/l10n/app_en.arb create mode 100644 lib/views/downloads.dart diff --git a/README.md b/README.md index 33ec40d..f73ae98 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 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/l10n/app_cs.arb b/lib/l10n/app_cs.arb new file mode 100644 index 0000000..c84c222 --- /dev/null +++ b/lib/l10n/app_cs.arb @@ -0,0 +1,18 @@ +{ + "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" +} \ 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..19177d5 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,55 @@ +{ + "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" +} \ 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..c5ab160 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,13 +31,13 @@ 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) ], ), ), @@ -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.search), 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 e0ed7fc..a60adec 100644 --- a/lib/util/render.dart +++ b/lib/util/render.dart @@ -12,6 +12,7 @@ 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 @@ -36,8 +37,16 @@ class PageRenderer { final double height; final double width; final BuildContext context; + final AppLocalizations loc; - PageRenderer(this.scheme, this.height, this.width, this.context); + /// For offline downloads; don't bother rendering the widget tree + final bool offline; + + /// HTML for offline download / caching + String outHtml = ""; + + 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) { @@ -56,7 +65,7 @@ class PageRenderer { Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - const SelectableText("From"), + SelectableText(loc.attrFrom), const SizedBox( width: 5, ), @@ -74,7 +83,7 @@ class PageRenderer { ), Row( children: [ - const SelectableText("under"), + SelectableText(loc.attrUnder), const SizedBox( width: 5, ), @@ -89,11 +98,13 @@ class PageRenderer { ], ) ]; + outHtml = ""; var document = parse(page.html); var sections = document.body!.getElementsByTagName("section"); for (var sec in sections) { if (sec.localName == "section") { - out.addAll(_renderSection(sec)); + if (!offline) out.addAll(_renderSection(sec)); + outHtml += sec.outerHtml; } } var l = ListView.builder( @@ -373,6 +384,7 @@ class PageRenderer { default: break; } + element.remove(); } diff --git a/lib/util/storage.dart b/lib/util/storage.dart index 7c7eb58..2adae75 100644 --- a/lib/util/storage.dart +++ b/lib/util/storage.dart @@ -1,7 +1,10 @@ import 'dart:convert'; import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:html/parser.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:voyagehandbook/api/wikimedia.dart'; /* Voyage Handbook - The open-source WikiVoyage reader @@ -35,6 +38,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 +86,49 @@ class StorageAccess { recent.writeAsStringSync(jsonEncode(recentContent)); } } + + static Future isDownloaded(String pageKey) async { + var files = + Directory("${(await getApplicationDocumentsDirectory()).path}/recent"); + 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); + if (imgMatch.isNotEmpty) { + // download images offline + for (var match in imgMatch) { + var src = match.group(1)!; + var r = await Dio().get( + src, + options: Options( + responseType: ResponseType.bytes, + followRedirects: false, + validateStatus: (status) { + return (status ?? 200) < 500; + }, + ), + ); + var img = File("${files.path}/${src.split('/').last}"); + print(img.path); + var openImg = img.openSync(mode: FileMode.write); + openImg.writeFromSync(r.data); + } + } + } + if (page.isEmpty) return Future.error("No sections to save"); + offlinePage.writeAsStringSync( + jsonEncode({"title": pageTitle, "key": pageKey, "content": out}), + mode: FileMode.writeOnly); + } } diff --git a/lib/views/downloads.dart b/lib/views/downloads.dart new file mode 100644 index 0000000..4c5befa --- /dev/null +++ b/lib/views/downloads.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:voyagehandbook/util/drawer.dart'; +import 'package:voyagehandbook/util/storage.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: 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)] + : _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.15, + child: InkWell( + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ArticleView( + pageKey: files[index]["key"], + name: files[index]["title"], + ), + ), + ), + child: Padding( + padding: const EdgeInsets.all(8), + child: Text(files[index]["title"]), + ), + ), + ), + ); + _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..d0806f5 100644 --- a/lib/views/pageview.dart +++ b/lib/views/pageview.dart @@ -5,6 +5,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 @@ -81,7 +82,8 @@ class _ArticleViewState extends State { Theme.of(context).colorScheme, MediaQuery.of(context).size.height, MediaQuery.of(context).size.width, - context); + context, + AppLocalizations.of(context)!); if (kDebugMode) { _content = [ SizedBox( @@ -103,8 +105,8 @@ class _ArticleViewState extends State { ]; } 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..63df7a4 100644 --- a/lib/views/search.dart +++ b/lib/views/search.dart @@ -3,6 +3,7 @@ import 'package:voyagehandbook/api/classes.dart'; import 'package:voyagehandbook/api/wikimedia.dart'; import 'package:voyagehandbook/views/pageview.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../util/drawer.dart'; /* @@ -36,7 +37,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 +61,7 @@ class _SearchViewState extends State { _searchResults = r; setState(() {}); }, - child: const Text("Search"), + child: Text(AppLocalizations.of(context)!.search), ), const SizedBox( height: 15, @@ -129,6 +131,23 @@ 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), + ), + ), + ); + }, onTap: () { Navigator.of(context).push( MaterialPageRoute( diff --git a/pubspec.lock b/pubspec.lock index e38d556..d19a6cb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -310,6 +310,11 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_test: dependency: "direct dev" description: flutter @@ -392,6 +397,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: "910f85bce16fb5c6f614e117efa303e85a1731bb0081edf3604a2ae6e9a3cc91" + url: "https://pub.dev" + source: hosted + version: "0.17.0" io: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index eb48449..7b0beb7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,6 +46,9 @@ dependencies: cached_network_image: ^3.2.3 html_unescape: ^2.0.0 widget_zoom: ^0.0.1 + flutter_localizations: + sdk: flutter + intl: any dev_dependencies: flutter_test: @@ -65,6 +68,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