feat: create basic article viewing
This commit is contained in:
parent
bb2524b2ba
commit
bb4e4fa9f8
12 changed files with 579 additions and 28 deletions
|
@ -2,6 +2,7 @@
|
||||||
Access [WikiVoyage](https://en.wikivoyage.org) conveniently from your phone!
|
Access [WikiVoyage](https://en.wikivoyage.org) conveniently from your phone!
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
- [ ] Navigation in articles by heading
|
||||||
- [ ] Bookmark articles
|
- [ ] Bookmark articles
|
||||||
- [ ] Download articles
|
- [ ] Download articles
|
||||||
- [ ] Download whole countries
|
- [ ] Download whole countries
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="cafe.caras.voyagehandbook">
|
package="cafe.caras.voyagehandbook">
|
||||||
<application
|
<application
|
||||||
android:label="voyagehandbook"
|
android:label="Voyage Handbook"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<activity
|
||||||
|
|
|
@ -19,3 +19,38 @@ class SearchResponse {
|
||||||
/// Connect the generated function to the `toJson` method.
|
/// Connect the generated function to the `toJson` method.
|
||||||
Map<String, dynamic> toJson() => _$SearchResponseToJson(this);
|
Map<String, dynamic> toJson() => _$SearchResponseToJson(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class LicenseAttribution {
|
||||||
|
final String url;
|
||||||
|
final String title;
|
||||||
|
const LicenseAttribution(this.url, this.title);
|
||||||
|
factory LicenseAttribution.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$LicenseAttributionFromJson(json);
|
||||||
|
|
||||||
|
/// Connect the generated function to the `toJson` method.
|
||||||
|
Map<String, dynamic> toJson() => _$LicenseAttributionToJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class RawPage {
|
||||||
|
final int id;
|
||||||
|
final String key;
|
||||||
|
final String title;
|
||||||
|
@JsonKey(fromJson: _editedFromJson, toJson: _editedToJson, name: "latest")
|
||||||
|
final String edited;
|
||||||
|
final String html;
|
||||||
|
final LicenseAttribution license;
|
||||||
|
|
||||||
|
const RawPage(
|
||||||
|
this.id, this.key, this.title, this.edited, this.html, this.license);
|
||||||
|
|
||||||
|
factory RawPage.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$RawPageFromJson(json);
|
||||||
|
|
||||||
|
/// Connect the generated function to the `toJson` method.
|
||||||
|
Map<String, dynamic> toJson() => _$RawPageToJson(this);
|
||||||
|
|
||||||
|
static String _editedFromJson(Map<String, dynamic> json) => json["timestamp"];
|
||||||
|
static Map<String, dynamic> _editedToJson(String json) => {"timestamp": json};
|
||||||
|
}
|
||||||
|
|
|
@ -23,3 +23,33 @@ Map<String, dynamic> _$SearchResponseToJson(SearchResponse instance) =>
|
||||||
'excerpt': instance.excerpt,
|
'excerpt': instance.excerpt,
|
||||||
'description': instance.description,
|
'description': instance.description,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
LicenseAttribution _$LicenseAttributionFromJson(Map<String, dynamic> json) =>
|
||||||
|
LicenseAttribution(
|
||||||
|
json['url'] as String,
|
||||||
|
json['title'] as String,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$LicenseAttributionToJson(LicenseAttribution instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'url': instance.url,
|
||||||
|
'title': instance.title,
|
||||||
|
};
|
||||||
|
|
||||||
|
RawPage _$RawPageFromJson(Map<String, dynamic> json) => RawPage(
|
||||||
|
json['id'] as int,
|
||||||
|
json['key'] as String,
|
||||||
|
json['title'] as String,
|
||||||
|
RawPage._editedFromJson(json['latest'] as Map<String, dynamic>),
|
||||||
|
json['html'] as String,
|
||||||
|
LicenseAttribution.fromJson(json['license'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$RawPageToJson(RawPage instance) => <String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'key': instance.key,
|
||||||
|
'title': instance.title,
|
||||||
|
'latest': RawPage._editedToJson(instance.edited),
|
||||||
|
'html': instance.html,
|
||||||
|
'license': instance.license,
|
||||||
|
};
|
||||||
|
|
|
@ -8,11 +8,13 @@ class WikiApi {
|
||||||
BaseOptions(baseUrl: "https://api.wikimedia.org/core/v1/wikivoyage/en/"));
|
BaseOptions(baseUrl: "https://api.wikimedia.org/core/v1/wikivoyage/en/"));
|
||||||
|
|
||||||
static Future<Response> _getRequest(String endpoint) async {
|
static Future<Response> _getRequest(String endpoint) async {
|
||||||
return await _dio.get(endpoint,
|
return await _dio.get(
|
||||||
|
endpoint,
|
||||||
options: Options(
|
options: Options(
|
||||||
headers: {"User-Agent": "VoyageHandbook/1.0.0"},
|
headers: {"User-Agent": "VoyageHandbook/1.0.0"},
|
||||||
validateStatus: (_) => true,
|
validateStatus: (_) => true,
|
||||||
responseType: ResponseType.plain));
|
responseType: ResponseType.plain),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Searches for pages using the WikiMedia API
|
/// Searches for pages using the WikiMedia API
|
||||||
|
@ -23,4 +25,10 @@ class WikiApi {
|
||||||
return List<SearchResponse>.generate(
|
return List<SearchResponse>.generate(
|
||||||
json.length, (index) => SearchResponse.fromJson(json[index]));
|
json.length, (index) => SearchResponse.fromJson(json[index]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<RawPage> getRawPage(String key) async {
|
||||||
|
var r = await _getRequest("page/$key/with_html");
|
||||||
|
if (r.statusCode! > 399) return Future.error("API error ${r.statusCode}");
|
||||||
|
return RawPage.fromJson(jsonDecode(r.data));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,16 +3,24 @@ import 'package:voyagehandbook/views/home.dart';
|
||||||
|
|
||||||
import '../views/search.dart';
|
import '../views/search.dart';
|
||||||
|
|
||||||
|
/// Used to generate the drawer
|
||||||
|
///
|
||||||
|
/// Use `0` in `page` if you don't want any tile selected
|
||||||
Drawer genDrawer(int page, BuildContext context) => Drawer(
|
Drawer genDrawer(int page, BuildContext context) => Drawer(
|
||||||
child: ListView(
|
child: ListView(
|
||||||
children: [
|
children: [
|
||||||
DrawerHeader(
|
DrawerHeader(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: const [
|
children: const [
|
||||||
Text("Voyage Handbook"),
|
Text(
|
||||||
Text("Created by Matyáš Caras")
|
"Voyage Handbook",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Text("Created by Matyáš Caras"),
|
||||||
|
Text("Thanks to WikiVoyage")
|
||||||
],
|
],
|
||||||
)),
|
),
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
selected: page == 1,
|
selected: page == 1,
|
||||||
title: const Text("Home"),
|
title: const Text("Home"),
|
||||||
|
|
198
lib/util/render.dart
Normal file
198
lib/util/render.dart
Normal file
|
@ -0,0 +1,198 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:html/parser.dart';
|
||||||
|
import 'package:html/dom.dart' as dom;
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:voyagehandbook/api/classes.dart';
|
||||||
|
import 'package:voyagehandbook/util/styles.dart';
|
||||||
|
|
||||||
|
final _ignoredTags = ["style", "script"];
|
||||||
|
|
||||||
|
/// Used to create [TextSpan] from raw HTML from WM API
|
||||||
|
List<Widget> renderFromPageHTML(RawPage page) {
|
||||||
|
var out = <Widget>[
|
||||||
|
const SizedBox(
|
||||||
|
height: 10,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
page.title,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: PageStyles.h1,
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 10,
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Text("From"),
|
||||||
|
const SizedBox(
|
||||||
|
width: 5,
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => launchUrl(
|
||||||
|
Uri.parse("https://en.wikivoyage.org/wiki/${page.key}"),
|
||||||
|
mode: LaunchMode.externalApplication),
|
||||||
|
child: const Text(
|
||||||
|
"WikiVoyage",
|
||||||
|
style: TextStyle(
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Text("under"),
|
||||||
|
const SizedBox(
|
||||||
|
width: 5,
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => launchUrl(Uri.parse(page.license.url),
|
||||||
|
mode: LaunchMode.externalApplication),
|
||||||
|
child: Text(
|
||||||
|
page.license.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
];
|
||||||
|
var document = parse(page.html);
|
||||||
|
var sections = document.body!.getElementsByTagName("section");
|
||||||
|
for (var sec in sections) {
|
||||||
|
if (sec.localName == "section") {
|
||||||
|
out.addAll(_renderSection(sec));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _renderSection(dom.Element sec) {
|
||||||
|
var out = <Widget>[];
|
||||||
|
// Get Section Title
|
||||||
|
var headings = sec.children
|
||||||
|
.where((element) => ["h2", "h3", "h4", "h5"].contains(element.localName));
|
||||||
|
var sectionTitle = (headings.isNotEmpty) ? headings.first : null;
|
||||||
|
if (sectionTitle != null) {
|
||||||
|
switch (sectionTitle.localName) {
|
||||||
|
case "h2":
|
||||||
|
out.add(
|
||||||
|
const SizedBox(
|
||||||
|
height: 10,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
out.add(Text(sectionTitle.text, style: PageStyles.h2));
|
||||||
|
break;
|
||||||
|
case "h3":
|
||||||
|
out.add(
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
sectionTitle.text,
|
||||||
|
style: PageStyles.h3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "h4":
|
||||||
|
out.add(
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(sectionTitle.text, style: PageStyles.h4),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "h5":
|
||||||
|
out.add(
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(sectionTitle.text, style: PageStyles.h5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
out.add(
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(sectionTitle.text),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// out.add(
|
||||||
|
// const SizedBox(
|
||||||
|
// height: 5,
|
||||||
|
// ),
|
||||||
|
// );
|
||||||
|
}
|
||||||
|
|
||||||
|
// create TextSpans from text content
|
||||||
|
for (var element in sec.children) {
|
||||||
|
// Go through all section's children
|
||||||
|
for (var t in _ignoredTags) {
|
||||||
|
var ignored = element.getElementsByTagName(t);
|
||||||
|
if (ignored.isNotEmpty) {
|
||||||
|
// Remove ignored tags
|
||||||
|
for (var element in ignored) {
|
||||||
|
element.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (element.localName) {
|
||||||
|
case "p":
|
||||||
|
case "link":
|
||||||
|
var paraSpans = <TextSpan>[];
|
||||||
|
var input = element.innerHtml
|
||||||
|
.replaceAll(RegExp(r"<(?!(?:\/b)|(?:\/i)|b|i).+?>"), "")
|
||||||
|
.replaceAll("\\n", "\r");
|
||||||
|
var noFormatting =
|
||||||
|
input.split(RegExp(r"(?:<b .+?>.+?<\/b>)|<i .+?>.+?<\/i>"));
|
||||||
|
var needToFormat = RegExp(r"(?:<b .+?>.+?<\/b>)|<i .+?>.+?<\/i>")
|
||||||
|
.allMatches(input)
|
||||||
|
.toList();
|
||||||
|
for (var s in needToFormat) {
|
||||||
|
paraSpans.add(TextSpan(
|
||||||
|
text: noFormatting[
|
||||||
|
needToFormat.indexOf(s)])); // add text before styled
|
||||||
|
var raw = s.group(0)!;
|
||||||
|
paraSpans.add(
|
||||||
|
TextSpan(
|
||||||
|
text: RegExp(r">(.+?)<").firstMatch(raw)!.group(1),
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: (raw.contains("<b")) ? FontWeight.bold : null,
|
||||||
|
fontStyle: (raw.contains("<i")) ? FontStyle.italic : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
); // add styled
|
||||||
|
}
|
||||||
|
paraSpans.add(TextSpan(text: noFormatting.last)); // add last
|
||||||
|
out.add(RichText(
|
||||||
|
text: TextSpan(children: paraSpans),
|
||||||
|
textAlign: TextAlign.justify,
|
||||||
|
)); // add paragraph spans as single rich text
|
||||||
|
out.add(
|
||||||
|
const SizedBox(
|
||||||
|
height: 5,
|
||||||
|
),
|
||||||
|
); // space paragraphs
|
||||||
|
break;
|
||||||
|
case "section":
|
||||||
|
out.addAll(_renderSection(element));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.add(
|
||||||
|
const SizedBox(
|
||||||
|
height: 5,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return out;
|
||||||
|
}
|
9
lib/util/styles.dart
Normal file
9
lib/util/styles.dart
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class PageStyles {
|
||||||
|
static const h1 = TextStyle(fontSize: 26, fontWeight: FontWeight.bold);
|
||||||
|
static const h2 = TextStyle(fontSize: 22, fontWeight: FontWeight.bold);
|
||||||
|
static const h3 = TextStyle(fontSize: 20);
|
||||||
|
static const h5 = TextStyle(fontSize: 18);
|
||||||
|
static const h4 = TextStyle(fontSize: 16);
|
||||||
|
}
|
55
lib/views/pageview.dart
Normal file
55
lib/views/pageview.dart
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:voyagehandbook/api/wikimedia.dart';
|
||||||
|
import 'package:voyagehandbook/util/drawer.dart';
|
||||||
|
import 'package:voyagehandbook/util/render.dart';
|
||||||
|
|
||||||
|
/// Renders a single WikiVoyage article
|
||||||
|
class ArticleView extends StatefulWidget {
|
||||||
|
const ArticleView({super.key, required this.pageKey});
|
||||||
|
final String pageKey;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ArticleView> createState() => _ArticleViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ArticleViewState extends State<ArticleView> {
|
||||||
|
var _content = <Widget>[];
|
||||||
|
@override
|
||||||
|
initState() {
|
||||||
|
super.initState();
|
||||||
|
loadPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
actions: (kDebugMode)
|
||||||
|
? [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => loadPage(),
|
||||||
|
icon: const Icon(Icons.restart_alt))
|
||||||
|
]
|
||||||
|
: null),
|
||||||
|
drawer: genDrawer(0, context),
|
||||||
|
body: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
height: MediaQuery.of(context).size.height,
|
||||||
|
width: MediaQuery.of(context).size.width * 0.9,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: _content,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadPage() async {
|
||||||
|
_content = renderFromPageHTML(await WikiApi.getRawPage(widget.pageKey));
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/src/widgets/framework.dart';
|
import 'package:voyagehandbook/api/classes.dart';
|
||||||
import 'package:flutter/src/widgets/placeholder.dart';
|
|
||||||
import 'package:voyagehandbook/api/wikimedia.dart';
|
import 'package:voyagehandbook/api/wikimedia.dart';
|
||||||
|
import 'package:voyagehandbook/views/pageview.dart';
|
||||||
|
|
||||||
import '../util/drawer.dart';
|
import '../util/drawer.dart';
|
||||||
|
|
||||||
|
@ -14,12 +14,16 @@ class SearchView extends StatefulWidget {
|
||||||
|
|
||||||
class _SearchViewState extends State<SearchView> {
|
class _SearchViewState extends State<SearchView> {
|
||||||
final _searchController = TextEditingController();
|
final _searchController = TextEditingController();
|
||||||
|
var _searchResults = <SearchResponse>[];
|
||||||
|
var _showBox = false;
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text("Search WikiVoyage")),
|
appBar: AppBar(title: const Text("Search WikiVoyage")),
|
||||||
drawer: genDrawer(2, context),
|
drawer: genDrawer(2, context),
|
||||||
body: Center(
|
body: SingleChildScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
child: Center(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: MediaQuery.of(context).size.height,
|
height: MediaQuery.of(context).size.height,
|
||||||
width: MediaQuery.of(context).size.width * 0.9,
|
width: MediaQuery.of(context).size.width * 0.9,
|
||||||
|
@ -32,14 +36,135 @@ class _SearchViewState extends State<SearchView> {
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (_searchController.text == "") return;
|
if (_searchController.text == "") return;
|
||||||
|
_showBox = true;
|
||||||
|
setState(() {});
|
||||||
var r = await WikiApi.search(_searchController.text);
|
var r = await WikiApi.search(_searchController.text);
|
||||||
print(r[0].excerpt);
|
_searchResults = r;
|
||||||
|
setState(() {});
|
||||||
},
|
},
|
||||||
child: const Text("Search"))
|
child: const Text("Search"),
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 15,
|
||||||
|
),
|
||||||
|
AnimatedOpacity(
|
||||||
|
opacity: (!_showBox) ? 0 : 1,
|
||||||
|
duration: const Duration(milliseconds: 500),
|
||||||
|
child: SizedBox(
|
||||||
|
height: MediaQuery.of(context).size.height -
|
||||||
|
301, // TODO: maybe not responsive?
|
||||||
|
width: MediaQuery.of(context).size.width * 0.9,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.secondaryContainer,
|
||||||
|
width: 1),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: (_searchResults.isNotEmpty)
|
||||||
|
? ListView.builder(
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
var span = <TextSpan>[];
|
||||||
|
var searchMatches = RegExp(
|
||||||
|
r'<span class="searchmatch">.+?<\/span>')
|
||||||
|
.allMatches(_searchResults[index].excerpt);
|
||||||
|
if (searchMatches.isEmpty) {
|
||||||
|
span = [
|
||||||
|
TextSpan(
|
||||||
|
text: _searchResults[index].excerpt)
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// create emphasis on words matching search per the span element from API
|
||||||
|
var text = _searchResults[index].excerpt;
|
||||||
|
for (var match in searchMatches) {
|
||||||
|
var split = text.split(match
|
||||||
|
.group(0)!); // split by span element
|
||||||
|
span.add(TextSpan(
|
||||||
|
text:
|
||||||
|
split[0])); // add text before span
|
||||||
|
span.add(
|
||||||
|
TextSpan(
|
||||||
|
text: match.group(0)!.replaceAll(
|
||||||
|
RegExp(r'<\/?span.*?>'), ""),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
); // add span as bold
|
||||||
|
|
||||||
|
text = text
|
||||||
|
.replaceFirst(
|
||||||
|
(split.isNotEmpty) ? split[0] : "",
|
||||||
|
"")
|
||||||
|
.replaceFirst(match.group(0)!,
|
||||||
|
""); // set text for next span to add
|
||||||
|
}
|
||||||
|
span.add(
|
||||||
|
TextSpan(text: text),
|
||||||
|
); // add text we didn't add before
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.only(left: 8, right: 8),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 150,
|
||||||
|
child: Card(
|
||||||
|
elevation: 2,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (c) => ArticleView(
|
||||||
|
pageKey: _searchResults[index]
|
||||||
|
.key),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_searchResults[index].title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize:
|
||||||
|
16), // TODO: responsive sizing
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 10,
|
||||||
|
),
|
||||||
|
RichText(
|
||||||
|
textAlign: TextAlign.justify,
|
||||||
|
text: TextSpan(children: span),
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 10,
|
||||||
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
itemCount: _searchResults.length,
|
||||||
|
)
|
||||||
|
: const SizedBox(
|
||||||
|
height: 50,
|
||||||
|
width: 50,
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
80
pubspec.lock
80
pubspec.lock
|
@ -161,6 +161,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.2"
|
version: "3.0.2"
|
||||||
|
csslib:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: csslib
|
||||||
|
sha256: b36c7f7e24c0bdf1bf9a3da461c837d1de64b9f8beb190c9011d8c72a3dfd745
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.17.2"
|
||||||
cupertino_icons:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -272,6 +280,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
html:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: html
|
||||||
|
sha256: "79d498e6d6761925a34ee5ea8fa6dfef38607781d2fa91e37523474282af55cb"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.15.2"
|
||||||
http_multi_server:
|
http_multi_server:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -645,6 +661,70 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.1"
|
version: "1.3.1"
|
||||||
|
url_launcher:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: url_launcher
|
||||||
|
sha256: "75f2846facd11168d007529d6cd8fcb2b750186bea046af9711f10b907e1587e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.1.10"
|
||||||
|
url_launcher_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_android
|
||||||
|
sha256: dd729390aa936bf1bdf5cd1bc7468ff340263f80a2c4f569416507667de8e3c8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.0.26"
|
||||||
|
url_launcher_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_ios
|
||||||
|
sha256: "3dedc66ca3c0bef9e6a93c0999aee102556a450afcc1b7bcfeace7a424927d92"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.1.3"
|
||||||
|
url_launcher_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_linux
|
||||||
|
sha256: "206fb8334a700ef7754d6a9ed119e7349bc830448098f21a69bf1b4ed038cabc"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.4"
|
||||||
|
url_launcher_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_macos
|
||||||
|
sha256: "0ef2b4f97942a16523e51256b799e9aa1843da6c60c55eefbfa9dbc2dcb8331a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.4"
|
||||||
|
url_launcher_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_platform_interface
|
||||||
|
sha256: "6c9ca697a5ae218ce56cece69d46128169a58aa8653c1b01d26fcd4aad8c4370"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
url_launcher_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_web
|
||||||
|
sha256: "81fe91b6c4f84f222d186a9d23c73157dc4c8e1c71489c4d08be1ad3b228f1aa"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.16"
|
||||||
|
url_launcher_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_windows
|
||||||
|
sha256: a83ba3607a507758669cfafb03f9de09bf6e6280c14d9b9cb18f013e406dcacd
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.5"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
@ -41,6 +41,8 @@ dependencies:
|
||||||
json_serializable: ^6.6.1
|
json_serializable: ^6.6.1
|
||||||
json_annotation: ^4.8.0
|
json_annotation: ^4.8.0
|
||||||
dynamic_color: ^1.6.2
|
dynamic_color: ^1.6.2
|
||||||
|
html: ^0.15.2
|
||||||
|
url_launcher: ^6.1.10
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
Loading…
Reference in a new issue