diff --git a/lib/extractor/general/national/india/thewire.dart b/lib/extractor/general/national/india/thewire.dart index dc041d4..936ecdc 100644 --- a/lib/extractor/general/national/india/thewire.dart +++ b/lib/extractor/general/national/india/thewire.dart @@ -18,9 +18,6 @@ class TheWire extends Publisher { @override String get iconUrl => "$homePage/favicon-32x32.png"; - @override - String get searchEndpoint => "/search"; - Future> extractCategories() async { Map map = {}; var response = await http.get(Uri.parse(homePage)); @@ -44,7 +41,7 @@ class TheWire extends Publisher { @override Future article(String url) async { var response = await http - .get(Uri.parse('$homePage/wp-json/thewire/v2/posts/detail/$url')); + .get(Uri.parse('$homePage$url')); if (response.statusCode == 200) { var data = json.decode(response.body); var postDetail = data["post-detail"][0]; @@ -74,10 +71,9 @@ class TheWire extends Publisher { return super.articles(category: category, page: page); } - Future> extract( - String apiUrl, Map params, bool isSearch) async { + Future> extract(String apiUrl, bool isSearch) async { Set articles = {}; - final response = await http.get(Uri.parse(apiUrl), headers: params); + final response = await http.get(Uri.parse(apiUrl)); if (response.statusCode == 200) { List data; if (isSearch) { @@ -90,7 +86,7 @@ class TheWire extends Publisher { var author = element['post_author_name'][0]["author_name"]; var thumbnail = element['hero_image'][0]; //element['thumbnail']['url']; var time = element["post_date_gmt"]; - var articleUrl = '${element['post_name']}'; + var articleUrl = '/wp-json/thewire/v2/posts/detail/${element['post_name']}'; var excerpt = element['post_excerpt']; articles.add(NewsArticle( this, @@ -113,26 +109,23 @@ class TheWire extends Publisher { if (category == '/') { category = 'home'; } - String apiUrl = - '$homePage/wp-json/thewire/v2/posts/$category/recent-stories'; - Map params = { - 'per_page': '10', - 'page': '$page', - }; - return extract(apiUrl, params, false); + String apiUrl = '$homePage/wp-json/thewire/v2/posts/$category/recent-stories?page=$page&per_page=10'; + return extract(apiUrl, false); } @override Future> searchedArticles( {required String searchQuery, int page = 1}) { - String apiUrl = '$homePage/wp-json/thewire/v2/posts$searchEndpoint'; + String apiUrl = '$homePage/wp-json/thewire/v2/posts/search'; Map params = { 'keyword': searchQuery, 'orderby': 'rel', - 'per_page': '5', - 'page': '1', + 'per_page': '10', + 'page': '$page', 'type': 'opinion', }; - return extract(apiUrl, params, true); + Uri uri = Uri.parse(apiUrl).replace(queryParameters: params); + String fullUrl = uri.toString(); + return extract(fullUrl, true); } } diff --git a/lib/extractor/general/world/aljazeera.dart b/lib/extractor/general/world/aljazeera.dart new file mode 100644 index 0000000..bc7f5c6 --- /dev/null +++ b/lib/extractor/general/world/aljazeera.dart @@ -0,0 +1,127 @@ +import 'package:intl/intl.dart'; +import 'package:whapp/model/article.dart'; +import 'package:whapp/model/publisher.dart'; +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:html/parser.dart' as html_parser; +import 'package:whapp/utils/time.dart'; + +class AlJazeera extends Publisher { + @override + String get name => "Al Jazeera"; + + @override + String get homePage => "https://www.aljazeera.com"; + + @override + Future> get categories => extractCategories(); + + @override + bool get hasSearchSupport => false; + + Future> extractCategories() async { + return { + "Features": "features", + "Economy": "economy", + "Opinion": "opinion", + "Science & Technology": "tag/science-and-technology", + "Sport": "sports", + }; + } + + @override + Future article(String url) async { + var response = await http.get(Uri.parse('$homePage$url')); + if (response.statusCode == 200) { + var document = html_parser.parse(utf8.decode(response.bodyBytes)); + + var article = document.getElementById("main-content-area"); + + var titleElement = article?.querySelector('h1'); + var excerptElement = article?.querySelector('em'); + var thumbnailElement = article?.querySelector('img'); + var articleElement = article?.querySelector('.wysiwyg'); + var authorElement = article?.querySelector('.author-link'); + var timeElement = article?.querySelector('.date-simple span[aria-hidden]'); + var title = titleElement?.text; + var content = articleElement?.text; + var author = authorElement?.text; + var excerpt = excerptElement?.text; + var thumbnail = "$homePage${thumbnailElement?.attributes["src"]}"; + var time = timeElement?.text; + + if (time!=null) { + time = DateFormat('d MMM yyyy').parse(time).toString(); + } + return NewsArticle( + this, + title ?? "", + content ?? "", + excerpt ?? "", + author ?? "", + url, + thumbnail, + parseDateString(time?.trim() ?? ""), + ); + } + return null; + } + + @override + Future> articles({ + String category = "features", + int page = 1, + }) async { + return super.articles(category: category, page: page); + } + + @override + Future> categoryArticles({ + String category = "/", + int page = 1, + }) async { + Set articles = {}; + + if (category == "/") { + category = "features"; + } + + var url = Uri.parse('https://www.aljazeera.com/graphql?wp-site=aje&operationName=ArchipelagoAjeSectionPostsQuery&variables={"category":"features","categoryType":"categories","postTypes":["blog","episode","opinion","post","video","external-article","gallery","podcast","longform","liveblog"],"quantity":10,"offset":14}&extensions={}'); + var headers = {'wp-site': 'aje'}; + + var response = await http.get(url, headers: headers); + + if (response.statusCode == 200) { + final Map data = json.decode(response.body); + var articlesData = data["data"]["articles"]; + for (var element in articlesData) { + var title = element['title']; + var author = element['author'].isNotEmpty?element['author'][0]['name']:""; + var thumbnail = element['featuredImage']['sourceUrl']; + var time = element['date']; + var articleUrl = element['link']; + var excerpt = element['excerpt']; + articles.add(NewsArticle( + this, + title ?? "", + "", + excerpt, + author ?? "", + articleUrl, + thumbnail ?? "", + parseDateString(time?.trim() ?? ""), + )); + } + } + + return articles; + } + + @override + Future> searchedArticles({ + required String searchQuery, + int page = 1, + }) async { + return {}; + } +} \ No newline at end of file diff --git a/lib/extractor/general/world/bbc.dart b/lib/extractor/general/world/bbc.dart new file mode 100644 index 0000000..c5587e4 --- /dev/null +++ b/lib/extractor/general/world/bbc.dart @@ -0,0 +1,259 @@ +import 'package:intl/intl.dart'; +import 'package:whapp/model/article.dart'; +import 'package:whapp/model/publisher.dart'; +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:html/parser.dart' as html_parser; +import 'package:whapp/utils/time.dart'; + +class BBC extends Publisher { + @override + String get name => "BBC"; + + @override + String get homePage => "https://www.bbc.com"; + + @override + Future> get categories => extractCategories(); + + Future> extractCategories() async { + return { + "World": "world", + "Asia": "asia", + "UK": "uk", + "Business": "business", + "Technology": "technology", + "Science": "science", + }; + } + + Map uuidMap() { + return { + "world": "8467c0e0-584b-41de-9682-756b311216b5", + "asia": "070fca6a-b5c7-4b7f-8834-1c989fd40297", + "uk": "082101b1-72b1-4e45-943d-29d6dc6f97b4", + "business": "19a1d11b-1755-4f97-8747-0c9534336a47", + }; + } + + Map> topicMap() { + return { + "technology": { + "topic": "cd1qez2v2j2t", + "urn": "b2790c4d-d5c4-489a-84dc-be0dcd3f5252", + }, + "science": { + "topic": "c43v9644301t", + "urn": "0e18053e-731e-400a-a5b4-0f4088c74fd0", + }, + }; + } + + Future> extractBatch(String id, int page) async { + + Set articlesData = {}; + String apiUrl = "https://push.api.bbci.co.uk/batch?" + "t=/data/bbc-morph-lx-commentary-data-paged/about/$id/" + "isUk/false/limit/20/nitroKey/lx-nitro/pageNumber/$page/version/1.5.6"; + + final response = await http.get(Uri.parse(apiUrl)); + + if (response.statusCode == 200) { + final Map data = json.decode(response.body); + + var articles = data["payload"][0]["body"]["results"]; + for (var article in articles) { + var title = article['title']; + var author = article.containsKey("contributor")?article['contributor']["name"]:""; + var thumbnail = article["image"]["href"]; + var time = article["lastPublished"]; + var articleUrl = article['url']; + var excerpt = article['summary']; + articlesData.add(NewsArticle( + this, + title ?? "", + "", + excerpt, + author ?? "", + articleUrl, + thumbnail ?? "", + parseDateString(time?.trim() ?? ""), + )); + } + } + + return articlesData; + } + + Future> extractTopic( + String topicId, String groupResourceId, int page) async { + Set articlesData = {}; + String apiUrl = 'https://www.bbc.com/wc-data/container/topic-stream?' + 'adSlotType=mpu_middle&enableDotcomAds=true&isUk=false' + '&lazyLoadImages=true&pageNumber=$page&pageSize=10' + '&promoAttributionsToSuppress=["/news","/news/front_page"]' + '&showPagination=true&title=Latest News' + '&tracking={"groupName":"Latest News","groupType":"topic stream",' + '"groupResourceId":"urn:bbc:vivo:curation:$groupResourceId",' + '"groupPosition":5,"topicId":"$topicId"}' + '&urn=urn:bbc:vivo:curation:$groupResourceId'; + final response = await http.get(Uri.parse(apiUrl)); + if (response.statusCode == 200) { + final Map data = json.decode(response.body); + var articles = data["posts"]; + for (var article in articles) { + var title = article['headline']; + var author = article['contributor'] ?? ""; + var thumbnail = article["image"]["src"]; + var time = convertToIso8601(article["timestamp"]); + var articleUrl = article['url']; + var excerpt = ""; + articlesData.add(NewsArticle( + this, + title ?? "", + "", + excerpt, + author ?? "", + articleUrl, + thumbnail ?? "", + parseDateString(time), + )); + } + } + return articlesData; + } + + String convertToIso8601(String inputTime) { + DateTime today = DateTime.now(); + DateFormat inputFormat = DateFormat('HH:mm dd MMMM yyyy'); + DateTime parsedTime; + + try { + if (inputTime + .split(" ") + .length == 3) { // 04:20 20 December + parsedTime = inputFormat.parse('$inputTime ${today.year.toString()}'); + } else if (inputTime + .split(" ") + .length == 1) { // 20 December + parsedTime = + inputFormat.parse('00:00 $inputTime ${today.year.toString()}'); + } else if (inputTime + .split(" ") + .length == 2) { // 20 December 2020 + parsedTime = inputFormat.parse('00:00 $inputTime'); + } else { // 04:20 + String fullDate = + "${DateFormat('dd').format(today)} ${DateFormat('MMMM').format( + today)} ${DateFormat('yyyy').format(today)}"; + parsedTime = inputFormat.parse('$inputTime ${fullDate.toString()}'); + } + String iso8601Format = DateFormat('yyyy-MM-ddTHH:mm:ss').format( + parsedTime); + return iso8601Format; + } catch (e) { + return inputTime; + } + } + + @override + Future article(String url) async { + var response = await http.get(Uri.parse("$homePage$url")); + if (response.statusCode == 200) { + var document = html_parser.parse(utf8.decode(response.bodyBytes)); + + var titleElement = document.querySelector('article h1'); + var articleElement = document.querySelectorAll('article p'); + var excerptElement = document.querySelector('article div b'); + var timeElement = document.querySelector('article time'); + var thumbnailElement = document.querySelector('article img'); + var authorElement = document.querySelector("article div[class*=TextContributorName]"); + var title = titleElement?.text; + var article = articleElement.sublist(1).map((e) => "

${e.text}

").join(); + var author = authorElement?.text.replaceFirst("By ", ""); + var excerpt = excerptElement?.text; + var thumbnail = thumbnailElement?.attributes["src"]; + var time = timeElement?.attributes["datetime"]; + return NewsArticle( + this, + title ?? "", + article, + excerpt ?? "", + author ?? "", + url, + thumbnail ?? "", + parseDateString(time?.trim() ?? ""), + ); + } + return null; + } + + @override + Future> articles({ + String category = "world", + int page = 1, + }) async { + return super.articles(category: category, page: page); + } + + @override + Future> categoryArticles({ + String category = "/", + int page = 1, + }) async { + if (category == "/") { + category = "world"; + } + Map uuidMap_ = uuidMap(); + Map topicMap_ = topicMap(); + if(uuidMap_.containsKey(category)) { + return extractBatch(uuidMap_[category], page); + } else if (topicMap_.containsKey(category)) { + return extractTopic(topicMap_[category]["topic"], topicMap_[category]["urn"], page); + } + return {}; + } + + @override + Future> searchedArticles({ + required String searchQuery, + int page = 1, + }) async { + Set articles = {}; + var response = await http.get(Uri.parse("https://www.bbc.co.uk/search?q=$searchQuery&page=$page")); + if (response.statusCode == 200) { + var document = html_parser.parse(utf8.decode(response.bodyBytes)); + + var articleElements = document.querySelectorAll('li div[data-testid]'); + for (var element in articleElements) { + var titleElement = element.querySelector('a span'); + var thumbnailElement = element.querySelector('img'); + var articleUrlElement = element.querySelector('a'); + var excerptElement = element.querySelector('p[class*=Paragraph]'); + var timeElement = element.querySelector('span[class*=MetadataText]'); + var title = titleElement?.text; + var author = ""; + var excerpt = excerptElement?.text; + var thumbnail = thumbnailElement?.attributes["src"]; + var time = timeElement?.text; + var articleUrl = articleUrlElement?.attributes["href"]; + + if (time!=null) { + time = convertToIso8601(time); + } + + articles.add(NewsArticle( + this, + title ?? "", + "", + excerpt ?? "", + author, + articleUrl?.replaceFirst(homePage, "") ?? "", + thumbnail ?? "", + parseDateString(time?.trim() ?? ""), + )); + } + } + return articles; + } +} diff --git a/lib/extractor/general/world/reuters.dart b/lib/extractor/general/world/reuters.dart index 462663d..4dd3aa8 100644 --- a/lib/extractor/general/world/reuters.dart +++ b/lib/extractor/general/world/reuters.dart @@ -16,11 +16,6 @@ class Reuters extends Publisher { @override Future> get categories => extractCategories(); - @override - String get iconUrl => "$homePage/favicon.ico"; - - @override - String get searchEndpoint => ""; Future> extractCategories() async { return { diff --git a/lib/extractor/technology/theverge.dart b/lib/extractor/technology/theverge.dart index bc74404..5d3cad4 100644 --- a/lib/extractor/technology/theverge.dart +++ b/lib/extractor/technology/theverge.dart @@ -15,12 +15,6 @@ class TheVerge extends Publisher { @override Future> get categories => extractCategories(); - @override - String get iconUrl => "$homePage/icons/favicon_32x32.png"; - - @override - String get searchEndpoint => "/api/search"; - Future> extractCategories() async { Map map = {}; var response = await http.get(Uri.parse(homePage)); @@ -117,7 +111,7 @@ class TheVerge extends Publisher { Future> extractSearchArticles(String searchQuery, int page) async { Set articles = {}; var response = await http.get( - Uri.parse("$homePage$searchEndpoint"), + Uri.parse("$homePage/api/search"), headers: { "q": searchQuery, "page": (page-1).toString(), diff --git a/lib/extractor/technology/torrentfreak.dart b/lib/extractor/technology/torrentfreak.dart index 2705445..2199767 100644 --- a/lib/extractor/technology/torrentfreak.dart +++ b/lib/extractor/technology/torrentfreak.dart @@ -16,12 +16,6 @@ class TorrentFreak extends Publisher { @override Future> get categories => extractCategories(); - @override - String get iconUrl => "$homePage/favicon-32x32.png"; - - @override - String get searchEndpoint => "/page/{page}/?s={query}"; - Future> extractCategories() async { Map map = {}; var response = await http.get(Uri.parse(homePage)); @@ -134,6 +128,6 @@ class TorrentFreak extends Publisher { @override Future> searchedArticles({required String searchQuery, int page = 1}) { - return extract("$homePage${searchEndpoint.replaceFirst("{page}", "$page").replaceFirst("{query}", searchQuery)}"); + return extract("$homePage/page/$page/?s=$searchQuery"); } } diff --git a/lib/main.dart b/lib/main.dart index e49707f..f2b5885 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,12 +2,14 @@ import 'package:flutter/material.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:whapp/model/user_subscription.dart'; import 'package:whapp/pages/home.dart'; - +import 'package:whapp/utils/store.dart'; +import 'package:whapp/utils/theme_provider.dart'; Future main() async { await Hive.initFlutter(); Hive.registerAdapter(UserSubscriptionAdapter()); await Hive.openBox('subscriptions'); + await Hive.openBox('settings'); runApp(const MyApp()); } @@ -16,9 +18,18 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return const MaterialApp( - debugShowCheckedModeBanner:false, - home: MyHomePage(), + return ValueListenableBuilder( + valueListenable: Store.settings.listenable(), + builder: (context, box, child) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: MyHomePage(), + theme: ThemeProvider.get( + ThemeProvider.colors[Store.themeColorSetting]!, + Store.darkThemeSetting, + ), + ); + }, ); } } diff --git a/lib/model/article.dart b/lib/model/article.dart index 67f7ca5..2e16fd2 100644 --- a/lib/model/article.dart +++ b/lib/model/article.dart @@ -8,7 +8,7 @@ class NewsArticle { String author; String url; String thumbnail; - MapEntry publishedAt; + MapEntry publishedAt; NewsArticle( this.publisher, @@ -34,6 +34,11 @@ class NewsArticle { }; } + @override + String toString() { + return toJson().toString(); + } + @override bool operator ==(Object other) => identical(this, other) || diff --git a/lib/model/publisher.dart b/lib/model/publisher.dart index c1d9b5d..d58f72c 100644 --- a/lib/model/publisher.dart +++ b/lib/model/publisher.dart @@ -1,4 +1,6 @@ import 'package:whapp/extractor/general/national/india/thewire.dart'; +import 'package:whapp/extractor/general/world/aljazeera.dart'; +import 'package:whapp/extractor/general/world/bbc.dart'; import 'package:whapp/extractor/general/world/reuters.dart'; import 'package:whapp/extractor/technology/theverge.dart'; import 'package:whapp/extractor/technology/torrentfreak.dart'; @@ -8,6 +10,8 @@ import 'package:whapp/utils/string.dart'; Map publishers = { + "Al Jazeera": AlJazeera(), + "BBC": BBC(), "Reuters": Reuters(), "The Verge": TheVerge(), "The Wire": TheWire(), @@ -19,30 +23,30 @@ abstract class Publisher { String get homePage; - String get iconUrl; + bool get hasSearchSupport => true; + + String get iconUrl => "$homePage/favicon.ico"; Future> get categories; - String get searchEndpoint; Future> articles({String category = "All", int page = 1}) { return category.startsWith("#") ? searchedArticles(searchQuery: getAsSearchQuery(category), page: page) : categoryArticles(category: category, page: page); } + Future> categoryArticles({String category = "All", int page = 1}); + Future> searchedArticles({required String searchQuery, int page = 1}); Future article(String url); - - Map toJson() { return { 'homePage': homePage, 'iconUrl': iconUrl, 'categories': categories, - 'searchEndpoint': searchEndpoint, }; } diff --git a/lib/pages/feed.dart b/lib/pages/feed.dart index 63ce190..11af154 100644 --- a/lib/pages/feed.dart +++ b/lib/pages/feed.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:whapp/model/article.dart'; import 'package:whapp/model/publisher.dart'; -import 'package:whapp/model/user_subscription.dart'; import 'package:whapp/pages/full_article.dart'; +import 'package:whapp/utils/store.dart'; class FeedPage extends StatefulWidget { const FeedPage({super.key}); @@ -45,75 +45,79 @@ class _FeedPageState extends State }); _loadMoreItems(); }, - child:ValueListenableBuilder( - valueListenable: Hive.box('subscriptions').listenable(), + child: ValueListenableBuilder( + valueListenable: Store.subscriptions.listenable(), builder: (BuildContext context, box, Widget? child) { - if(box.get("selected")!=null && box.get("selected").isNotEmpty) { + if (Store.selectedSubscriptions.isNotEmpty) { return ListView.builder( - itemCount: filteredArticles.length + 2, - itemBuilder: (context, index) { - if(index==0){ - return Padding( - padding: const EdgeInsets.all(8.0), - child: TextField( - controller: searchController, - onChanged: (value) { - setState(() { - filteredArticles = newsArticles - .where((article) => - article!.title.toLowerCase().contains(value.toLowerCase())) - .toList(); - }); - }, - decoration: const InputDecoration( - labelText: 'Search feed', - prefixIcon: Icon(Icons.search), - border: OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(30.0), // Adjust the value as needed + itemCount: filteredArticles.length + 2, + itemBuilder: (context, index) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + controller: searchController, + onChanged: (value) { + setState(() { + filteredArticles = newsArticles + .where((article) => article!.title + .toLowerCase() + .contains(value.toLowerCase())) + .toList(); + }); + }, + decoration: const InputDecoration( + labelText: 'Search feed', + prefixIcon: Icon(Icons.search), + border: OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular( + 30.0), // Adjust the value as needed + ), ), ), ), - ), - ); - } - else if (index-1 < filteredArticles.length) { - var article = filteredArticles[index-1]; - return ListTile( - title: Text(article!.title), - leading: CachedNetworkImage( - imageUrl: article.publisher.iconUrl, - progressIndicatorBuilder: - (context, url, downloadProgress) { - return CircularProgressIndicator( - value: downloadProgress.progress); + ); + } else if (index - 1 < filteredArticles.length) { + var article = filteredArticles[index - 1]; + return ListTile( + title: Text(article!.title), + leading: CachedNetworkImage( + imageUrl: article.publisher.iconUrl, + progressIndicatorBuilder: + (context, url, downloadProgress) { + return CircularProgressIndicator( + value: downloadProgress.progress); + }, + height: 24, + width: 24, + errorWidget: (context, url, error) => + const Icon(Icons.error), + ), + subtitle: Text( + article.publishedAt.value, + ), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + ArticlePage(article: article)), + ); + }, + ); + } else { + return ElevatedButton( + onPressed: () { + _loadMoreItems(); }, - errorWidget: (context, url, error) => - const Icon(Icons.error), - ), - subtitle: Text( - "${article.author} - ${article.publishedAt.value}", - ), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - ArticlePage(article: article)), - ); - }, - ); - } else { - return ElevatedButton( - onPressed: () { - _loadMoreItems(); - }, - child: const Text("Load more"), - ); - } - }, - ); - } return Center(child: Text("Select some subscriptions")); + child: const Text("Load more"), + ); + } + }, + ); + } + return Center(child: Text("Select some subscriptions")); }, ), ), @@ -127,18 +131,17 @@ class _FeedPageState extends State isLoading = true; }); - List subscriptions = Hive.box("subscriptions").get("selected") ?? - List.empty(growable: true); + List subscriptions = Store.selectedSubscriptions; for (var subscription in subscriptions) { Publisher publisher = publishers[subscription.publisher]!; publisher .articles(page: page, category: subscription.category) .then((articles) { setState(() { - newsArticles = newsArticles.toSet().union(articles).toList() - ..sort( - (a, b) => a?.publishedAt.key.compareTo(b?.publishedAt.key), - ); + newsArticles = newsArticles.toSet().union(articles).toList(); + newsArticles.sort( + (a, b) => a!.publishedAt.key.compareTo(b!.publishedAt.key), + ); filteredArticles = List.from(newsArticles); isLoading = false; }); diff --git a/lib/pages/full_article.dart b/lib/pages/full_article.dart index edc2a6a..cbe8be6 100644 --- a/lib/pages/full_article.dart +++ b/lib/pages/full_article.dart @@ -1,8 +1,11 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:whapp/model/article.dart'; +import 'package:whapp/utils/network.dart'; +import 'package:whapp/utils/store.dart'; class ArticlePage extends StatefulWidget { final NewsArticle article; @@ -14,79 +17,118 @@ class ArticlePage extends StatefulWidget { } class _ArticlePageState extends State { + TextStyle metadataStyle = const TextStyle( + fontStyle: FontStyle.italic, + color: Colors.grey, + ); + + TextStyle titleStyle = const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ); + + TextStyle excerptStyle = TextStyle( + fontSize: 16, + color: Colors.grey[700], + ); + @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(), - body: FutureBuilder( - initialData: widget.article, - future: widget.article.publisher.article(widget.article.url), - builder: (context, snapshot) { - if (snapshot.hasData) { - return Padding( - padding: const EdgeInsets.all(16.0), - child: ListView( - children: [ - Text( - snapshot.data!.title, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - 'Author: ${snapshot.data!.author}', - style: const TextStyle( - fontStyle: FontStyle.italic, - color: Colors.grey, - ), - ), - const SizedBox(height: 8), - Text( - 'Published: ${snapshot.data!.publishedAt.value}', - style: const TextStyle( - fontStyle: FontStyle.italic, - color: Colors.grey, - ), - ), - const SizedBox(height: 8), - if (snapshot.data!.thumbnail.isNotEmpty && - snapshot.data!.thumbnail.startsWith("https")) - CachedNetworkImage( - imageUrl: snapshot.data!.thumbnail, - progressIndicatorBuilder: - (context, url, downloadProgress) { - return CircularProgressIndicator( - value: downloadProgress.progress); - }, - errorWidget: (context, url, error) { - return const Icon(Icons.error); - }, - ), - const SizedBox(height: 16), - Text( - snapshot.data!.excerpt, - style: TextStyle( - fontSize: 16, - color: Colors.grey[700], - ), - ), - const SizedBox(height: 16), - HtmlWidget( - snapshot.data!.content, - ), - ], + + return FutureBuilder( + initialData: widget.article, + future: widget.article.publisher.article(widget.article.url), + builder: (context, snapshot) { + String fullUrl = + "${widget.article.publisher.homePage}${snapshot.data!.url}"; + String altUrl = "${Store.ladderUrl}/$fullUrl"; + return Scaffold( + appBar: AppBar( + title: Text(widget.article.publisher.name), + actions: [ + InkWell( + onLongPress: () { + Share.shareUri(Uri.parse(altUrl)); + }, + child: IconButton( + icon: Icon(Icons.share), + onPressed: () { + Share.shareUri(Uri.parse(fullUrl)); + }, + ), + ), + InkWell( + onLongPress: () { + launchUrl(Uri.parse(altUrl)); + }, + child: IconButton( + icon: Icon(Icons.open_in_browser), + onPressed: () { + launchUrl(Uri.parse(fullUrl)); + }, + ), ), - ); - } else if (snapshot.hasError) { - throw snapshot.error!; - } - return const Center(child: CircularProgressIndicator()); + ], + ), + body: snapshot.connectionState == ConnectionState.done + ? snapshot.hasData + ? Padding( + padding: const EdgeInsets.all(8.0), + child: ListView( + children: [ + textWidget("", snapshot.data!.title, titleStyle), + textWidget( + "Author", snapshot.data!.author, metadataStyle), + textWidget("Published", + snapshot.data!.publishedAt.value, metadataStyle), + if (Network.shouldLoadImage(snapshot.data!.thumbnail)) + image(snapshot), + textWidget("", snapshot.data!.excerpt, excerptStyle), + HtmlWidget(snapshot.data!.content), + ], + ), + ) + : const Center(child: Text("Error loading data")) + : const Center(child: CircularProgressIndicator()), + ); + }, + ); + } + + Padding image(AsyncSnapshot snapshot) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: CachedNetworkImage( + imageUrl: snapshot.data!.thumbnail, + progressIndicatorBuilder: (context, url, downloadProgress) { + return Center( + child: LinearProgressIndicator( + value: downloadProgress.progress, + ), + ); + }, + errorWidget: (context, url, error) { + return const Icon(Icons.error); }, ), ); } + + Widget textWidget( + String label, + String value, + TextStyle style, + ) { + return value.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(left: 8.0, right: 8.0, bottom: 8.0), + child: Text( + label.isNotEmpty ? '$label: $value' : value, + style: style, + ), + ) + : SizedBox.shrink(); + } } class HtmlWidget extends StatelessWidget { @@ -115,17 +157,19 @@ class HtmlWidget extends StatelessWidget { var src = extensionContext.attributes.containsKey("data-lazy-src") ? "data-lazy-src" : "src"; - return CachedNetworkImage( - imageUrl: extensionContext.attributes[src]!, - progressIndicatorBuilder: (context, url, downloadProgress) { - return CircularProgressIndicator( - value: downloadProgress.progress, - ); - }, - errorWidget: (context, url, error) { - return const Icon(Icons.error); - }, - ); + return Network.shouldLoadImage(extensionContext.attributes[src]!) + ? CachedNetworkImage( + imageUrl: extensionContext.attributes[src]!, + progressIndicatorBuilder: (context, url, downloadProgress) { + return CircularProgressIndicator( + value: downloadProgress.progress, + ); + }, + errorWidget: (context, url, error) { + return const Icon(Icons.error); + }, + ) + : SizedBox.shrink(); }, ), ], diff --git a/lib/pages/home.dart b/lib/pages/home.dart index cd4b639..4146ce8 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:whapp/pages/feed.dart'; +import 'package:whapp/pages/settings.dart'; import 'package:whapp/pages/subscription.dart'; @@ -34,6 +35,11 @@ class _MyHomePageState extends State { selectedIcon: Icon(Icons.favorite_rounded), label: 'Subscriptions', ), + NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: 'Settings', + ), ], ), body: IndexedStack( @@ -41,6 +47,7 @@ class _MyHomePageState extends State { children: [ const FeedPage(), const SubscriptionsPage(), + const SettingsPage(), ], ), ); diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart new file mode 100644 index 0000000..3701819 --- /dev/null +++ b/lib/pages/settings.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:hive_flutter/adapters.dart'; +import 'package:whapp/utils/store.dart'; +import 'package:whapp/utils/theme_provider.dart'; + +class SettingsPage extends StatefulWidget { + const SettingsPage({super.key}); + + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Settings'), + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: ValueListenableBuilder( + valueListenable: Store.settings.listenable(), + builder: (context, box, child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Theme', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + SwitchListTile( + title: Text('Dark Mode'), + value: Store.darkThemeSetting, + onChanged: (value) { + Store.darkThemeSetting = value; + }, + ), + ListTile( + title: Text('Color'), + trailing: DropdownButton( + value: Store.themeColorSetting, + onChanged: (String? color) { + if(color!=null) { + Store.themeColorSetting = color; + } + }, + items: ThemeProvider.colors.keys.map>((String color) { + return DropdownMenuItem( + value: color, + child: Text(color), + ); + }).toList(), + ), + ), + SizedBox(height: 20), + Text( + 'Article', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + ListTile( + title: Text('Load images'), + trailing: DropdownButton( + value: Store.loadImagesSetting, + onChanged: (String? option) { + if(option!=null) { + Store.loadImagesSetting = option; + } + }, + items: Store.loadImagesValues.map>((String option) { + return DropdownMenuItem( + value: option, + child: Text(option), + ); + }).toList(), + ), + ), + ListTile( + title: Text('Redirection'), + subtitle: Text('Alternate URLs (Long tap)'), + trailing: DropdownButton( + value: Store.ladderSetting, + onChanged: (String? option) { + if(option!=null) { + Store.ladderSetting = option; + } + }, + items: Store.ladders.keys.map>((String option) { + return DropdownMenuItem( + value: option, + child: Text(option), + ); + }).toList(), + ), + ), + ], + ); + }, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/pages/subscription.dart b/lib/pages/subscription.dart index d3d9230..ab76641 100644 --- a/lib/pages/subscription.dart +++ b/lib/pages/subscription.dart @@ -1,9 +1,10 @@ import 'dart:core'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:hive_flutter/adapters.dart'; import 'package:whapp/model/publisher.dart'; import 'package:whapp/model/user_subscription.dart'; +import 'package:whapp/utils/store.dart'; class SubscriptionsPage extends StatefulWidget { const SubscriptionsPage({super.key}); @@ -62,6 +63,18 @@ class _SubscriptionsPageState extends State with AutomaticKee var categories = getSelectedCategories(newsSource); return ListTile( title: Text(newsSource), + leading: CachedNetworkImage( + imageUrl: publishers[newsSource]!.iconUrl, + progressIndicatorBuilder: + (context, url, downloadProgress) { + return CircularProgressIndicator( + value: downloadProgress.progress); + }, + height: 24, + width: 24, + errorWidget: (context, url, error) => + const Icon(Icons.error), + ), subtitle: categories.isEmpty? null:Text(categories), onTap: () { showDialog( @@ -85,7 +98,7 @@ class _SubscriptionsPageState extends State with AutomaticKee } String getSelectedCategories(String newsSource) { - var categories = (Hive.box("subscriptions").get("selected", defaultValue: [])) + var categories = Store.selectedSubscriptions .where((element) => element.publisher==newsSource) .map((e) => e.category) .join(", "); @@ -116,10 +129,8 @@ class _CategoryPopupState extends State { @override void initState() { setState(() { - selectedSubscriptions = Hive.box("subscriptions").get("selected") ?? - List.empty(growable: true); - customSubscriptions = Hive.box("subscriptions").get("custom") ?? - List.empty(growable: true); + selectedSubscriptions = Store.selectedSubscriptions; + customSubscriptions = Store.customSubscriptions.where((element) => element.publisher==widget.newsSource).toList(); }); super.initState(); } @@ -203,7 +214,7 @@ class _CategoryPopupState extends State { ), ListView.builder( shrinkWrap: true, - itemCount: customSubscriptions.length, + itemCount: customSubscriptions.where((element) => element.publisher==widget.newsSource).length, itemBuilder: (context, index) { return CheckboxListTile( secondary: IconButton(icon: const Icon(Icons.delete_forever), onPressed: () { @@ -212,9 +223,12 @@ class _CategoryPopupState extends State { customSubscriptions.remove(subscription); selectedSubscriptions.remove(subscription); }); - - Hive.box("subscriptions").put("custom", customSubscriptions); - Hive.box("subscriptions").put("selected", selectedSubscriptions); + var cs = Store.customSubscriptions; + cs.remove(subscription); + Store.customSubscriptions= cs; + var ss = Store.selectedSubscriptions; + ss.remove(subscription); + Store.selectedSubscriptions = ss; }), title: Text(convertString((customSubscriptions[index] as UserSubscription).category)), value: selectedSubscriptions.contains(customSubscriptions[index]), @@ -259,10 +273,10 @@ class _CategoryPopupState extends State { customCategory, )); }); - Hive.box("subscriptions").put( - "custom", - customSubscriptions, - ); + Store.customSubscriptions +=[UserSubscription( + widget.newsSource, + customCategory, + )]; }, icon: const Icon(Icons.save_alt)) : const Icon(Icons.cancel); @@ -279,8 +293,7 @@ class _CategoryPopupState extends State { padding: const EdgeInsets.all(8.0), child: FilledButton( onPressed: () { - Hive.box("subscriptions") - .put("selected", selectedSubscriptions); + Store.selectedSubscriptions = selectedSubscriptions; Navigator.of(context).pop(); widget.callback(); }, diff --git a/lib/utils/network.dart b/lib/utils/network.dart new file mode 100644 index 0000000..ceb0763 --- /dev/null +++ b/lib/utils/network.dart @@ -0,0 +1,9 @@ +import 'package:whapp/utils/store.dart'; + +class Network { + static bool shouldLoadImage(String url) { + return url.isNotEmpty && + url.startsWith("https") && + Store.loadImagesSetting == "Always"; + } +} diff --git a/lib/utils/store.dart b/lib/utils/store.dart new file mode 100644 index 0000000..dfe55df --- /dev/null +++ b/lib/utils/store.dart @@ -0,0 +1,76 @@ +import 'package:hive/hive.dart'; +import 'package:whapp/utils/theme_provider.dart'; + +class Store { + + static List loadImagesValues = ["Always", "Never"]; + + static Map ladders = { + "12ft": "https://12ft.io", + "1ft": "https://1ft.io", + "archive.is": "https://archive.is", + "archive.ph": "https://archive.ph", + "Web Archive": "https://web.archive.org/web/*", + }; + + static Box get subscriptions { + return Hive.box("subscriptions"); + } + + static List get selectedSubscriptions { + return subscriptions.get("selected", defaultValue: []); + } + + static set selectedSubscriptions(List newSubscriptions) { + subscriptions.put("selected", newSubscriptions); + } + + static List get customSubscriptions { + return subscriptions.get("custom", defaultValue: []); + } + + static set customSubscriptions(List newSubscriptions) { + subscriptions.put("custom", newSubscriptions); + } + + static Box get settings { + return Hive.box("settings"); + } + + static String get ladderSetting { + return settings.get("ladder", defaultValue: ladders.keys.first); + } + + static set ladderSetting(String ladder) { + settings.put("ladder", ladder); + } + + static String get loadImagesSetting { + return settings.get("loadImages", defaultValue: loadImagesValues.first); + } + + static set loadImagesSetting(String load) { + settings.put("loadImages", load); + } + + static bool get darkThemeSetting { + return settings.get("darkMode", defaultValue: false); + } + + static set darkThemeSetting(bool load) { + settings.put("darkMode", load); + } + + static String get themeColorSetting { + return settings.get("themeColor", defaultValue: ThemeProvider.defaultColor); + } + + static set themeColorSetting(String color) { + settings.put("themeColor", color); + } + + static String get ladderUrl { + return ladders[ladderSetting] ?? ladders.keys.first; + } + +} \ No newline at end of file diff --git a/lib/utils/theme_provider.dart b/lib/utils/theme_provider.dart new file mode 100644 index 0000000..eae2a59 --- /dev/null +++ b/lib/utils/theme_provider.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +class ThemeProvider { + + static String defaultColor = "Orange"; + + static Map colors = { + 'Red': Colors.red, + 'Pink': Colors.pink, + 'Purple': Colors.purple, + 'Deep Purple': Colors.deepPurple, + 'Indigo': Colors.indigo, + 'Blue': Colors.blue, + 'Light Blue': Colors.lightBlue, + 'Cyan': Colors.cyan, + 'Teal': Colors.teal, + 'Green': Colors.green, + 'Light Green': Colors.lightGreen, + 'Lime': Colors.lime, + 'Yellow': Colors.yellow, + 'Amber': Colors.amber, + 'Orange': Colors.orange, + 'Deep Orange': Colors.deepOrange, + 'Brown': Colors.brown, + }; + + static ThemeData get(Color color, bool dark) { + return ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: color, + brightness: dark?Brightness.dark:Brightness.light, + ), + ); + } +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index d3d2905..4b18fc1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: whapp description: "News aggregator" publish_to: 'none' -version: 0.0.1+1 +version: 0.1.0+1 environment: sdk: '>=3.2.1 <4.0.0' @@ -19,6 +19,7 @@ dependencies: html: ^0.15.4 http: ^1.1.2 intl: ^0.19.0 + share_plus: ^7.2.1 url_launcher: ^6.2.2 dev_dependencies: diff --git a/test/extractor/general/world/aljazeera.dart b/test/extractor/general/world/aljazeera.dart new file mode 100644 index 0000000..f2353e6 --- /dev/null +++ b/test/extractor/general/world/aljazeera.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:whapp/extractor/general/world/aljazeera.dart'; +import 'package:whapp/model/article.dart'; + +void main() { + + late AlJazeera alJazeera; + setUp(() { + alJazeera = AlJazeera(); + }); + + test('Al Jazeera - Categories Test', () async { + final categories = await alJazeera.categories; + + expect(categories, isA>()); + expect(categories.isNotEmpty, true); + }); + + test('Al Jazeera - Article Test', () async { + final articleUrl = + '/news/2023/12/25/ukraine-russia-say-six-civilians-killed-in-attacks-on-kherson-horlivka'; + final article = await alJazeera.article(articleUrl); + + expect(article, isA()); + expect(article?.title, isNotEmpty); + expect(article?.content, isNotEmpty); + expect(article?.publishedAt.value, isNot(0)); + }); + + test('Al Jazeera - Category Articles Test', () async { + final categoryArticles = + await alJazeera.categoryArticles(category: 'features', page: 1); + + expect(categoryArticles, isA>()); + expect(categoryArticles, isNotEmpty); + }); + + test('Al Jazeera - Searched Articles Test', () async { + final searchedArticles = + await alJazeera.searchedArticles(searchQuery: 'ukraine', page: 1); + + expect(searchedArticles, isA>()); + expect(searchedArticles, isNotEmpty); + }); +} diff --git a/test/extractor/general/world/bbc.dart b/test/extractor/general/world/bbc.dart new file mode 100644 index 0000000..cf8e86f --- /dev/null +++ b/test/extractor/general/world/bbc.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:whapp/extractor/general/world/bbc.dart'; +import 'package:whapp/model/article.dart'; + +void main() { + test('BBC - Extract Categories Test', () async { + final bbc = BBC(); + + final categories = await bbc.categories; + + expect(categories, isA>()); + expect(categories.isNotEmpty, true); + }); + + test('BBC - Article Test', () async { + final bbc = BBC(); + + final articleUrl = '/news/world-asia-67825665'; + final article = await bbc.article(articleUrl); + + expect(article, isA()); + expect(article?.title, isNotEmpty); + expect(article?.content, isNotEmpty); + expect(article?.publishedAt.value, isNot(0)); + }); + + test('BBC - Category Articles Test', () async { + final bbc = BBC(); + + var categoryArticles = + await bbc.categoryArticles(category: 'world', page: 1); + + expect(categoryArticles, isA>()); + expect(categoryArticles, isNotEmpty); + + categoryArticles = + await bbc.categoryArticles(category: 'technology', page: 1); + expect(categoryArticles, isA>()); + expect(categoryArticles, isNotEmpty); + }); + + test('BBC - Searched Articles Test', () async { + final bbc = BBC(); + + final searchedArticles = + await bbc.searchedArticles(searchQuery: 'climate', page: 1); + + expect(searchedArticles, isA>()); + expect(searchedArticles, isNotEmpty); + }); +} \ No newline at end of file