From bb4e4fa9f8f5a000bd2a9b1583b0d5b48fbf5052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Fri, 24 Mar 2023 23:48:55 +0100 Subject: [PATCH] feat: create basic article viewing --- README.md | 1 + android/app/src/main/AndroidManifest.xml | 2 +- lib/api/classes.dart | 35 ++++ lib/api/classes.g.dart | 30 ++++ lib/api/wikimedia.dart | 18 ++- lib/util/drawer.dart | 20 ++- lib/util/render.dart | 198 +++++++++++++++++++++++ lib/util/styles.dart | 9 ++ lib/views/pageview.dart | 55 +++++++ lib/views/search.dart | 157 ++++++++++++++++-- pubspec.lock | 80 +++++++++ pubspec.yaml | 2 + 12 files changed, 579 insertions(+), 28 deletions(-) create mode 100644 lib/util/render.dart create mode 100644 lib/util/styles.dart create mode 100644 lib/views/pageview.dart diff --git a/README.md b/README.md index 2280cbb..789aefc 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ Access [WikiVoyage](https://en.wikivoyage.org) conveniently from your phone! ## Roadmap +- [ ] Navigation in articles by heading - [ ] Bookmark articles - [ ] Download articles - [ ] Download whole countries diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 42b630e..2afe0ec 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ toJson() => _$SearchResponseToJson(this); } + +@JsonSerializable() +class LicenseAttribution { + final String url; + final String title; + const LicenseAttribution(this.url, this.title); + factory LicenseAttribution.fromJson(Map json) => + _$LicenseAttributionFromJson(json); + + /// Connect the generated function to the `toJson` method. + Map 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 json) => + _$RawPageFromJson(json); + + /// Connect the generated function to the `toJson` method. + Map toJson() => _$RawPageToJson(this); + + static String _editedFromJson(Map json) => json["timestamp"]; + static Map _editedToJson(String json) => {"timestamp": json}; +} diff --git a/lib/api/classes.g.dart b/lib/api/classes.g.dart index f789398..67717ce 100644 --- a/lib/api/classes.g.dart +++ b/lib/api/classes.g.dart @@ -23,3 +23,33 @@ Map _$SearchResponseToJson(SearchResponse instance) => 'excerpt': instance.excerpt, 'description': instance.description, }; + +LicenseAttribution _$LicenseAttributionFromJson(Map json) => + LicenseAttribution( + json['url'] as String, + json['title'] as String, + ); + +Map _$LicenseAttributionToJson(LicenseAttribution instance) => + { + 'url': instance.url, + 'title': instance.title, + }; + +RawPage _$RawPageFromJson(Map json) => RawPage( + json['id'] as int, + json['key'] as String, + json['title'] as String, + RawPage._editedFromJson(json['latest'] as Map), + json['html'] as String, + LicenseAttribution.fromJson(json['license'] as Map), + ); + +Map _$RawPageToJson(RawPage instance) => { + 'id': instance.id, + 'key': instance.key, + 'title': instance.title, + 'latest': RawPage._editedToJson(instance.edited), + 'html': instance.html, + 'license': instance.license, + }; diff --git a/lib/api/wikimedia.dart b/lib/api/wikimedia.dart index 8c5e9a4..69de0ed 100644 --- a/lib/api/wikimedia.dart +++ b/lib/api/wikimedia.dart @@ -8,11 +8,13 @@ class WikiApi { BaseOptions(baseUrl: "https://api.wikimedia.org/core/v1/wikivoyage/en/")); static Future _getRequest(String endpoint) async { - return await _dio.get(endpoint, - options: Options( - headers: {"User-Agent": "VoyageHandbook/1.0.0"}, - validateStatus: (_) => true, - responseType: ResponseType.plain)); + return await _dio.get( + endpoint, + options: Options( + headers: {"User-Agent": "VoyageHandbook/1.0.0"}, + validateStatus: (_) => true, + responseType: ResponseType.plain), + ); } /// Searches for pages using the WikiMedia API @@ -23,4 +25,10 @@ class WikiApi { return List.generate( json.length, (index) => SearchResponse.fromJson(json[index])); } + + static Future 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)); + } } diff --git a/lib/util/drawer.dart b/lib/util/drawer.dart index 6df759b..4f9ee73 100644 --- a/lib/util/drawer.dart +++ b/lib/util/drawer.dart @@ -3,16 +3,24 @@ import 'package:voyagehandbook/views/home.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( child: ListView( children: [ DrawerHeader( - child: Column( - children: const [ - Text("Voyage Handbook"), - Text("Created by Matyáš Caras") - ], - )), + child: Column( + children: const [ + Text( + "Voyage Handbook", + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text("Created by Matyáš Caras"), + Text("Thanks to WikiVoyage") + ], + ), + ), ListTile( selected: page == 1, title: const Text("Home"), diff --git a/lib/util/render.dart b/lib/util/render.dart new file mode 100644 index 0000000..ad41117 --- /dev/null +++ b/lib/util/render.dart @@ -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 renderFromPageHTML(RawPage page) { + var out = [ + 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 _renderSection(dom.Element sec) { + var out = []; + // 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 = []; + var input = element.innerHtml + .replaceAll(RegExp(r"<(?!(?:\/b)|(?:\/i)|b|i).+?>"), "") + .replaceAll("\\n", "\r"); + var noFormatting = + input.split(RegExp(r"(?:.+?<\/b>)|.+?<\/i>")); + var needToFormat = RegExp(r"(?:.+?<\/b>)|.+?<\/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(" createState() => _ArticleViewState(); +} + +class _ArticleViewState extends State { + var _content = []; + @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(() {}); + } +} diff --git a/lib/views/search.dart b/lib/views/search.dart index 1929a67..4ba959d 100644 --- a/lib/views/search.dart +++ b/lib/views/search.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:flutter/src/widgets/framework.dart'; -import 'package:flutter/src/widgets/placeholder.dart'; +import 'package:voyagehandbook/api/classes.dart'; import 'package:voyagehandbook/api/wikimedia.dart'; +import 'package:voyagehandbook/views/pageview.dart'; import '../util/drawer.dart'; @@ -14,29 +14,154 @@ class SearchView extends StatefulWidget { class _SearchViewState extends State { final _searchController = TextEditingController(); + var _searchResults = []; + var _showBox = false; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("Search WikiVoyage")), drawer: genDrawer(2, 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: [ - TextField( - controller: _searchController, - ), - TextButton( + body: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Center( + child: SizedBox( + height: MediaQuery.of(context).size.height, + width: MediaQuery.of(context).size.width * 0.9, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _searchController, + ), + TextButton( onPressed: () async { if (_searchController.text == "") return; + _showBox = true; + setState(() {}); 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 = []; + var searchMatches = RegExp( + r'.+?<\/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(), + ), + ), + ), + ) + ], + ), ), ), ), diff --git a/pubspec.lock b/pubspec.lock index 197f246..3c75301 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -161,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + csslib: + dependency: transitive + description: + name: csslib + sha256: b36c7f7e24c0bdf1bf9a3da461c837d1de64b9f8beb190c9011d8c72a3dfd745 + url: "https://pub.dev" + source: hosted + version: "0.17.2" cupertino_icons: dependency: "direct main" description: @@ -272,6 +280,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -645,6 +661,70 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 39a7ae9..05c6895 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,8 @@ dependencies: json_serializable: ^6.6.1 json_annotation: ^4.8.0 dynamic_color: ^1.6.2 + html: ^0.15.2 + url_launcher: ^6.1.10 dev_dependencies: flutter_test: