This commit is contained in:
Matyáš Caras 2023-07-04 00:17:22 +02:00
commit 76ec1883d8
15 changed files with 603 additions and 72 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
@ -35,7 +39,13 @@ cd voyagehandbook
./flutterw doctor ./flutterw doctor
``` ```
3. Run on your connected device 3. Generate locales
```sh
./flutterw gen-l10n
```
4. Run on your connected device
```sh ```sh
./flutterw run ./flutterw run

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

View file

@ -25,8 +25,10 @@ class SearchResponse {
final String title; final String title;
final String excerpt; final String excerpt;
final String? description; final String? description;
const SearchResponse( @JsonKey(includeFromJson: false, includeToJson: false)
this.id, this.key, this.title, this.excerpt, this.description); bool downloaded;
SearchResponse(this.id, this.key, this.title, this.excerpt, this.description,
{this.downloaded = false});
/// Connect the generated function to the `fromJson` /// Connect the generated function to the `fromJson`
/// factory. /// factory.
@ -56,11 +58,10 @@ class RawPage {
final String title; final String title;
@JsonKey(fromJson: _editedFromJson, toJson: _editedToJson, name: "latest") @JsonKey(fromJson: _editedFromJson, toJson: _editedToJson, name: "latest")
final String edited; final String edited;
final String html; String html;
final LicenseAttribution license; final LicenseAttribution license;
const RawPage( RawPage(this.id, this.key, this.title, this.edited, this.html, this.license);
this.id, this.key, this.title, this.edited, this.html, this.license);
factory RawPage.fromJson(Map<String, dynamic> json) => factory RawPage.fromJson(Map<String, dynamic> json) =>
_$RawPageFromJson(json); _$RawPageFromJson(json);

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:voyagehandbook/api/classes.dart'; import 'package:voyagehandbook/api/classes.dart';
import 'package:voyagehandbook/util/storage.dart';
/* /*
Voyage Handbook - The open-source WikiVoyage reader Voyage Handbook - The open-source WikiVoyage reader
@ -39,8 +40,14 @@ class WikiApi {
var r = await _getRequest("search/page?q=$q&limit=$limit"); var r = await _getRequest("search/page?q=$q&limit=$limit");
if (r.statusCode! > 399) return Future.error("API error ${r.statusCode}"); if (r.statusCode! > 399) return Future.error("API error ${r.statusCode}");
var json = jsonDecode(r.data)["pages"]; var json = jsonDecode(r.data)["pages"];
return List<SearchResponse>.generate( var list = List<SearchResponse>.generate(
json.length, (index) => SearchResponse.fromJson(json[index])); 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<RawPage> getRawPage(String key) async { static Future<RawPage> getRawPage(String key) async {

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

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

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

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

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,19 +31,19 @@ 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)
], ],
), ),
), ),
ListTile( ListTile(
selected: page == 1, selected: page == 1,
title: const Text("Home"), title: Text(AppLocalizations.of(context)!.home),
leading: const Icon(Icons.home), leading: const Icon(Icons.home),
onTap: () => page == 1 onTap: () => page == 1
? Navigator.of(context).pop() ? Navigator.of(context).pop()
@ -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.download),
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

@ -10,11 +10,13 @@ import 'package:logger/logger.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:voyagehandbook/api/classes.dart'; import 'package:voyagehandbook/api/classes.dart';
import 'package:voyagehandbook/util/storage.dart';
import 'package:voyagehandbook/util/styles.dart'; import 'package:voyagehandbook/util/styles.dart';
import 'package:voyagehandbook/util/widgets/warning.dart'; 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
@ -40,12 +42,20 @@ class PageRenderer {
final double height; final double height;
final double width; final double width;
final BuildContext context; 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; 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 /// Used to create Widgets from raw HTML from WM API
ListView renderFromPageHTML(RawPage page) { Future<ListView> renderFromPageHTML(RawPage page) async {
var out = <Widget>[ var out = <Widget>[
const SizedBox( const SizedBox(
height: 10, height: 10,
@ -61,7 +71,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,
), ),
@ -79,27 +89,32 @@ class PageRenderer {
), ),
Row( Row(
children: [ children: [
const SelectableText("under"), SelectableText(loc.attrUnder),
const SizedBox( const SizedBox(
width: 5, width: 2,
), ),
SelectableText( Flexible(
child: SelectableText(
page.license.title, page.license.title,
textAlign: TextAlign.center,
onTap: () => launchUrl(Uri.parse(page.license.url), onTap: () => launchUrl(Uri.parse(page.license.url),
mode: LaunchMode.externalApplication), mode: LaunchMode.externalApplication),
style: const TextStyle( style: const TextStyle(
decoration: TextDecoration.underline, decoration: TextDecoration.underline,
), ),
),
) )
], ],
) )
]; ];
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");
this.document = page.html; this.document = page.html;
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(await _renderSection(sec));
outHtml += sec.outerHtml;
} }
} }
var l = ListView.builder( var l = ListView.builder(
@ -110,7 +125,7 @@ class PageRenderer {
return l; return l;
} }
List<Widget> _renderSection(dom.Element sec) { Future<List<Widget>> _renderSection(dom.Element sec) async {
var out = <Widget>[]; var out = <Widget>[];
// Get Section Title // Get Section Title
var headings = sec.children.where( var headings = sec.children.where(
@ -208,7 +223,7 @@ class PageRenderer {
); // space paragraphs ); // space paragraphs
break; break;
case "figure": case "figure":
out.addAll(_renderImageFigure(element)); out.addAll(await _renderImageFigure(element));
out.add( out.add(
const SizedBox( const SizedBox(
height: 10, height: 10,
@ -216,7 +231,7 @@ class PageRenderer {
); );
break; break;
case "section": case "section":
out.addAll(_renderSection(element)); out.addAll(await _renderSection(element));
break; break;
case "dl": case "dl":
var dd = element.getElementsByTagName("dd").first; var dd = element.getElementsByTagName("dd").first;
@ -280,7 +295,7 @@ class PageRenderer {
for (var e in inner.body!.children) { for (var e in inner.body!.children) {
if (e.localName == "figure") { if (e.localName == "figure") {
// render image // render image
out.addAll(_renderImageFigure(e)); out.addAll(await _renderImageFigure(e));
out.add( out.add(
const SizedBox( const SizedBox(
height: 5, height: 5,
@ -440,6 +455,11 @@ class PageRenderer {
out.add(const SizedBox( out.add(const SizedBox(
height: 10, height: 10,
)); ));
var offlineImage = await StorageAccess.getOfflineImage(img
.attributes["src"]!
.split('/')
.last
.replaceAll(RegExp(r"(?!\..+?)\?.+"), ""));
out.add( out.add(
SizedBox( SizedBox(
width: width * 0.8, width: width * 0.8,
@ -473,6 +493,7 @@ class PageRenderer {
default: default:
break; break;
} }
element.remove(); element.remove();
} }
@ -484,7 +505,7 @@ class PageRenderer {
return out; return out;
} }
List<Widget> _renderImageFigure(dom.Element element) { Future<List<Widget>> _renderImageFigure(dom.Element element) async {
var out = <Widget>[]; var out = <Widget>[];
/// Image figure /// Image figure
@ -497,6 +518,12 @@ class PageRenderer {
if (figcap.isNotEmpty) { if (figcap.isNotEmpty) {
caption = figcap.first.text; // TODO: handle links caption = figcap.first.text; // TODO: handle links
} }
var offlineImage = await StorageAccess.getOfflineImage(img
.attributes["src"]!
.split('/')
.last
.replaceAll(RegExp(r"(?!\..+?)\?.+"), ""));
out.add(const SizedBox( out.add(const SizedBox(
height: 10, height: 10,
)); ));
@ -511,9 +538,10 @@ class PageRenderer {
imageUrl: img.attributes["src"]!.replaceAll("//", "https://"), imageUrl: img.attributes["src"]!.replaceAll("//", "https://"),
progressIndicatorBuilder: (context, url, downloadProgress) => progressIndicatorBuilder: (context, url, downloadProgress) =>
LinearProgressIndicator(value: downloadProgress.progress), LinearProgressIndicator(value: downloadProgress.progress),
errorWidget: (context, url, error) => Icon( errorWidget: (context, url, error) => (offlineImage != null)
Icons.error, ? Image.file(offlineImage)
color: scheme.error, : Flexible(
child: Text(loc.imageError),
), ),
), ),
), ),
@ -589,7 +617,9 @@ class PageRenderer {
content.add( content.add(
WidgetSpan( WidgetSpan(
child: SelectableText( child: SelectableText(
match.group(2)!, match.group(2)!.replaceAll(
RegExp(r'<(?:(?:\/b)|(?:\/i)|(?:\/a)|(?:b)|(?:i)|(?:a )).*?>'),
""),
onTap: () { onTap: () {
if (match.group(0)!.contains('rel="mw:ExtLink')) { if (match.group(0)!.contains('rel="mw:ExtLink')) {
// handle as an external link // handle as an external link
@ -635,8 +665,9 @@ class PageRenderer {
.toList(); .toList();
for (var s in needToFormat) { for (var s in needToFormat) {
content.add(TextSpan( content.add(TextSpan(
text: text: noFormatting[needToFormat.indexOf(s)].replaceAll(
noFormatting[needToFormat.indexOf(s)])); // add text before styled RegExp(r'<(?:(?:\/b)|(?:\/i)|(?:\/a)|(?:b)|(?:i)|(?:a )).*?>'),
""))); // add text before styled
var raw = s.group(0)!; var raw = s.group(0)!;
content.add( content.add(
TextSpan( TextSpan(

View file

@ -1,7 +1,12 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; 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:path_provider/path_provider.dart';
import 'package:voyagehandbook/api/classes.dart';
import 'package:voyagehandbook/api/wikimedia.dart';
/* /*
Voyage Handbook - The open-source WikiVoyage reader Voyage Handbook - The open-source WikiVoyage reader
@ -35,6 +40,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 +88,91 @@ class StorageAccess {
recent.writeAsStringSync(jsonEncode(recentContent)); recent.writeAsStringSync(jsonEncode(recentContent));
} }
} }
static Future<bool> 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<void> 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 = "<html><head></head><body>";
var sections = page.body!.children
.where((element) => element.localName == "section");
for (var el in sections) {
out += el.outerHtml;
}
out += "</body></html>";
var imgMatch = RegExp(r'<img.+?src="(.+?)".+?>')
.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<RawPage?> 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<File?> getOfflineImage(String key) async {
var files = Directory(
"${(await getApplicationDocumentsDirectory()).path}/offline/assets");
return File("${files.path}/$key");
}
} }

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

@ -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<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: 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(() {});
}
}

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

@ -1,3 +1,4 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:voyagehandbook/api/wikimedia.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/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
@ -77,34 +79,55 @@ class _ArticleViewState extends State<ArticleView> {
} }
void loadPage() async { void loadPage() async {
if (!mounted) return;
var renderer = PageRenderer( var renderer = PageRenderer(
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,
if (kDebugMode) { AppLocalizations.of(context)!);
_content = [
SizedBox(
width: MediaQuery.of(context).size.width * 0.9,
height: MediaQuery.of(context).size.height,
child: renderer
.renderFromPageHTML(await WikiApi.getRawPage(widget.pageKey)),
)
];
} else {
try { try {
_content = [ _content = [
SizedBox( SizedBox(
width: MediaQuery.of(context).size.width * 0.9, width: MediaQuery.of(context).size.width * 0.9,
height: MediaQuery.of(context).size.height, height: MediaQuery.of(context).size.height,
child: renderer child: await renderer
.renderFromPageHTML(await WikiApi.getRawPage(widget.pageKey)), .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 = [ _content = [
const Text( Text(
"Error while rendering:", 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,
style: PageStyles.h1, style: PageStyles.h1,
), ),
const SizedBox( const SizedBox(

View file

@ -1,8 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:voyagehandbook/api/classes.dart'; import 'package:voyagehandbook/api/classes.dart';
import 'package:voyagehandbook/api/wikimedia.dart'; import 'package:voyagehandbook/api/wikimedia.dart';
import 'package:voyagehandbook/util/storage.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 +38,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 +62,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 +132,126 @@ 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),
),
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: () { onTap: () {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
@ -145,13 +268,27 @@ class _SearchViewState extends State<SearchView> {
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Column( child: Column(
children: [ children: [
Row(children: [
Text( Text(
_searchResults[index].title, _searchResults[index].title,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight:
FontWeight.bold,
fontSize: fontSize:
16), // TODO: responsive sizing 16), // TODO: responsive sizing
), ),
if (_searchResults[index]
.downloaded)
const SizedBox(
width: 15,
),
if (_searchResults[index]
.downloaded)
const Icon(
Icons.download,
size: 15,
),
]),
const SizedBox( const SizedBox(
height: 10, height: 10,
), ),

View file

@ -49,6 +49,9 @@ dependencies:
flutter_map: ^5.0.0 flutter_map: ^5.0.0
latlong2: ^0.9.0 latlong2: ^0.9.0
logger: ^1.4.0 logger: ^1.4.0
flutter_localizations:
sdk: flutter
intl: any
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@ -68,6 +71,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