feat: complete download

This commit is contained in:
Matyáš Caras 2023-04-07 13:02:40 +02:00
parent f91ef2e68f
commit 33839b6b72
11 changed files with 335 additions and 78 deletions

View file

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

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 {

View file

@ -14,5 +14,13 @@
"about":"O Aplikcai", "about":"O Aplikcai",
"sourceCode":"Zdrojový kód", "sourceCode":"Zdrojový kód",
"creditsCreatedBy":"Vytvořil Matyáš Caras", "creditsCreatedBy":"Vytvořil Matyáš Caras",
"creditsAffiliation":"Není nijak spojeno s WikiVoyage" "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í."
} }

View file

@ -51,5 +51,22 @@
"about":"About", "about":"About",
"sourceCode":"Source code", "sourceCode":"Source code",
"creditsCreatedBy":"Created by Matyáš Caras", "creditsCreatedBy":"Created by Matyáš Caras",
"creditsAffiliation":"Not affiliated with WikiVoyage" "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

@ -68,7 +68,7 @@ Drawer genDrawer(int page, BuildContext context) => Drawer(
ListTile( ListTile(
selected: page == 3, selected: page == 3,
title: Text(AppLocalizations.of(context)!.downloadsTitle), title: Text(AppLocalizations.of(context)!.downloadsTitle),
leading: const Icon(Icons.search), leading: const Icon(Icons.download),
onTap: () => page == 3 onTap: () => page == 3
? Navigator.of(context).pop() ? Navigator.of(context).pop()
: Navigator.of(context).push( : Navigator.of(context).push(

View file

@ -7,6 +7,7 @@ import 'package:html/dom.dart' as dom;
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';
@ -49,7 +50,7 @@ class PageRenderer {
{this.offline = false}); {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,
@ -85,14 +86,17 @@ class PageRenderer {
children: [ children: [
SelectableText(loc.attrUnder), SelectableText(loc.attrUnder),
const SizedBox( const SizedBox(
width: 5, width: 2,
), ),
SelectableText( Flexible(
page.license.title, child: SelectableText(
onTap: () => launchUrl(Uri.parse(page.license.url), page.license.title,
mode: LaunchMode.externalApplication), textAlign: TextAlign.center,
style: const TextStyle( onTap: () => launchUrl(Uri.parse(page.license.url),
decoration: TextDecoration.underline, mode: LaunchMode.externalApplication),
style: const TextStyle(
decoration: TextDecoration.underline,
),
), ),
) )
], ],
@ -103,7 +107,7 @@ class PageRenderer {
var sections = document.body!.getElementsByTagName("section"); var sections = document.body!.getElementsByTagName("section");
for (var sec in sections) { for (var sec in sections) {
if (sec.localName == "section") { if (sec.localName == "section") {
if (!offline) out.addAll(_renderSection(sec)); if (!offline) out.addAll(await _renderSection(sec));
outHtml += sec.outerHtml; outHtml += sec.outerHtml;
} }
} }
@ -115,7 +119,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(
@ -213,7 +217,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,
@ -221,7 +225,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;
@ -285,7 +289,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,
@ -352,13 +356,24 @@ 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,
height: height * 0.3, height: height * 0.3,
child: WidgetZoom( child: WidgetZoom(
zoomWidget: zoomWidget: CachedNetworkImage(
CachedNetworkImage(imageUrl: img.attributes["src"]!), imageUrl: img.attributes["src"]!,
errorWidget: (context, url, error) => (offlineImage != null)
? Image.file(offlineImage)
: Flexible(
child: Text(loc.imageError),
),
),
heroAnimationTag: 'tag', heroAnimationTag: 'tag',
), ),
), ),
@ -396,7 +411,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
@ -409,6 +424,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,
)); ));
@ -423,10 +444,11 @@ 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),
),
), ),
), ),
), ),
@ -501,7 +523,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
@ -547,8 +571,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

