Merge branch 'main' of https://git.mnau.xyz/hernik/voyagehandbook
This commit is contained in:
commit
76ec1883d8
15 changed files with 603 additions and 72 deletions
14
README.md
14
README.md
|
@ -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
3
l10n.yaml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
arb-dir: lib/l10n
|
||||||
|
template-arb-file: app_en.arb
|
||||||
|
output-localization-file: app_localizations.dart
|
|
@ -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);
|
||||||
|
|
|
@ -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
26
lib/l10n/app_cs.arb
Normal 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
72
lib/l10n/app_en.arb
Normal 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."
|
||||||
|
}
|
|
@ -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),
|
||||||
|
|
|
@ -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),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
85
lib/views/downloads.dart
Normal 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(() {});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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(
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue