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; const NewsDetailScreen({super.key, required this.hash}); @override State createState() => _NewsDetailScreenState(); } 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((_) { if (!mounted) return; Navigator.pop(context); }); } finally { setState(() {}); } } 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('//')) { src = 'https:$src'; } else 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: Container( color: Theme.of(context).colorScheme.surfaceContainer, child: AutoResizeUniversalImage( src, fit: width != null && height != null ? BoxFit.cover : BoxFit.contain, ), ), ), ), ), ); break; default: widgets.addAll(_parseHtmlToWidgets(node.children)); break; } } return widgets; } @override void initState() { super.initState(); _fetchArticle(); } bool _isReadingFromReader = true; @override Widget build(BuildContext context) { return AppScaffold( appBar: AppBar( leading: const PageBackButton(), title: Text(_article?.title ?? 'loading'.tr()), ), body: Column( children: [ MaterialBanner( dividerColor: Colors.transparent, leading: const Icon(Icons.info), content: Text(_isReadingFromReader ? 'newsReadingFromReader'.tr() : 'newsReadingFromOriginal'.tr()), actions: [ TextButton( child: Text('newsReadingProviderSwap').tr(), onPressed: () { setState(() => _isReadingFromReader = !_isReadingFromReader); }, ), ], ), 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)), ), ), ], ), ); } }