feat: support l10n and work on offline downloads

This commit is contained in:
Matyáš Caras 2023-04-06 22:46:40 +02:00
parent 9994197579
commit f91ef2e68f
14 changed files with 317 additions and 24 deletions

View file

@ -1,13 +1,17 @@
# Voyage Handbook # Voyage Handbook
Access [WikiVoyage](https://en.wikivoyage.org) conveniently from your phone! 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 ## Roadmap
*(In no particular order)* *(In no particular order)*
- [ ] Render articles *completely* using native widgets - [ ] Render articles *completely* using native widgets
- [ ] Navigation in articles by heading - [ ] Navigation in articles by heading
- [ ] Settings for reader - [ ] Settings for reader
- [ ] Translate UI - [X] Translate UI
- [ ] Support different WikiVoyage languages - [ ] Support different WikiVoyage languages
- [ ] Bookmark articles - [ ] Bookmark articles
- [ ] Download articles - [ ] Download articles

3
l10n.yaml Normal file
View file

@ -0,0 +1,3 @@
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart

18
lib/l10n/app_cs.arb Normal file
View file

@ -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"
}

55
lib/l10n/app_en.arb Normal file
View file

@ -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"
}

View file

@ -2,7 +2,7 @@ import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:voyagehandbook/util/color_schemes.g.dart'; import 'package:voyagehandbook/util/color_schemes.g.dart';
import 'package:voyagehandbook/views/home.dart'; import 'package:voyagehandbook/views/home.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
/* /*
Voyage Handbook - The open-source WikiVoyage reader Voyage Handbook - The open-source WikiVoyage reader
Copyright (C) 2023 Matyáš Caras Copyright (C) 2023 Matyáš Caras
@ -33,6 +33,8 @@ class MyApp extends StatelessWidget {
return DynamicColorBuilder( return DynamicColorBuilder(
builder: (lightDynamic, darkDynamic) => MaterialApp( builder: (lightDynamic, darkDynamic) => MaterialApp(
title: 'Voyage Handbook', title: 'Voyage Handbook',
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData( theme: ThemeData(
useMaterial3: true, colorScheme: lightDynamic ?? lightColorScheme), useMaterial3: true, colorScheme: lightDynamic ?? lightColorScheme),

View file

@ -1,6 +1,9 @@
import 'package:flutter/material.dart'; 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:voyagehandbook/views/home.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../views/search.dart'; import '../views/search.dart';
/* /*
@ -28,13 +31,13 @@ Drawer genDrawer(int page, BuildContext context) => Drawer(
children: [ children: [
DrawerHeader( DrawerHeader(
child: Column( child: Column(
children: const [ children: [
Text( const Text(
"Voyage Handbook", "Voyage Handbook",
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
Text("Created by Matyáš Caras"), Text(AppLocalizations.of(context)!.creditsCreatedBy),
Text("Not affiliated with WikiVoyage") Text(AppLocalizations.of(context)!.creditsAffiliation)
], ],
), ),
), ),
@ -52,7 +55,7 @@ Drawer genDrawer(int page, BuildContext context) => Drawer(
), ),
ListTile( ListTile(
selected: page == 2, selected: page == 2,
title: const Text("Search"), title: Text(AppLocalizations.of(context)!.search),
leading: const Icon(Icons.search), leading: const Icon(Icons.search),
onTap: () => page == 2 onTap: () => page == 2
? Navigator.of(context).pop() ? Navigator.of(context).pop()
@ -64,9 +67,21 @@ Drawer genDrawer(int page, BuildContext context) => Drawer(
), ),
ListTile( ListTile(
selected: page == 3, selected: page == 3,
title: const Text("About"), title: Text(AppLocalizations.of(context)!.downloadsTitle),
leading: const Icon(Icons.info_outline), leading: const Icon(Icons.search),
onTap: () => page == 3 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).pop()
: Navigator.of(context).push( : Navigator.of(context).push(
MaterialPageRoute( 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),
),
], ],
), ),
); );

View file

@ -12,6 +12,7 @@ import 'package:voyagehandbook/util/widgets/warning.dart';
import 'package:html_unescape/html_unescape_small.dart'; import 'package:html_unescape/html_unescape_small.dart';
import 'package:voyagehandbook/views/pageview.dart'; import 'package:voyagehandbook/views/pageview.dart';
import 'package:widget_zoom/widget_zoom.dart'; import 'package:widget_zoom/widget_zoom.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
/* /*
Voyage Handbook - The open-source WikiVoyage reader Voyage Handbook - The open-source WikiVoyage reader
@ -36,8 +37,16 @@ class PageRenderer {
final double height; final double height;
final double width; final double width;
final BuildContext context; 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 /// Used to create Widgets from raw HTML from WM API
ListView renderFromPageHTML(RawPage page) { ListView renderFromPageHTML(RawPage page) {
@ -56,7 +65,7 @@ class PageRenderer {
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const SelectableText("From"), SelectableText(loc.attrFrom),
const SizedBox( const SizedBox(
width: 5, width: 5,
), ),
@ -74,7 +83,7 @@ class PageRenderer {
), ),
Row( Row(
children: [ children: [
const SelectableText("under"), SelectableText(loc.attrUnder),
const SizedBox( const SizedBox(
width: 5, width: 5,
), ),
@ -89,11 +98,13 @@ class PageRenderer {
], ],
) )
]; ];
outHtml = "<html><head></head><body>";
var document = parse(page.html); var document = parse(page.html);
var sections = document.body!.getElementsByTagName("section"); var sections = document.body!.getElementsByTagName("section");
for (var sec in sections) { for (var sec in sections) {
if (sec.localName == "section") { if (sec.localName == "section") {
out.addAll(_renderSection(sec)); if (!offline) out.addAll(_renderSection(sec));
outHtml += sec.outerHtml;
} }
} }
var l = ListView.builder( var l = ListView.builder(
@ -373,6 +384,7 @@ class PageRenderer {
default: default:
break; break;
} }
element.remove(); element.remove();
} }

View file

@ -1,7 +1,10 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart';
import 'package:html/parser.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:voyagehandbook/api/wikimedia.dart';
/* /*
Voyage Handbook - The open-source WikiVoyage reader Voyage Handbook - The open-source WikiVoyage reader
@ -35,6 +38,19 @@ class StorageAccess {
.toList(); .toList();
} }
/// Get files in `offline` folder, which contains recently opened pages
static Future<List<Map<String, dynamic>>> get offline async {
var files =
Directory("${(await getApplicationDocumentsDirectory()).path}/offline");
if (!files.existsSync()) files.createSync();
return files
.listSync()
.whereType<File>()
.toList()
.map<Map<String, dynamic>>((e) => jsonDecode(e.readAsStringSync()))
.toList();
}
static void addToRecents(String pageName, String pageKey) async { static void addToRecents(String pageName, String pageKey) async {
var files = var files =
Directory("${(await getApplicationDocumentsDirectory()).path}/recent"); Directory("${(await getApplicationDocumentsDirectory()).path}/recent");
@ -70,4 +86,49 @@ class StorageAccess {
recent.writeAsStringSync(jsonEncode(recentContent)); recent.writeAsStringSync(jsonEncode(recentContent));
} }
} }
static Future<bool> isDownloaded(String pageKey) async {
var files =
Directory("${(await getApplicationDocumentsDirectory()).path}/recent");
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");
var offlinePage = File("${files.path}/$pageKey");
var page = parse(await WikiApi.getRawPage(pageKey))
.body!
.getElementsByTagName("section");
var out = "<html><head></head><body>";
for (var el in page) {
out += el.outerHtml;
var imgMatch = RegExp(r'<img src="(.+?)">').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);
}
} }

75
lib/views/downloads.dart Normal file
View file

@ -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<DownloadsView> createState() => _DownloadsViewState();
}
class _DownloadsViewState extends State<DownloadsView> {
@override
void initState() {
super.initState();
loadDownloads();
}
var _content = <Widget>[];
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(() {});
}
}

View file

@ -4,6 +4,7 @@ import 'package:voyagehandbook/util/storage.dart';
import 'package:voyagehandbook/util/styles.dart'; import 'package:voyagehandbook/util/styles.dart';
import 'package:voyagehandbook/views/pageview.dart'; import 'package:voyagehandbook/views/pageview.dart';
import 'package:voyagehandbook/views/search.dart'; import 'package:voyagehandbook/views/search.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
/* /*
Voyage Handbook - The open-source WikiVoyage reader Voyage Handbook - The open-source WikiVoyage reader
@ -48,7 +49,7 @@ class _HomeViewState extends State<HomeView> {
), ),
child: const Icon(Icons.search), child: const Icon(Icons.search),
), ),
appBar: AppBar(title: const Text("Home")), appBar: AppBar(title: Text(AppLocalizations.of(context)!.home)),
drawer: genDrawer(1, context), drawer: genDrawer(1, context),
body: Center( body: Center(
child: SizedBox( child: SizedBox(
@ -58,9 +59,8 @@ class _HomeViewState extends State<HomeView> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: (_recents.isEmpty) children: (_recents.isEmpty)
? [ ? [
const Flexible( Flexible(
child: Text( child: Text(AppLocalizations.of(context)!.noRecents),
"You haven't opened anything recently, swipe right and start searching."),
) )
] ]
: _recents), : _recents),
@ -79,9 +79,10 @@ class _HomeViewState extends State<HomeView> {
DateTime.fromMillisecondsSinceEpoch(b["date"])) DateTime.fromMillisecondsSinceEpoch(b["date"]))
? 0 ? 0
: 1); : 1);
if (!mounted) return;
_recents = [ _recents = [
const Text( Text(
"Recent pages", AppLocalizations.of(context)!.recentPages,
style: PageStyles.h1, style: PageStyles.h1,
), ),
const SizedBox( const SizedBox(

View file

@ -5,6 +5,7 @@ import 'package:voyagehandbook/util/drawer.dart';
import 'package:voyagehandbook/util/render.dart'; import 'package:voyagehandbook/util/render.dart';
import 'package:voyagehandbook/util/storage.dart'; import 'package:voyagehandbook/util/storage.dart';
import 'package:voyagehandbook/util/styles.dart'; import 'package:voyagehandbook/util/styles.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
/* /*
Voyage Handbook - The open-source WikiVoyage reader Voyage Handbook - The open-source WikiVoyage reader
@ -81,7 +82,8 @@ class _ArticleViewState extends State<ArticleView> {
Theme.of(context).colorScheme, Theme.of(context).colorScheme,
MediaQuery.of(context).size.height, MediaQuery.of(context).size.height,
MediaQuery.of(context).size.width, MediaQuery.of(context).size.width,
context); context,
AppLocalizations.of(context)!);
if (kDebugMode) { if (kDebugMode) {
_content = [ _content = [
SizedBox( SizedBox(
@ -103,8 +105,8 @@ class _ArticleViewState extends State<ArticleView> {
]; ];
} catch (e) { } catch (e) {
_content = [ _content = [
const Text( Text(
"Error while rendering:", AppLocalizations.of(context)!.renderError,
style: PageStyles.h1, style: PageStyles.h1,
), ),
const SizedBox( const SizedBox(

View file

@ -3,6 +3,7 @@ import 'package:voyagehandbook/api/classes.dart';
import 'package:voyagehandbook/api/wikimedia.dart'; import 'package:voyagehandbook/api/wikimedia.dart';
import 'package:voyagehandbook/views/pageview.dart'; import 'package:voyagehandbook/views/pageview.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../util/drawer.dart'; import '../util/drawer.dart';
/* /*
@ -36,7 +37,8 @@ class _SearchViewState extends State<SearchView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text("Search WikiVoyage")), appBar:
AppBar(title: Text(AppLocalizations.of(context)!.searchAppBarTitle)),
drawer: genDrawer(2, context), drawer: genDrawer(2, context),
body: SingleChildScrollView( body: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
@ -59,7 +61,7 @@ class _SearchViewState extends State<SearchView> {
_searchResults = r; _searchResults = r;
setState(() {}); setState(() {});
}, },
child: const Text("Search"), child: Text(AppLocalizations.of(context)!.search),
), ),
const SizedBox( const SizedBox(
height: 15, height: 15,
@ -129,6 +131,23 @@ class _SearchViewState extends State<SearchView> {
child: Card( child: Card(
elevation: 2, elevation: 2,
child: InkWell( 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: () { onTap: () {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(

View file

@ -310,6 +310,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.1" version: "2.0.1"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -392,6 +397,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.3.0" version: "3.3.0"
intl:
dependency: "direct main"
description:
name: intl
sha256: "910f85bce16fb5c6f614e117efa303e85a1731bb0081edf3604a2ae6e9a3cc91"
url: "https://pub.dev"
source: hosted
version: "0.17.0"
io: io:
dependency: transitive dependency: transitive
description: description:

View file

@ -46,6 +46,9 @@ dependencies:
cached_network_image: ^3.2.3 cached_network_image: ^3.2.3
html_unescape: ^2.0.0 html_unescape: ^2.0.0
widget_zoom: ^0.0.1 widget_zoom: ^0.0.1
flutter_localizations:
sdk: flutter
intl: any
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@ -65,6 +68,7 @@ dev_dependencies:
# The following section is specific to Flutter packages. # The following section is specific to Flutter packages.
flutter: flutter:
generate: true
# The following line ensures that the Material Icons font is # The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in # included with your application, so that you can use the icons in