A framework for building convergent cross-platform Nextcloud clients using Flutter.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

249 lines
8.5 KiB

part of '../neon_news.dart';
2 years ago
class NewsArticlesView extends StatefulWidget {
const NewsArticlesView({
required this.bloc,
required this.newsBloc,
2 years ago
super.key,
});
final NewsArticlesBloc bloc;
final NewsBloc newsBloc;
2 years ago
@override
State<NewsArticlesView> createState() => _NewsArticlesViewState();
}
class _NewsArticlesViewState extends State<NewsArticlesView> {
@override
void initState() {
super.initState();
widget.bloc.errors.listen((final error) {
NeonException.showSnackbar(context, error);
2 years ago
});
}
@override
Widget build(final BuildContext context) => ResultBuilder<List<NewsFeed>>.behaviorSubject(
stream: widget.newsBloc.feeds,
builder: (final context, final feeds) => ResultBuilder<List<NewsArticle>>.behaviorSubject(
stream: widget.bloc.articles,
builder: (final context, final articles) => SortBoxBuilder<ArticlesSortProperty, NewsArticle>(
sortBox: articlesSortBox,
sortPropertyOption: widget.newsBloc.options.articlesSortPropertyOption,
sortBoxOrderOption: widget.newsBloc.options.articlesSortBoxOrderOption,
input: articles.data,
builder: (final context, final sorted) => NeonListView<NewsArticle>(
scrollKey: 'news-articles',
items: feeds.hasData ? sorted : null,
isLoading: articles.isLoading || feeds.isLoading,
error: articles.error ?? feeds.error,
onRefresh: () async {
await Future.wait([
widget.bloc.refresh(),
widget.newsBloc.refresh(),
]);
},
builder: (final context, final article) => _buildArticle(
context,
article,
feeds.requireData.singleWhere((final feed) => feed.id == article.feedId),
),
topFixedChildren: [
StreamBuilder<FilterType>(
stream: widget.bloc.filterType,
builder: (final context, final selectedFilterTypeSnapshot) => Container(
margin: const EdgeInsets.symmetric(horizontal: 15),
child: DropdownButton<FilterType>(
isExpanded: true,
value: selectedFilterTypeSnapshot.data,
items: [
FilterType.all,
FilterType.unread,
if (widget.bloc.listType == null) ...[
FilterType.starred,
],
].map<DropdownMenuItem<FilterType>>(
(final a) {
late final String label;
switch (a) {
case FilterType.all:
label = AppLocalizations.of(context).articlesFilterAll;
case FilterType.unread:
label = AppLocalizations.of(context).articlesFilterUnread;
case FilterType.starred:
label = AppLocalizations.of(context).articlesFilterStarred;
default:
throw Exception('FilterType $a should not be shown');
}
return DropdownMenuItem(
value: a,
child: Text(label),
);
2 years ago
},
).toList(),
onChanged: (final value) {
widget.bloc.setFilterType(value!);
},
2 years ago
),
),
),
],
2 years ago
),
),
),
);
Widget _buildArticle(
final BuildContext context,
final NewsArticle article,
final NewsFeed feed,
) =>
ListTile(
2 years ago
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
article.title,
style: article.unread
2 years ago
? null
: Theme.of(context).textTheme.titleMedium!.copyWith(color: Theme.of(context).disabledColor),
2 years ago
),
),
if (article.mediaThumbnail != null) ...[
NeonCachedImage.url(
2 years ago
url: article.mediaThumbnail!,
size: const Size(100, 50),
2 years ago
fit: BoxFit.cover,
),
],
],
),
subtitle: Row(
children: [
Container(
margin: const EdgeInsets.only(
top: 8,
bottom: 8,
right: 8,
),
child: NewsFeedIcon(
feed: feed,
size: smallIconSize,
borderRadius: const BorderRadius.all(Radius.circular(2)),
2 years ago
),
),
RelativeTime(
date: DateTime.fromMillisecondsSinceEpoch(article.pubDate * 1000),
style: const TextStyle(
fontWeight: FontWeight.w300,
fontSize: 12,
),
),
const SizedBox(
width: 5,
),
2 years ago
Flexible(
child: Text(
feed.title,
2 years ago
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
trailing: IconButton(
onPressed: () {
if (article.starred) {
widget.bloc.unstarArticle(article);
2 years ago
} else {
widget.bloc.starArticle(article);
2 years ago
}
},
tooltip:
article.starred ? AppLocalizations.of(context).articleUnstar : AppLocalizations.of(context).articleStar,
icon: Icon(
article.starred ? Icons.star : Icons.star_outline,
color: Theme.of(context).colorScheme.primary,
),
2 years ago
),
onLongPress: () {
if (article.unread) {
widget.bloc.markArticleAsRead(article);
2 years ago
} else {
widget.bloc.markArticleAsUnread(article);
2 years ago
}
},
onTap: () async {
final viewType = widget.newsBloc.options.articleViewTypeOption.value;
String? bodyData;
try {
bodyData = _fixArticleBody(article.body);
} catch (e, s) {
debugPrint(e.toString());
debugPrint(s.toString());
}
2 years ago
if ((viewType == ArticleViewType.direct || article.url == null) && bodyData != null) {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (final context) => NewsArticlePage(
bloc: NewsArticleBloc(
widget.bloc,
article,
),
articlesBloc: widget.bloc,
useWebView: false,
bodyData: bodyData,
url: article.url,
),
),
);
} else if (viewType == ArticleViewType.internalBrowser &&
article.url != null &&
NeonPlatform.instance.canUseWebView) {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (final context) => NewsArticlePage(
bloc: NewsArticleBloc(
widget.bloc,
article,
),
articlesBloc: widget.bloc,
useWebView: true,
url: article.url,
),
),
);
} else {
if (article.unread) {
widget.bloc.markArticleAsRead(article);
}
if (article.url != null) {
await launchUrlString(
article.url!,
mode: LaunchMode.externalApplication,
);
}
}
},
);
2 years ago
String _fixArticleBody(final String b) => _fixArticleBodyElement(html_parser.parse(b).documentElement!).outerHtml;
2 years ago
html_dom.Element _fixArticleBodyElement(final html_dom.Element element) {
2 years ago
for (final attributeName in ['src', 'href']) {
final attributeValue = element.attributes[attributeName];
if (attributeValue != null && attributeValue.startsWith('//')) {
element.attributes[attributeName] = 'https:$attributeValue';
}
}
element.children.forEach(_fixArticleBodyElement);
2 years ago
return element;
}
}