News reader

This commit is contained in:
2025-01-26 12:38:26 +08:00
parent 963e538ae5
commit 5377161fb0
12 changed files with 243 additions and 69 deletions

View File

@ -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<NewsDetailScreen> {
SnNewsArticle? _article;
dom.Document? _articleFragment;
Future<void> _fetchArticle() async {
try {
final sn = context.read<SnNetworkProvider>();
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<NewsDetailScreen> {
}
}
List<Widget> _parseHtmlToWidgets(Iterable<dom.Element>? elements) {
if (elements == null) return [];
final List<Widget> 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<NewsDetailScreen> {
bool _isReadingFromReader = true;
String get _htmlContent => """
<!DOCTYPE html>
<html>
<head>
<style>
body {
background-color: #000000;
color: #fff;
font-family: Arial, sans-serif;
padding: 20px;
}
</style>
</head>
<body>
${_article?.content ?? ''}
</body>
</html>
""";
InAppWebViewController? _webViewController;
@override
Widget build(BuildContext context) {
return AppScaffold(
@ -82,30 +161,68 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
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)),
),
),
),
],
),
);

View File

@ -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<NewsScreen> {
},
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<NewsScreen> {
class _NewsArticleListWidget extends StatefulWidget {
final String? source;
final List<SnNewsSource> 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),
),
),
);
},