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
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

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: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),

View file

@ -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),
),
],
),
);

View file

@ -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 = "<html><head></head><body>";
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();
}

View file

@ -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<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 {
var files =
Directory("${(await getApplicationDocumentsDirectory()).path}/recent");
@ -70,4 +86,49 @@ class StorageAccess {
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/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<HomeView> {
),
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<HomeView> {
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<HomeView> {
DateTime.fromMillisecondsSinceEpoch(b["date"]))
? 0
: 1);
if (!mounted) return;
_recents = [
const Text(
"Recent pages",
Text(
AppLocalizations.of(context)!.recentPages,
style: PageStyles.h1,
),
const SizedBox(

View file

@ -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<ArticleView> {
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<ArticleView> {
];
} catch (e) {
_content = [
const Text(
"Error while rendering:",
Text(
AppLocalizations.of(context)!.renderError,
style: PageStyles.h1,
),
const SizedBox(

View file

@ -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<SearchView> {
@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<SearchView> {
_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<SearchView> {
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(

View file

@ -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:

View file

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