Articles

This commit is contained in:
LittleSheep 2024-07-10 10:50:10 +08:00
parent 505290b2ae
commit 8dbf6ff4f3
15 changed files with 270 additions and 40 deletions

View File

@ -73,4 +73,13 @@ class FeedProvider extends GetConnect {
return resp; return resp;
} }
Future<Response> getArticle(String alias) async {
final resp = await get('/api/articles/$alias');
if (resp.statusCode != 200) {
throw Exception(resp.body);
}
return resp;
}
} }

View File

@ -4,7 +4,8 @@ import 'package:solian/screens/about.dart';
import 'package:solian/screens/account.dart'; import 'package:solian/screens/account.dart';
import 'package:solian/screens/account/friend.dart'; import 'package:solian/screens/account/friend.dart';
import 'package:solian/screens/account/personalize.dart'; import 'package:solian/screens/account/personalize.dart';
import 'package:solian/screens/articles/article_publish.dart'; import 'package:solian/screens/articles/article_detail.dart';
import 'package:solian/screens/articles/article_editor.dart';
import 'package:solian/screens/channel/channel_chat.dart'; import 'package:solian/screens/channel/channel_chat.dart';
import 'package:solian/screens/channel/channel_detail.dart'; import 'package:solian/screens/channel/channel_detail.dart';
import 'package:solian/screens/channel/channel_organize.dart'; import 'package:solian/screens/channel/channel_organize.dart';
@ -17,7 +18,7 @@ import 'package:solian/screens/realms/realm_detail.dart';
import 'package:solian/screens/realms/realm_organize.dart'; import 'package:solian/screens/realms/realm_organize.dart';
import 'package:solian/screens/realms/realm_view.dart'; import 'package:solian/screens/realms/realm_view.dart';
import 'package:solian/screens/feed.dart'; import 'package:solian/screens/feed.dart';
import 'package:solian/screens/posts/post_publish.dart'; import 'package:solian/screens/posts/post_editor.dart';
import 'package:solian/shells/basic_shell.dart'; import 'package:solian/shells/basic_shell.dart';
import 'package:solian/shells/root_shell.dart'; import 'package:solian/shells/root_shell.dart';
import 'package:solian/shells/title_shell.dart'; import 'package:solian/shells/title_shell.dart';
@ -77,8 +78,18 @@ abstract class AppRouter {
), ),
), ),
GoRoute( GoRoute(
path: '/posts/publish', path: '/articles/view/:alias',
name: 'postCreate', name: 'articleDetail',
builder: (context, state) => TitleShell(
state: state,
child: ArticleDetailScreen(
alias: state.pathParameters['alias']!,
),
),
),
GoRoute(
path: '/posts/editor',
name: 'postEditor',
builder: (context, state) { builder: (context, state) {
final arguments = state.extra as PostPublishArguments?; final arguments = state.extra as PostPublishArguments?;
return PostPublishScreen( return PostPublishScreen(
@ -90,8 +101,8 @@ abstract class AppRouter {
}, },
), ),
GoRoute( GoRoute(
path: '/articles/publish', path: '/articles/editor',
name: 'articleCreate', name: 'articleEditor',
builder: (context, state) { builder: (context, state) {
final arguments = state.extra as ArticlePublishArguments?; final arguments = state.extra as ArticlePublishArguments?;
return ArticlePublishScreen( return ArticlePublishScreen(

View File

@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/articles.dart';
import 'package:solian/providers/content/feed.dart';
import 'package:solian/widgets/articles/article_item.dart';
import 'package:solian/widgets/centered_container.dart';
class ArticleDetailScreen extends StatefulWidget {
final String alias;
const ArticleDetailScreen({super.key, required this.alias});
@override
State<ArticleDetailScreen> createState() => _ArticleDetailScreenState();
}
class _ArticleDetailScreenState extends State<ArticleDetailScreen> {
Article? item;
Future<Article?> getDetail() async {
final FeedProvider provider = Get.find();
try {
final resp = await provider.getArticle(widget.alias);
item = Article.fromJson(resp.body);
} catch (e) {
context.showErrorDialog(e).then((_) => Navigator.pop(context));
}
return item;
}
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.surface,
child: FutureBuilder(
future: getDetail(),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data == null) {
return const Center(
child: CircularProgressIndicator(),
);
}
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: CenteredContainer(
child: ArticleItem(
item: item!,
isClickable: true,
isFullDate: true,
isFullContent: true,
),
),
),
SliverToBoxAdapter(
child: const Divider(thickness: 0.3, height: 1)
.paddingOnly(top: 4),
),
SliverToBoxAdapter(
child: SizedBox(height: MediaQuery.of(context).padding.bottom),
),
],
);
},
),
);
}
}

View File

@ -99,6 +99,8 @@ class _ArticlePublishScreenState extends State<ArticlePublishScreen> {
void syncWidget() { void syncWidget() {
if (widget.edit != null) { if (widget.edit != null) {
_titleController.text = widget.edit!.title;
_descriptionController.text = widget.edit!.description;
_contentController.text = widget.edit!.content; _contentController.text = widget.edit!.content;
_attachments = widget.edit!.attachments ?? List.empty(); _attachments = widget.edit!.attachments ?? List.empty();
_isDraft = widget.edit!.isDraft ?? false; _isDraft = widget.edit!.isDraft ?? false;

View File

@ -115,12 +115,12 @@ class FeedCreationButton extends StatelessWidget {
itemBuilder: (BuildContext context) => [ itemBuilder: (BuildContext context) => [
PopupMenuItem( PopupMenuItem(
child: ListTile( child: ListTile(
title: Text('postCreate'.tr), title: Text('postEditor'.tr),
leading: const Icon(Icons.article), leading: const Icon(Icons.article),
contentPadding: const EdgeInsets.symmetric(horizontal: 8), contentPadding: const EdgeInsets.symmetric(horizontal: 8),
), ),
onTap: () { onTap: () {
AppRouter.instance.pushNamed('postCreate').then((val) { AppRouter.instance.pushNamed('postEditor').then((val) {
if (val != null && onCreated != null) { if (val != null && onCreated != null) {
onCreated!(); onCreated!();
} }
@ -129,12 +129,12 @@ class FeedCreationButton extends StatelessWidget {
), ),
PopupMenuItem( PopupMenuItem(
child: ListTile( child: ListTile(
title: Text('articleCreate'.tr), title: Text('articleEditor'.tr),
leading: const Icon(Icons.newspaper), leading: const Icon(Icons.newspaper),
contentPadding: const EdgeInsets.symmetric(horizontal: 8), contentPadding: const EdgeInsets.symmetric(horizontal: 8),
), ),
onTap: () { onTap: () {
AppRouter.instance.pushNamed('articleCreate').then((val) { AppRouter.instance.pushNamed('articleEditor').then((val) {
if (val != null && onCreated != null) { if (val != null && onCreated != null) {
onCreated!(); onCreated!();
} }

View File

@ -12,7 +12,7 @@ import 'package:solian/providers/content/feed.dart';
import 'package:solian/providers/content/realm.dart'; import 'package:solian/providers/content/realm.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/screens/channel/channel_organize.dart'; import 'package:solian/screens/channel/channel_organize.dart';
import 'package:solian/screens/posts/post_publish.dart'; import 'package:solian/screens/posts/post_editor.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/channel/channel_list.dart'; import 'package:solian/widgets/channel/channel_list.dart';
import 'package:solian/widgets/posts/post_list.dart'; import 'package:solian/widgets/posts/post_list.dart';
@ -210,7 +210,7 @@ class _RealmPostListWidgetState extends State<RealmPostListWidget> {
onTap: () { onTap: () {
AppRouter.instance AppRouter.instance
.pushNamed( .pushNamed(
'postCreate', 'postEditor',
extra: PostPublishArguments(realm: widget.realm), extra: PostPublishArguments(realm: widget.realm),
) )
.then((value) { .then((value) {

View File

@ -23,6 +23,7 @@ const messagesEnglish = {
'delete': 'Delete', 'delete': 'Delete',
'search': 'Search', 'search': 'Search',
'post': 'Post', 'post': 'Post',
'article': 'Article',
'reply': 'Reply', 'reply': 'Reply',
'repost': 'Repost', 'repost': 'Repost',
'openInBrowser': 'Open in browser', 'openInBrowser': 'Open in browser',
@ -77,8 +78,9 @@ const messagesEnglish = {
'notifyAllRead': 'Mark all as read', 'notifyAllRead': 'Mark all as read',
'notifyEmpty': 'All notifications read', 'notifyEmpty': 'All notifications read',
'notifyEmptyCaption': 'It seems like nothing happened recently', 'notifyEmptyCaption': 'It seems like nothing happened recently',
'postCreate': 'Create new post', 'postEditor': 'Create new post',
'articleCreate': 'Create new article', 'articleEditor': 'Create new article',
'articleDetail': 'Article details',
'draftBoxOpen': 'Open draft box', 'draftBoxOpen': 'Open draft box',
'postNew': 'Create a new post', 'postNew': 'Create a new post',
'postNewInRealmHint': 'Add post in realm @realm', 'postNewInRealmHint': 'Add post in realm @realm',

View File

@ -23,6 +23,7 @@ const simplifiedChineseMessages = {
'apply': '应用', 'apply': '应用',
'search': '搜索', 'search': '搜索',
'post': '帖子', 'post': '帖子',
'article': '文章',
'reply': '回复', 'reply': '回复',
'repost': '转帖', 'repost': '转帖',
'openInBrowser': '在浏览器中打开', 'openInBrowser': '在浏览器中打开',
@ -71,8 +72,9 @@ const simplifiedChineseMessages = {
'notifyAllRead': '已读所有通知', 'notifyAllRead': '已读所有通知',
'notifyEmpty': '通知箱为空', 'notifyEmpty': '通知箱为空',
'notifyEmptyCaption': '看起来最近没发生什么呢', 'notifyEmptyCaption': '看起来最近没发生什么呢',
'postCreate': '发个帖子', 'postEditor': '发个帖子',
'articleCreate': '撰写文章', 'articleEditor': '撰写文章',
'articleDetail': '文章详情',
'draftBoxOpen': '打开草稿箱', 'draftBoxOpen': '打开草稿箱',
'postNew': '创建新帖子', 'postNew': '创建新帖子',
'postNewInRealmHint': '在领域 @realm 里发表新帖子', 'postNewInRealmHint': '在领域 @realm 里发表新帖子',

View File

@ -7,7 +7,7 @@ import 'package:solian/exts.dart';
import 'package:solian/models/articles.dart'; import 'package:solian/models/articles.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/screens/articles/article_publish.dart'; import 'package:solian/screens/articles/article_editor.dart';
class ArticleAction extends StatefulWidget { class ArticleAction extends StatefulWidget {
final Article item; final Article item;
@ -71,7 +71,7 @@ class _ArticleActionState extends State<ArticleAction> {
title: Text('edit'.tr), title: Text('edit'.tr),
onTap: () async { onTap: () async {
final value = await AppRouter.instance.pushNamed( final value = await AppRouter.instance.pushNamed(
'articleCreate', 'articleEditor',
extra: ArticlePublishArguments(edit: widget.item), extra: ArticlePublishArguments(edit: widget.item),
); );
if (value != null) { if (value != null) {

View File

@ -5,7 +5,6 @@ import 'package:solian/models/articles.dart';
import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/account_profile_popup.dart'; import 'package:solian/widgets/account/account_profile_popup.dart';
import 'package:solian/widgets/articles/article_quick_action.dart'; import 'package:solian/widgets/articles/article_quick_action.dart';
import 'package:solian/widgets/attachments/attachment_list.dart';
import 'package:solian/widgets/feed/feed_content.dart'; import 'package:solian/widgets/feed/feed_content.dart';
import 'package:solian/widgets/feed/feed_tags.dart'; import 'package:solian/widgets/feed/feed_tags.dart';
import 'package:timeago/timeago.dart' show format; import 'package:timeago/timeago.dart' show format;
@ -55,14 +54,14 @@ class _ArticleItemState extends State<ArticleItem> {
Text( Text(
item.author.nick, item.author.nick,
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
).paddingOnly(left: 12), ),
buildDate().paddingOnly(left: 4), buildDate().paddingOnly(left: 4),
], ],
); );
} }
Widget buildFooter() { Widget buildFooter() {
List<String> labels = List.empty(growable: true); List<String> labels = List.from(['article'.tr], growable: true);
if (widget.item.createdAt != widget.item.updatedAt) { if (widget.item.createdAt != widget.item.updatedAt) {
labels.add('postEdited'.trParams({ labels.add('postEdited'.trParams({
'date': DateFormat('yy/M/d H:m').format(item.updatedAt.toLocal()), 'date': DateFormat('yy/M/d H:m').format(item.updatedAt.toLocal()),
@ -103,10 +102,36 @@ class _ArticleItemState extends State<ArticleItem> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!widget.isFullContent) { if (!widget.isFullContent) {
return ListTile( return Row(
leading: AccountAvatar(content: item.author.avatar.toString()), crossAxisAlignment: CrossAxisAlignment.start,
title: Text(item.title), children: [
subtitle: Text(item.description), AccountAvatar(content: item.author.avatar.toString()),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildHeader(),
Text(item.title, style: const TextStyle(fontSize: 15)),
Text(
item.description,
style: TextStyle(
fontSize: 13,
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.8),
),
),
buildFooter(),
],
).paddingOnly(left: 12),
),
],
).paddingOnly(
top: 10,
bottom: 10,
right: 16,
left: 16,
); );
} }
@ -135,13 +160,18 @@ class _ArticleItemState extends State<ArticleItem> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
buildHeader(), buildHeader(),
FeedContent(content: item.content).paddingOnly( Text(item.title),
left: 12, Text(
right: 8, item.description,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.8),
),
), ),
buildFooter().paddingOnly(left: 12),
], ],
), ).paddingOnly(left: 12),
) )
], ],
).paddingOnly( ).paddingOnly(
@ -149,11 +179,17 @@ class _ArticleItemState extends State<ArticleItem> {
right: 16, right: 16,
left: 16, left: 16,
), ),
AttachmentList( const Divider(thickness: 0.3, height: 0.3).paddingSymmetric(
parentId: widget.item.alias, vertical: 10,
attachmentsId: item.attachments ?? List.empty(),
divided: true,
), ),
FeedContent(content: item.content).paddingSymmetric(
horizontal: 20,
),
const Divider(thickness: 0.3, height: 0.3).paddingOnly(
top: 10,
bottom: 6,
),
buildFooter().paddingOnly(left: 20),
if (widget.isReactable) if (widget.isReactable)
ArticleQuickAction( ArticleQuickAction(
isReactable: widget.isReactable, isReactable: widget.isReactable,
@ -166,7 +202,7 @@ class _ArticleItemState extends State<ArticleItem> {
}, },
).paddingOnly( ).paddingOnly(
top: 6, top: 6,
left: 60, left: 16,
right: 16, right: 16,
bottom: 10, bottom: 10,
) )

View File

@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/articles.dart';
import 'package:solian/router.dart';
import 'package:solian/widgets/articles/article_action.dart';
import 'package:solian/widgets/articles/article_item.dart';
import 'package:solian/widgets/centered_container.dart';
class ArticleListWidget extends StatelessWidget {
final bool isShowEmbed;
final bool isClickable;
final bool isNestedClickable;
final PagingController<int, Article> controller;
const ArticleListWidget({
super.key,
required this.controller,
this.isShowEmbed = true,
this.isClickable = true,
this.isNestedClickable = true,
});
@override
Widget build(BuildContext context) {
return PagedSliverList<int, Article>.separated(
pagingController: controller,
builderDelegate: PagedChildBuilderDelegate<Article>(
itemBuilder: (context, item, index) {
return RepaintBoundary(
child: CenteredContainer(
child: ArticleListEntryWidget(
isClickable: isClickable,
item: item,
onUpdate: () {
controller.refresh();
},
),
),
);
},
),
separatorBuilder: (_, __) => const Divider(thickness: 0.3, height: 0.3),
);
}
}
class ArticleListEntryWidget extends StatelessWidget {
final bool isClickable;
final Article item;
final Function onUpdate;
const ArticleListEntryWidget({
super.key,
required this.isClickable,
required this.item,
required this.onUpdate,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
child: ArticleItem(
key: Key('a${item.alias}'),
item: item,
).paddingSymmetric(vertical: 8),
onTap: () {
if (!isClickable) return;
AppRouter.instance.pushNamed(
'articleDetail',
pathParameters: {'alias': item.alias},
);
},
onLongPress: () {
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => ArticleAction(item: item),
).then((value) {
if (value != null) onUpdate();
});
},
);
}
}

View File

@ -50,7 +50,7 @@ class _ArticleQuickActionState extends State<ArticleQuickAction> {
setState(() => _isSubmitting = true); setState(() => _isSubmitting = true);
final resp = await client.post('/api/posts/${widget.item.alias}/react', { final resp = await client.post('/api/articles/${widget.item.alias}/react', {
'symbol': symbol, 'symbol': symbol,
'attitude': attitude, 'attitude': attitude,
}); });

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/articles.dart';
import 'package:solian/models/feed.dart'; import 'package:solian/models/feed.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.dart';
import 'package:solian/widgets/articles/article_list.dart';
import 'package:solian/widgets/centered_container.dart'; import 'package:solian/widgets/centered_container.dart';
import 'package:solian/widgets/posts/post_list.dart'; import 'package:solian/widgets/posts/post_list.dart';
@ -41,6 +43,15 @@ class FeedListWidget extends StatelessWidget {
controller.refresh(); controller.refresh();
}, },
); );
case 'article':
final data = Article.fromJson(item.data);
return ArticleListEntryWidget(
isClickable: isClickable,
item: data,
onUpdate: () {
controller.refresh();
},
);
default: default:
return const SizedBox(); return const SizedBox();
} }

View File

@ -8,7 +8,7 @@ import 'package:solian/exts.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/screens/posts/post_publish.dart'; import 'package:solian/screens/posts/post_editor.dart';
class PostAction extends StatefulWidget { class PostAction extends StatefulWidget {
final Post item; final Post item;
@ -73,7 +73,7 @@ class _PostActionState extends State<PostAction> {
title: Text('reply'.tr), title: Text('reply'.tr),
onTap: () async { onTap: () async {
final value = await AppRouter.instance.pushNamed( final value = await AppRouter.instance.pushNamed(
'postCreate', 'postEditor',
extra: PostPublishArguments(reply: widget.item), extra: PostPublishArguments(reply: widget.item),
); );
if (value != null) { if (value != null) {
@ -88,7 +88,7 @@ class _PostActionState extends State<PostAction> {
title: Text('repost'.tr), title: Text('repost'.tr),
onTap: () async { onTap: () async {
final value = await AppRouter.instance.pushNamed( final value = await AppRouter.instance.pushNamed(
'postCreate', 'postEditor',
extra: PostPublishArguments(repost: widget.item), extra: PostPublishArguments(repost: widget.item),
); );
if (value != null) { if (value != null) {
@ -106,7 +106,7 @@ class _PostActionState extends State<PostAction> {
title: Text('edit'.tr), title: Text('edit'.tr),
onTap: () async { onTap: () async {
final value = await AppRouter.instance.pushNamed( final value = await AppRouter.instance.pushNamed(
'postCreate', 'postEditor',
extra: PostPublishArguments(edit: widget.item), extra: PostPublishArguments(edit: widget.item),
); );
if (value != null) { if (value != null) {