✨ News Reader Basis
This commit is contained in:
113
lib/screens/news/news_detail.dart
Normal file
113
lib/screens/news/news_detail.dart
Normal file
@ -0,0 +1,113 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.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';
|
||||
|
||||
class NewsDetailScreen extends StatefulWidget {
|
||||
final String hash;
|
||||
|
||||
const NewsDetailScreen({super.key, required this.hash});
|
||||
|
||||
@override
|
||||
State<NewsDetailScreen> createState() => _NewsDetailScreenState();
|
||||
}
|
||||
|
||||
class _NewsDetailScreenState extends State<NewsDetailScreen> {
|
||||
SnNewsArticle? _article;
|
||||
|
||||
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);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err).then((_) {
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
});
|
||||
} finally {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchArticle();
|
||||
}
|
||||
|
||||
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(
|
||||
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 (!_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)");
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
189
lib/screens/news/news_list.dart
Normal file
189
lib/screens/news/news_list.dart
Normal file
@ -0,0 +1,189 @@
|
||||
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:provider/provider.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/app_bar_leading.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
import 'package:surface/widgets/universal_image.dart';
|
||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||
|
||||
class NewsScreen extends StatefulWidget {
|
||||
const NewsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<NewsScreen> createState() => _NewsScreenState();
|
||||
}
|
||||
|
||||
class _NewsScreenState extends State<NewsScreen> {
|
||||
List<SnNewsSource>? _sources;
|
||||
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
_fetchSources();
|
||||
}
|
||||
|
||||
Future<void> _fetchSources() async {
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/re/well-known/sources');
|
||||
_sources = List<SnNewsSource>.from(
|
||||
resp.data?.map((e) => SnNewsSource.fromJson(e)) ?? [],
|
||||
);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_sources == null) {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
leading: AutoAppBarLeading(),
|
||||
title: Text('screenNews').tr(),
|
||||
),
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return DefaultTabController(
|
||||
length: _sources!.length + 1,
|
||||
child: AppScaffold(
|
||||
body: NestedScrollView(
|
||||
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
|
||||
return <Widget>[
|
||||
SliverOverlapAbsorber(
|
||||
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
|
||||
sliver: SliverAppBar(
|
||||
leading: AutoAppBarLeading(),
|
||||
title: Text('screenNews').tr(),
|
||||
bottom: TabBar(
|
||||
isScrollable: true,
|
||||
tabs: [
|
||||
Tab(child: Text('newsAllSources'.tr())),
|
||||
for (final source in _sources!) Tab(child: Text(source.label)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
body: TabBarView(
|
||||
children: [
|
||||
_NewsArticleListWidget(),
|
||||
for (final source in _sources!) _NewsArticleListWidget(source: source.id),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NewsArticleListWidget extends StatefulWidget {
|
||||
final String? source;
|
||||
|
||||
const _NewsArticleListWidget({this.source});
|
||||
|
||||
@override
|
||||
State<_NewsArticleListWidget> createState() => _NewsArticleListWidgetState();
|
||||
}
|
||||
|
||||
class _NewsArticleListWidgetState extends State<_NewsArticleListWidget> {
|
||||
bool _isBusy = false;
|
||||
|
||||
int? _totalCount;
|
||||
final List<SnNewsArticle> _articles = List.empty(growable: true);
|
||||
|
||||
Future<void> _fetchArticles() async {
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/re/news', queryParameters: {
|
||||
'take': 10,
|
||||
'offset': _articles.length,
|
||||
if (widget.source != null) 'source': widget.source,
|
||||
});
|
||||
_totalCount = resp.data['count'];
|
||||
_articles.addAll(List<SnNewsArticle>.from(
|
||||
resp.data['data']?.map((e) => SnNewsArticle.fromJson(e)) ?? [],
|
||||
));
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchArticles();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _fetchArticles,
|
||||
child: InfiniteList(
|
||||
isLoading: _isBusy,
|
||||
itemCount: _articles.length,
|
||||
hasReachedMax: _totalCount != null && _articles.length >= _totalCount!,
|
||||
onFetchData: () {
|
||||
_fetchArticles();
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final article = _articles[index];
|
||||
|
||||
final baseUri = Uri.parse(article.url);
|
||||
final baseUrl = '${baseUri.scheme}://${baseUri.host}';
|
||||
|
||||
return Card(
|
||||
child: InkWell(
|
||||
radius: 8,
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'newsDetail',
|
||||
pathParameters: {'hash': article.hash},
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
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}'),
|
||||
),
|
||||
),
|
||||
Text(article.title).textStyle(Theme.of(context).textTheme.titleLarge!),
|
||||
Text(article.description).textStyle(Theme.of(context).textTheme.bodyMedium!),
|
||||
const Gap(8),
|
||||
Text(article.source).textStyle(Theme.of(context).textTheme.bodySmall!),
|
||||
],
|
||||
).padding(all: 8),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user