@ -2,8 +2,10 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:html/parser.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'; import 'package:voyagehandbook/api/wikimedia.dart';
/* /*
@ -89,26 +91,36 @@ class StorageAccess {
static Future<bool> isDownloaded(String pageKey) async { static Future<bool> isDownloaded(String pageKey) async {
var files = var files =
Directory("${(await getApplicationDocumentsDirectory()).path}/recent"); Directory("${(await getApplicationDocumentsDirectory()).path}/offline");
if (!files.existsSync()) files.createSync();
var offlinePage = File("${files.path}/$pageKey"); var offlinePage = File("${files.path}/$pageKey");
return offlinePage.existsSync(); return offlinePage.existsSync();
} }
static Future<void> downloadArticle(String pageKey, String pageTitle) async { static Future<void> downloadArticle(String pageKey, String pageTitle) async {
var files = try {
Directory("${(await getApplicationDocumentsDirectory()).path}/offline"); var files = Directory(
var offlinePage = File("${files.path}/$pageKey"); "${(await getApplicationDocumentsDirectory()).path}/offline");
var page = parse(await WikiApi.getRawPage(pageKey)) if (!files.existsSync()) files.createSync();
.body! var offlinePage = File("${files.path}/$pageKey");
.getElementsByTagName("section"); var raw = await WikiApi.getRawPage(pageKey);
var out = "<html><head></head><body>"; var page = parse(raw.html);
for (var el in page) { var out = "<html><head></head><body>";
out += el.outerHtml; var sections = page.body!.children
var imgMatch = RegExp(r'<img src="(.+?)">').allMatches(el.innerHtml); .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) { if (imgMatch.isNotEmpty) {
// download images offline // download images offline
for (var match in imgMatch) { for (var match in imgMatch) {
var src = match.group(1)!; var src = match.group(1)!;
if (!src.startsWith("https://")) {
src = src.replaceAll("//", "https://");
}
var r = await Dio().get( var r = await Dio().get(
src, src,
options: Options( options: Options(
@ -119,16 +131,48 @@ class StorageAccess {
}, },
), ),
); );
var img = File("${files.path}/${src.split('/').last}"); 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); print(img.path);
var openImg = img.openSync(mode: FileMode.write); var openImg = img.openSync(mode: FileMode.write);
openImg.writeFromSync(r.data); 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);
} }
if (page.isEmpty) return Future.error("No sections to save"); }
offlinePage.writeAsStringSync(
jsonEncode({"title": pageTitle, "key": pageKey, "content": out}), static Future<RawPage?> getOfflinePage(String pageKey) async {
mode: FileMode.writeOnly); 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");
} }
} }

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:voyagehandbook/util/drawer.dart'; import 'package:voyagehandbook/util/drawer.dart';
import 'package:voyagehandbook/util/storage.dart'; import 'package:voyagehandbook/util/storage.dart';
import 'package:voyagehandbook/util/styles.dart';
import 'package:voyagehandbook/views/pageview.dart'; import 'package:voyagehandbook/views/pageview.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
@ -29,16 +30,21 @@ class _DownloadsViewState extends State<DownloadsView> {
title: Text(AppLocalizations.of(context)!.downloadsTitle), title: Text(AppLocalizations.of(context)!.downloadsTitle),
), ),
drawer: genDrawer(3, context), drawer: genDrawer(3, context),
body: SizedBox( body: Center(
height: MediaQuery.of(context).size.height, child: SizedBox(
width: MediaQuery.of(context).size.width * 0.9, height: MediaQuery.of(context).size.height,
child: Center( width: MediaQuery.of(context).size.width * 0.9,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: _isLoading children: _isLoading
? [const CircularProgressIndicator()] ? [const CircularProgressIndicator()]
: _content.isEmpty : _content.isEmpty
? [Text(AppLocalizations.of(context)!.noDownloads)] ? [
Text(
AppLocalizations.of(context)!.noDownloads,
textAlign: TextAlign.center,
)
]
: _content, : _content,
), ),
), ),
@ -52,7 +58,7 @@ class _DownloadsViewState extends State<DownloadsView> {
files.length, files.length,
(index) => SizedBox( (index) => SizedBox(
width: MediaQuery.of(context).size.width * 0.9, width: MediaQuery.of(context).size.width * 0.9,
height: MediaQuery.of(context).size.height * 0.15, height: MediaQuery.of(context).size.height * 0.1,
child: InkWell( child: InkWell(
onTap: () => Navigator.of(context).push( onTap: () => Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
@ -62,9 +68,13 @@ class _DownloadsViewState extends State<DownloadsView> {
), ),
), ),
), ),
child: Padding( child: Align(
padding: const EdgeInsets.all(8), alignment: Alignment.center,
child: Text(files[index]["title"]), child: Text(
files[index]["title"],
textAlign: TextAlign.center,
style: PageStyles.h1,
),
), ),
), ),
), ),

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';
@ -78,32 +79,52 @@ 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,
AppLocalizations.of(context)!); AppLocalizations.of(context)!);
if (kDebugMode) {
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)),
) )
]; ];
} else { } catch (e) {
try { if (e.toString().contains("Failed host lookup")) {
_content = [ // user is offline
SizedBox( var offline = await StorageAccess.getOfflinePage(widget.pageKey);
width: MediaQuery.of(context).size.width * 0.9, if (offline == null) {
height: MediaQuery.of(context).size.height, // Not downloaded, show error
child: renderer if (!mounted) return;
.renderFromPageHTML(await WikiApi.getRawPage(widget.pageKey)), _content = [
) Text(
]; AppLocalizations.of(context)!.renderError,
} catch (e) { 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 = [ _content = [
Text( Text(
AppLocalizations.of(context)!.renderError, AppLocalizations.of(context)!.renderError,

View file

@ -1,6 +1,7 @@
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 'package:flutter_gen/gen_l10n/app_localizations.dart';
@ -145,6 +146,109 @@ class _SearchViewState extends State<SearchView> {
_searchResults[index] _searchResults[index]
.title), .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),
),
],
), ),
); );
}, },
@ -164,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: [
Text( Row(children: [
_searchResults[index].title, Text(
style: const TextStyle( _searchResults[index].title,
fontWeight: FontWeight.bold, style: const TextStyle(
fontSize: fontWeight:
16), // TODO: responsive sizing FontWeight.bold,
), fontSize:
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,
), ),