diff --git a/api/Reader/List News.bru b/api/Reader/List News.bru index 11c11b7..747062d 100644 --- a/api/Reader/List News.bru +++ b/api/Reader/List News.bru @@ -5,7 +5,7 @@ meta { } get { - url: {{endpoint}}/cgi/re/news?take=10&offset=0&source=taiwan-pts + url: {{endpoint}}/cgi/re/news?take=10&offset=0&source=shadiao body: none auth: none } @@ -13,5 +13,5 @@ get { params:query { take: 10 offset: 0 - source: taiwan-pts + source: shadiao } diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 74d2897..b1eb334 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -563,5 +563,6 @@ "newsAllSources": "All News", "newsReadingProviderSwap": "Swap", "newsReadingFromReader": "You're reading from HyperNet.Reader", - "newsReadingFromOriginal": "You're reading the original article" + "newsReadingFromOriginal": "You're reading the original article", + "newsDisclaimer": "This article is fetched from the Internet, we do not guarantee its authenticity, please judge for yourself. All content in this article belongs to the original author." } diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index a17f52f..1fe8e4b 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -561,5 +561,6 @@ "newsAllSources": "所有新闻", "newsReadingProviderSwap": "切换", "newsReadingFromReader": "你正在从 HyperNet.Reader 阅读文章", - "newsReadingFromOriginal": "你正在阅读原始文章" + "newsReadingFromOriginal": "你正在阅读原始文章", + "newsDisclaimer": "本文由 HyperNet.Reader 从互联网上获取,我们不担保其内容的真实性,请自行判断。本文章的所有内容版权归原作者所有。" } diff --git a/assets/translations/zh-HK.json b/assets/translations/zh-HK.json index 9020f55..29177f2 100644 --- a/assets/translations/zh-HK.json +++ b/assets/translations/zh-HK.json @@ -15,6 +15,7 @@ "screenAccountProfileEdit": "編輯資料", "screenAbuseReport": "濫用檢舉", "screenSettings": "設置", + "screenNews": "新聞", "screenAlbum": "相冊", "screenChat": "聊天", "screenChatManage": "編輯聊天頻道", @@ -194,6 +195,10 @@ "settingsFeatures": "功能", "settingsNotifyWithHaptic": "新通知時振動", "settingsNotifyWithHapticDescription": "在應用在前台時收到新通知出現時出發輕量的振動。", + "settingsExpandPostLink": "展開帖子鏈接", + "settingsExpandPostLinkDescription": "在帖子列表中展開顯示帖子中的鏈接。", + "settingsExpandChatLink": "展開聊天鏈接", + "settingsExpandChatLinkDescription": "在聊天信息中展開顯示內容中的鏈接。", "settingsNetwork": "網絡", "settingsNetworkServer": "HyperNet 服務器", "settingsNetworkServerDescription": "設置 HyperNet 服務器地址,選擇我們提供的,或者自己搭建。", @@ -552,5 +557,10 @@ "postCategoryKnowledge": "知識", "postCategoryLiterature": "文學", "postCategoryFunny": "搞笑", - "postCategoryUncategorized": "未分類" + "postCategoryUncategorized": "未分類", + "newsAllSources": "所有新聞", + "newsReadingProviderSwap": "切換", + "newsReadingFromReader": "你正在從 HyperNet.Reader 閲讀文章", + "newsReadingFromOriginal": "你正在閲讀原始文章", + "newsDisclaimer": "本文由 HyperNet.Reader 從互聯網上獲取,我們不擔保其內容的真實性,請自行判斷。本文章的所有內容版權歸原作者所有。" } diff --git a/assets/translations/zh-TW.json b/assets/translations/zh-TW.json index a11513b..f47c840 100644 --- a/assets/translations/zh-TW.json +++ b/assets/translations/zh-TW.json @@ -15,6 +15,7 @@ "screenAccountProfileEdit": "編輯資料", "screenAbuseReport": "濫用檢舉", "screenSettings": "設置", + "screenNews": "新聞", "screenAlbum": "相冊", "screenChat": "聊天", "screenChatManage": "編輯聊天頻道", @@ -194,6 +195,10 @@ "settingsFeatures": "功能", "settingsNotifyWithHaptic": "新通知時振動", "settingsNotifyWithHapticDescription": "在應用在前臺時收到新通知出現時出發輕量的振動。", + "settingsExpandPostLink": "展開帖子鏈接", + "settingsExpandPostLinkDescription": "在帖子列表中展開顯示帖子中的鏈接。", + "settingsExpandChatLink": "展開聊天鏈接", + "settingsExpandChatLinkDescription": "在聊天信息中展開顯示內容中的鏈接。", "settingsNetwork": "網絡", "settingsNetworkServer": "HyperNet 服務器", "settingsNetworkServerDescription": "設置 HyperNet 服務器地址,選擇我們提供的,或者自己搭建。", @@ -552,5 +557,10 @@ "postCategoryKnowledge": "知識", "postCategoryLiterature": "文學", "postCategoryFunny": "搞笑", - "postCategoryUncategorized": "未分類" + "postCategoryUncategorized": "未分類", + "newsAllSources": "所有新聞", + "newsReadingProviderSwap": "切換", + "newsReadingFromReader": "你正在從 HyperNet.Reader 閱讀文章", + "newsReadingFromOriginal": "你正在閱讀原始文章", + "newsDisclaimer": "本文由 HyperNet.Reader 從互聯網上獲取,我們不擔保其內容的真實性,請自行判斷。本文章的所有內容版權歸原作者所有。" } diff --git a/lib/screens/news/news_detail.dart b/lib/screens/news/news_detail.dart index 6e440e2..f9f3d60 100644 --- a/lib/screens/news/news_detail.dart +++ b/lib/screens/news/news_detail.dart @@ -1,11 +1,19 @@ import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart'; import 'package:provider/provider.dart'; +import 'package:relative_time/relative_time.dart'; +import 'package:styled_widget/styled_widget.dart'; import 'package:surface/providers/sn_network.dart'; import 'package:surface/types/news.dart'; import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/navigation/app_scaffold.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:surface/widgets/universal_image.dart'; +import 'package:url_launcher/url_launcher_string.dart'; class NewsDetailScreen extends StatefulWidget { final String hash; @@ -18,12 +26,14 @@ class NewsDetailScreen extends StatefulWidget { class _NewsDetailScreenState extends State { SnNewsArticle? _article; + dom.Document? _articleFragment; Future _fetchArticle() async { try { final sn = context.read(); final resp = await sn.client.get('/cgi/re/news/${widget.hash}'); _article = SnNewsArticle.fromJson(resp.data); + _articleFragment = parse(_article!.content); } catch (err) { if (!mounted) return; context.showErrorDialog(err).then((_) { @@ -35,6 +45,96 @@ class _NewsDetailScreenState extends State { } } + List _parseHtmlToWidgets(Iterable? elements) { + if (elements == null) return []; + + final List widgets = []; + + for (final node in elements) { + switch (node.localName) { + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': + widgets.add(Text(node.text.trim(), style: Theme.of(context).textTheme.titleMedium)); + break; + case 'p': + if (node.text.trim().isEmpty) continue; + widgets.add( + Text.rich( + TextSpan( + text: node.text.trim(), + children: [ + for (final child in node.children) + switch (child.localName) { + 'a' => TextSpan( + text: child.text.trim(), + style: const TextStyle(decoration: TextDecoration.underline), + recognizer: TapGestureRecognizer() + ..onTap = () { + launchUrlString(child.attributes['href']!); + }, + ), + _ => TextSpan(text: child.text.trim()), + }, + ], + ), + style: Theme.of(context).textTheme.bodyLarge, + ), + ); + break; + case 'a': + // drop single link + break; + case 'div': + // ignore div text, normally it is not meaningful + widgets.addAll(_parseHtmlToWidgets(node.children)); + break; + case 'hr': + widgets.add(const Divider()); + break; + case 'img': + var src = node.attributes['src']; + if (src == null) break; + final width = double.tryParse(node.attributes['width'] ?? 'null'); + final height = double.tryParse(node.attributes['height'] ?? 'null'); + final ratio = width != null && height != null ? width / height : 1.0; + if (!src.startsWith('http')) { + final baseUri = Uri.parse(_article!.url); + final baseUrl = '${baseUri.scheme}://${baseUri.host}'; + src = '$baseUrl/$src'; + } + widgets.add( + AspectRatio( + aspectRatio: ratio, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(8)), + border: Border.all( + color: Theme.of(context).dividerColor, + width: 1, + ), + ), + height: height ?? double.infinity, + child: ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(8)), + child: AutoResizeUniversalImage(src, fit: BoxFit.cover), + ), + ), + ), + ); + break; + default: + widgets.addAll(_parseHtmlToWidgets(node.children)); + break; + } + } + + return widgets; + } + @override void initState() { super.initState(); @@ -43,27 +143,6 @@ class _NewsDetailScreenState extends State { bool _isReadingFromReader = true; - String get _htmlContent => """ - - - - - - - ${_article?.content ?? ''} - - - """; - - InAppWebViewController? _webViewController; - @override Widget build(BuildContext context) { return AppScaffold( @@ -82,30 +161,68 @@ class _NewsDetailScreenState extends State { child: Text('newsReadingProviderSwap').tr(), onPressed: () { setState(() => _isReadingFromReader = !_isReadingFromReader); - if (!_isReadingFromReader) { - _webViewController?.goTo(historyItem: WebHistoryItem(url: WebUri(_article!.url))); - } else { - _webViewController?.goBack(); - } }, ), ], ), - Expanded( - child: InAppWebView( - key: Key('news-detail-webview-${widget.hash}-$_isReadingFromReader'), - onWebViewCreated: (controller) { - _webViewController = controller; - }, - initialUrlRequest: URLRequest(url: WebUri(_article!.url)), - onLoadStop: (controller, url) { - print("Loaded: $url"); - }, - onLoadError: (controller, url, code, message) { - print("Error loading $url: $message ($code)"); - }, + if (_articleFragment != null && _isReadingFromReader) + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text(_article!.title, style: Theme.of(context).textTheme.titleLarge), + Builder(builder: (context) { + final htmlDescription = parse(_article!.description); + return Text( + htmlDescription.children.map((ele) => ele.text.trim()).join(), + style: Theme.of(context).textTheme.bodyMedium, + ); + }), + Builder(builder: (context) { + final date = _article!.publishedAt ?? _article!.createdAt; + return Row( + spacing: 2, + children: [ + Text(DateFormat().format(date)).textStyle(Theme.of(context).textTheme.bodySmall!), + Text(' · ').textStyle(Theme.of(context).textTheme.bodySmall!).bold(), + Text(RelativeTime(context).format(date)).textStyle(Theme.of(context).textTheme.bodySmall!), + ], + ).opacity(0.75); + }), + Text('newsDisclaimer').tr().textStyle(Theme.of(context).textTheme.bodySmall!).opacity(0.75), + const Divider(), + ..._parseHtmlToWidgets(_articleFragment!.children), + const Divider(), + InkWell( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Reference from original website', + style: TextStyle(decoration: TextDecoration.underline), + ), + const Gap(4), + Icon(Icons.launch, size: 16), + ], + ).opacity(0.85), + onTap: () { + launchUrlString(_article!.url); + }, + ), + Gap(MediaQuery.of(context).padding.bottom), + ], + ).padding(horizontal: 12, vertical: 16), + ), + ) + else if (_article != null) + Expanded( + child: InAppWebView( + key: GlobalKey(), + initialUrlRequest: URLRequest(url: WebUri(_article!.url)), + ), ), - ), ], ), ); diff --git a/lib/screens/news/news_list.dart b/lib/screens/news/news_list.dart index 7efa1a6..e7094e9 100644 --- a/lib/screens/news/news_list.dart +++ b/lib/screens/news/news_list.dart @@ -2,7 +2,9 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; +import 'package:html/parser.dart'; import 'package:provider/provider.dart'; +import 'package:relative_time/relative_time.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:surface/providers/sn_network.dart'; import 'package:surface/types/news.dart'; @@ -81,8 +83,12 @@ class _NewsScreenState extends State { }, body: TabBarView( children: [ - _NewsArticleListWidget(), - for (final source in _sources!) _NewsArticleListWidget(source: source.id), + _NewsArticleListWidget(allSources: _sources!), + for (final source in _sources!) + _NewsArticleListWidget( + source: source.id, + allSources: _sources!, + ), ], ), ), @@ -93,8 +99,9 @@ class _NewsScreenState extends State { class _NewsArticleListWidget extends StatefulWidget { final String? source; + final List allSources; - const _NewsArticleListWidget({this.source}); + const _NewsArticleListWidget({this.source, required this.allSources}); @override State<_NewsArticleListWidget> createState() => _NewsArticleListWidgetState(); @@ -154,6 +161,9 @@ class _NewsArticleListWidgetState extends State<_NewsArticleListWidget> { final baseUri = Uri.parse(article.url); final baseUrl = '${baseUri.scheme}://${baseUri.host}'; + final htmlDescription = parse(article.description); + final date = article.publishedAt ?? article.createdAt; + return Card( child: InkWell( radius: 8, @@ -164,21 +174,43 @@ class _NewsArticleListWidgetState extends State<_NewsArticleListWidget> { ); }, child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ if (article.thumbnail.isNotEmpty && !article.thumbnail.endsWith('.svg')) ClipRRect( borderRadius: BorderRadius.all(Radius.circular(8)), child: AspectRatio( aspectRatio: 16 / 9, - child: AutoResizeUniversalImage('$baseUrl/${article.thumbnail}'), + child: AutoResizeUniversalImage( + article.thumbnail.startsWith('http') ? article.thumbnail : '$baseUrl/${article.thumbnail}', + ), ), ), - Text(article.title).textStyle(Theme.of(context).textTheme.titleLarge!), - Text(article.description).textStyle(Theme.of(context).textTheme.bodyMedium!), + const Gap(16), + Text(article.title).textStyle(Theme.of(context).textTheme.titleLarge!).padding(horizontal: 16), const Gap(8), - Text(article.source).textStyle(Theme.of(context).textTheme.bodySmall!), + Text(htmlDescription.children.map((ele) => ele.text.trim()).join()) + .textStyle(Theme.of(context).textTheme.bodyMedium!) + .padding(horizontal: 16), + const Gap(8), + Row( + spacing: 2, + children: [ + Text(widget.allSources.where((x) => x.id == article.source).first.label) + .textStyle(Theme.of(context).textTheme.bodySmall!), + ], + ).opacity(0.75).padding(horizontal: 16), + Row( + spacing: 2, + children: [ + Text(DateFormat().format(date)).textStyle(Theme.of(context).textTheme.bodySmall!), + Text(' · ').textStyle(Theme.of(context).textTheme.bodySmall!).bold(), + Text(RelativeTime(context).format(date)).textStyle(Theme.of(context).textTheme.bodySmall!), + ], + ).opacity(0.75).padding(horizontal: 16), + const Gap(16), ], - ).padding(all: 8), + ), ), ); }, diff --git a/lib/types/news.dart b/lib/types/news.dart index 1181022..289753d 100644 --- a/lib/types/news.dart +++ b/lib/types/news.dart @@ -31,7 +31,7 @@ class SnNewsArticle with _$SnNewsArticle { required String url, required String hash, required String source, - required dynamic publishedAt, + required DateTime? publishedAt, }) = _SnNewsArticle; factory SnNewsArticle.fromJson(Map json) => _$SnNewsArticleFromJson(json); diff --git a/lib/types/news.freezed.dart b/lib/types/news.freezed.dart index a157a42..c7d682c 100644 --- a/lib/types/news.freezed.dart +++ b/lib/types/news.freezed.dart @@ -285,7 +285,7 @@ mixin _$SnNewsArticle { String get url => throw _privateConstructorUsedError; String get hash => throw _privateConstructorUsedError; String get source => throw _privateConstructorUsedError; - dynamic get publishedAt => throw _privateConstructorUsedError; + DateTime? get publishedAt => throw _privateConstructorUsedError; /// Serializes this SnNewsArticle to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -315,7 +315,7 @@ abstract class $SnNewsArticleCopyWith<$Res> { String url, String hash, String source, - dynamic publishedAt}); + DateTime? publishedAt}); } /// @nodoc @@ -394,7 +394,7 @@ class _$SnNewsArticleCopyWithImpl<$Res, $Val extends SnNewsArticle> publishedAt: freezed == publishedAt ? _value.publishedAt : publishedAt // ignore: cast_nullable_to_non_nullable - as dynamic, + as DateTime?, ) as $Val); } } @@ -419,7 +419,7 @@ abstract class _$$SnNewsArticleImplCopyWith<$Res> String url, String hash, String source, - dynamic publishedAt}); + DateTime? publishedAt}); } /// @nodoc @@ -496,7 +496,7 @@ class __$$SnNewsArticleImplCopyWithImpl<$Res> publishedAt: freezed == publishedAt ? _value.publishedAt : publishedAt // ignore: cast_nullable_to_non_nullable - as dynamic, + as DateTime?, )); } } @@ -544,7 +544,7 @@ class _$SnNewsArticleImpl implements _SnNewsArticle { @override final String source; @override - final dynamic publishedAt; + final DateTime? publishedAt; @override String toString() { @@ -571,8 +571,8 @@ class _$SnNewsArticleImpl implements _SnNewsArticle { (identical(other.url, url) || other.url == url) && (identical(other.hash, hash) || other.hash == hash) && (identical(other.source, source) || other.source == source) && - const DeepCollectionEquality() - .equals(other.publishedAt, publishedAt)); + (identical(other.publishedAt, publishedAt) || + other.publishedAt == publishedAt)); } @JsonKey(includeFromJson: false, includeToJson: false) @@ -590,7 +590,7 @@ class _$SnNewsArticleImpl implements _SnNewsArticle { url, hash, source, - const DeepCollectionEquality().hash(publishedAt)); + publishedAt); /// Create a copy of SnNewsArticle /// with the given fields replaced by the non-null parameter values. @@ -621,7 +621,7 @@ abstract class _SnNewsArticle implements SnNewsArticle { required final String url, required final String hash, required final String source, - required final dynamic publishedAt}) = _$SnNewsArticleImpl; + required final DateTime? publishedAt}) = _$SnNewsArticleImpl; factory _SnNewsArticle.fromJson(Map json) = _$SnNewsArticleImpl.fromJson; @@ -649,7 +649,7 @@ abstract class _SnNewsArticle implements SnNewsArticle { @override String get source; @override - dynamic get publishedAt; + DateTime? get publishedAt; /// Create a copy of SnNewsArticle /// with the given fields replaced by the non-null parameter values. diff --git a/lib/types/news.g.dart b/lib/types/news.g.dart index 2c7f788..c8b43ed 100644 --- a/lib/types/news.g.dart +++ b/lib/types/news.g.dart @@ -39,7 +39,9 @@ _$SnNewsArticleImpl _$$SnNewsArticleImplFromJson(Map json) => url: json['url'] as String, hash: json['hash'] as String, source: json['source'] as String, - publishedAt: json['published_at'], + publishedAt: json['published_at'] == null + ? null + : DateTime.parse(json['published_at'] as String), ); Map _$$SnNewsArticleImplToJson(_$SnNewsArticleImpl instance) => @@ -55,5 +57,5 @@ Map _$$SnNewsArticleImplToJson(_$SnNewsArticleImpl instance) => 'url': instance.url, 'hash': instance.hash, 'source': instance.source, - 'published_at': instance.publishedAt, + 'published_at': instance.publishedAt?.toIso8601String(), }; diff --git a/pubspec.lock b/pubspec.lock index 2ac2676..e69ee3c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -939,7 +939,7 @@ packages: source: hosted version: "0.7.0" html: - dependency: transitive + dependency: "direct main" description: name: html sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec" diff --git a/pubspec.yaml b/pubspec.yaml index af6ffc2..41b3a2a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -116,6 +116,7 @@ dependencies: video_compress: ^3.1.3 cached_network_image: ^3.4.1 flutter_inappwebview: ^6.1.5 + html: ^0.15.5 dev_dependencies: flutter_test: