diff --git a/lib/models/articles.dart b/lib/models/articles.dart new file mode 100644 index 0000000..6d9b0d3 --- /dev/null +++ b/lib/models/articles.dart @@ -0,0 +1,107 @@ +import 'package:solian/models/account.dart'; +import 'package:solian/models/feed.dart'; +import 'package:solian/models/realm.dart'; + +class Article { + int id; + DateTime createdAt; + DateTime updatedAt; + DateTime? deletedAt; + String alias; + String title; + String description; + String content; + List? tags; + List? categories; + List? attachments; + int? realmId; + Realm? realm; + DateTime? publishedAt; + bool? isDraft; + int authorId; + Account author; + int reactionCount; + Map reactionList; + + Article({ + required this.id, + required this.createdAt, + required this.updatedAt, + required this.deletedAt, + required this.alias, + required this.title, + required this.description, + required this.content, + required this.tags, + required this.categories, + required this.attachments, + required this.realmId, + required this.realm, + required this.publishedAt, + required this.isDraft, + required this.authorId, + required this.author, + required this.reactionCount, + required this.reactionList, + }); + + factory Article.fromJson(Map json) => Article( + id: json['id'], + createdAt: DateTime.parse(json['created_at']), + updatedAt: DateTime.parse(json['updated_at']), + deletedAt: json['deleted_at'] != null + ? DateTime.parse(json['deleted_at']) + : null, + alias: json['alias'], + title: json['title'], + description: json['description'], + content: json['content'], + tags: json['tags']?.map((x) => Tag.fromJson(x)).toList().cast(), + categories: json['categories'] + ?.map((x) => Category.fromJson(x)) + .toList() + .cast(), + attachments: json['attachments'] != null + ? List.from(json['attachments']) + : null, + realmId: json['realm_id'], + realm: json['realm'] != null ? Realm.fromJson(json['realm']) : null, + publishedAt: json['published_at'] != null + ? DateTime.parse(json['published_at']) + : null, + isDraft: json['is_draft'], + authorId: json['author_id'], + author: Account.fromJson(json['author']), + reactionCount: json['reaction_count'], + reactionList: json['reaction_list'] != null + ? json['reaction_list'] + .map((key, value) => MapEntry( + key, + int.tryParse(value.toString()) ?? + (value is double ? value.toInt() : null))) + .cast() + : {}, + ); + + Map toJson() => { + 'id': id, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + 'deleted_at': deletedAt, + 'alias': alias, + 'title': title, + 'description': description, + 'content': content, + 'tags': tags, + 'categories': categories, + 'attachments': attachments, + 'realm_id': realmId, + 'realm': realm?.toJson(), + 'published_at': publishedAt?.toIso8601String(), + 'is_draft': isDraft, + 'author_id': authorId, + 'author': author.toJson(), + 'reaction_count': reactionCount, + 'reaction_list': reactionList, + }; +} diff --git a/lib/screens/articles/article_publish.dart b/lib/screens/articles/article_publish.dart index 1539a7d..b49c6b3 100644 --- a/lib/screens/articles/article_publish.dart +++ b/lib/screens/articles/article_publish.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:get/get.dart'; import 'package:solian/exts.dart'; -import 'package:solian/models/post.dart'; +import 'package:solian/models/articles.dart'; import 'package:solian/models/realm.dart'; import 'package:solian/providers/auth.dart'; import 'package:solian/router.dart'; @@ -15,14 +15,14 @@ import 'package:textfield_tags/textfield_tags.dart'; import 'package:badges/badges.dart' as badges; class ArticlePublishArguments { - final Post? edit; + final Article? edit; final Realm? realm; ArticlePublishArguments({this.edit, this.realm}); } class ArticlePublishScreen extends StatefulWidget { - final Post? edit; + final Article? edit; final Realm? realm; const ArticlePublishScreen({ diff --git a/lib/screens/feed/draft_box.dart b/lib/screens/feed/draft_box.dart index af4d109..ed6b784 100644 --- a/lib/screens/feed/draft_box.dart +++ b/lib/screens/feed/draft_box.dart @@ -1,6 +1,7 @@ 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/models/feed.dart'; import 'package:solian/models/pagination.dart'; import 'package:solian/models/post.dart'; @@ -8,6 +9,8 @@ import 'package:solian/providers/content/feed.dart'; import 'package:solian/screens/feed.dart'; import 'package:solian/theme.dart'; import 'package:solian/widgets/app_bar_title.dart'; +import 'package:solian/widgets/articles/article_action.dart'; +import 'package:solian/widgets/articles/article_owned_list.dart'; import 'package:solian/widgets/posts/post_action.dart'; import 'package:solian/widgets/posts/post_owned_list.dart'; import 'package:solian/widgets/prev_page.dart'; @@ -98,6 +101,20 @@ class _DraftBoxScreenState extends State { }); }, ).paddingOnly(left: 12, right: 12, bottom: 4); + case 'article': + final data = Article.fromJson(item.data); + return ArticleOwnedListEntry( + item: data, + onTap: () async { + showModalBottomSheet( + useRootNavigator: true, + context: context, + builder: (context) => ArticleAction(item: data), + ).then((value) { + if (value != null) _pagingController.refresh(); + }); + }, + ).paddingOnly(left: 12, right: 12, bottom: 4); default: return const SizedBox(); } diff --git a/lib/widgets/articles/article_action.dart b/lib/widgets/articles/article_action.dart new file mode 100644 index 0000000..af69716 --- /dev/null +++ b/lib/widgets/articles/article_action.dart @@ -0,0 +1,157 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:get/get.dart'; +import 'package:solian/exts.dart'; +import 'package:solian/models/articles.dart'; +import 'package:solian/providers/auth.dart'; +import 'package:solian/router.dart'; +import 'package:solian/screens/articles/article_publish.dart'; + +class ArticleAction extends StatefulWidget { + final Article item; + + const ArticleAction({super.key, required this.item}); + + @override + State createState() => _ArticleActionState(); +} + +class _ArticleActionState extends State { + bool _isBusy = true; + bool _canModifyContent = false; + + void checkAbleToModifyContent() async { + final AuthProvider provider = Get.find(); + if (!await provider.isAuthorized) return; + + setState(() => _isBusy = true); + + final prof = await provider.getProfile(); + setState(() { + _canModifyContent = prof.body?['id'] == widget.item.author.externalId; + _isBusy = false; + }); + } + + @override + void initState() { + super.initState(); + checkAbleToModifyContent(); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'postActionList'.tr, + style: Theme.of(context).textTheme.headlineSmall, + ), + Text( + '#${widget.item.id.toString().padLeft(8, '0')}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), + if (_isBusy) const LinearProgressIndicator().animate().scaleX(), + Expanded( + child: ListView( + children: [ + if (_canModifyContent) + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Icons.edit), + title: Text('edit'.tr), + onTap: () async { + final value = await AppRouter.instance.pushNamed( + 'articleCreate', + extra: ArticlePublishArguments(edit: widget.item), + ); + if (value != null) { + Navigator.pop(context, true); + } + }, + ), + if (_canModifyContent) + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Icons.delete), + title: Text('delete'.tr), + onTap: () async { + final value = await showDialog( + context: context, + builder: (context) => + ArticleDeletionDialog(item: widget.item), + ); + if (value != null) { + Navigator.pop(context, true); + } + }, + ), + ], + ), + ), + ], + ), + ); + } +} + +class ArticleDeletionDialog extends StatefulWidget { + final Article item; + + const ArticleDeletionDialog({super.key, required this.item}); + + @override + State createState() => _ArticleDeletionDialogState(); +} + +class _ArticleDeletionDialogState extends State { + bool _isBusy = false; + + void performAction() async { + final AuthProvider auth = Get.find(); + if (!await auth.isAuthorized) return; + + final client = auth.configureClient('interactive'); + + setState(() => _isBusy = true); + final resp = await client.delete('/api/articles/${widget.item.id}'); + setState(() => _isBusy = false); + + if (resp.statusCode != 200) { + context.showErrorDialog(resp.bodyString); + } else { + Navigator.pop(context, true); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('postDeletionConfirm'.tr), + content: Text('postDeletionConfirmCaption'.trParams({ + 'content': widget.item.content + .substring(0, min(widget.item.content.length, 60)) + .trim(), + })), + actions: [ + TextButton( + onPressed: _isBusy ? null : () => Navigator.pop(context), + child: Text('cancel'.tr), + ), + TextButton( + onPressed: _isBusy ? null : () => performAction(), + child: Text('confirm'.tr), + ), + ], + ); + } +} diff --git a/lib/widgets/articles/article_item.dart b/lib/widgets/articles/article_item.dart new file mode 100644 index 0000000..a384a7e --- /dev/null +++ b/lib/widgets/articles/article_item.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; +import 'package:get/get_utils/get_utils.dart'; +import 'package:intl/intl.dart'; +import 'package:solian/models/articles.dart'; +import 'package:solian/widgets/account/account_avatar.dart'; +import 'package:solian/widgets/account/account_profile_popup.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_tags.dart'; +import 'package:timeago/timeago.dart' show format; + +class ArticleItem extends StatefulWidget { + final Article item; + final bool isClickable; + final bool isReactable; + final bool isFullDate; + final bool isFullContent; + final String? overrideAttachmentParent; + + const ArticleItem({ + super.key, + required this.item, + this.isClickable = false, + this.isReactable = true, + this.isFullDate = false, + this.isFullContent = false, + this.overrideAttachmentParent, + }); + + @override + State createState() => _ArticleItemState(); +} + +class _ArticleItemState extends State { + late final Article item; + + @override + void initState() { + item = widget.item; + super.initState(); + } + + Widget buildDate() { + if (widget.isFullDate) { + return Text(DateFormat('y/M/d H:m').format(item.createdAt.toLocal())); + } else { + return Text(format(item.createdAt.toLocal(), locale: 'en_short')); + } + } + + Widget buildHeader() { + return Row( + children: [ + Text( + item.author.nick, + style: const TextStyle(fontWeight: FontWeight.bold), + ).paddingOnly(left: 12), + buildDate().paddingOnly(left: 4), + ], + ); + } + + Widget buildFooter() { + List labels = List.empty(growable: true); + if (widget.item.createdAt != widget.item.updatedAt) { + labels.add('postEdited'.trParams({ + 'date': DateFormat('yy/M/d H:m').format(item.updatedAt.toLocal()), + })); + } + if (widget.item.realm != null) { + labels.add('postInRealm'.trParams({ + 'realm': '#${widget.item.realm!.alias}', + })); + } + + List widgets = List.empty(growable: true); + + if (widget.item.tags?.isNotEmpty ?? false) { + widgets.add(FeedTagsList(tags: widget.item.tags!)); + } + if (labels.isNotEmpty) { + widgets.add(Text( + labels.join(' ยท '), + textAlign: TextAlign.left, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75), + ), + )); + } + + if (widgets.isEmpty) { + return const SizedBox(); + } else { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: widgets, + ).paddingOnly(top: 4); + } + } + + @override + Widget build(BuildContext context) { + if (!widget.isFullContent) { + return ListTile( + leading: AccountAvatar(content: item.author.avatar.toString()), + title: Text(item.title), + subtitle: Text(item.description), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + child: AccountAvatar(content: item.author.avatar.toString()), + onTap: () { + showModalBottomSheet( + useRootNavigator: true, + isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surface, + context: context, + builder: (context) => AccountProfilePopup( + account: item.author, + ), + ); + }, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + buildHeader(), + FeedContent(content: item.content).paddingOnly( + left: 12, + right: 8, + ), + buildFooter().paddingOnly(left: 12), + ], + ), + ) + ], + ).paddingOnly( + top: 10, + right: 16, + left: 16, + ), + AttachmentList( + parentId: widget.item.alias, + attachmentsId: item.attachments ?? List.empty(), + divided: true, + ), + if (widget.isReactable) + ArticleQuickAction( + isReactable: widget.isReactable, + item: widget.item, + onReact: (symbol, changes) { + setState(() { + item.reactionList[symbol] = + (item.reactionList[symbol] ?? 0) + changes; + }); + }, + ).paddingOnly( + top: 6, + left: 60, + right: 16, + bottom: 10, + ) + else + const SizedBox(height: 10), + ], + ); + } +} diff --git a/lib/widgets/articles/article_owned_list.dart b/lib/widgets/articles/article_owned_list.dart new file mode 100644 index 0000000..59799a0 --- /dev/null +++ b/lib/widgets/articles/article_owned_list.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:solian/models/articles.dart'; +import 'package:solian/widgets/articles/article_item.dart'; + +class ArticleOwnedListEntry extends StatelessWidget { + final Article item; + final Function onTap; + + const ArticleOwnedListEntry({ + super.key, + required this.item, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: InkWell( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ArticleItem( + key: Key('a${item.alias}'), + item: item, + isClickable: false, + isReactable: false, + ), + ], + ), + onTap: () => onTap(), + ), + ); + } +} diff --git a/lib/widgets/articles/article_quick_action.dart b/lib/widgets/articles/article_quick_action.dart new file mode 100644 index 0000000..3639d5e --- /dev/null +++ b/lib/widgets/articles/article_quick_action.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:solian/exts.dart'; +import 'package:solian/models/articles.dart'; +import 'package:solian/models/reaction.dart'; +import 'package:solian/providers/auth.dart'; +import 'package:solian/widgets/posts/post_reaction.dart'; + +class ArticleQuickAction extends StatefulWidget { + final Article item; + final bool isReactable; + final void Function(String symbol, int num) onReact; + + const ArticleQuickAction({ + super.key, + required this.item, + this.isReactable = true, + required this.onReact, + }); + + @override + State createState() => _ArticleQuickActionState(); +} + +class _ArticleQuickActionState extends State { + bool _isSubmitting = false; + + void showReactMenu() { + showModalBottomSheet( + useRootNavigator: true, + context: context, + builder: (context) => PostReactionPopup( + reactionList: widget.item.reactionList, + onReact: (key, value) { + doWidgetReact(key, value.attitude); + }, + ), + ); + } + + Future doWidgetReact(String symbol, int attitude) async { + if (!widget.isReactable) return; + + final AuthProvider auth = Get.find(); + + if (_isSubmitting) return; + if (!await auth.isAuthorized) return; + + final client = auth.configureClient('interactive'); + + setState(() => _isSubmitting = true); + + final resp = await client.post('/api/posts/${widget.item.alias}/react', { + 'symbol': symbol, + 'attitude': attitude, + }); + if (resp.statusCode == 201) { + widget.onReact(symbol, 1); + context.showSnackbar('reactCompleted'.tr); + } else if (resp.statusCode == 204) { + widget.onReact(symbol, -1); + context.showSnackbar('reactUncompleted'.tr); + } else { + context.showErrorDialog(resp.bodyString); + } + + setState(() => _isSubmitting = false); + } + + @override + void initState() { + super.initState(); + + if (!widget.isReactable && widget.item.reactionList.isEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onReact('thumb_up', 0); + }); + } + } + + @override + Widget build(BuildContext context) { + const density = VisualDensity(horizontal: -4, vertical: -3); + + return SizedBox( + height: 32, + width: double.infinity, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: ListView( + shrinkWrap: true, + scrollDirection: Axis.horizontal, + children: [ + ...widget.item.reactionList.entries.map((x) { + final info = reactions[x.key]; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ActionChip( + avatar: Text(info!.icon), + label: Text(x.value.toString()), + tooltip: ':${x.key}:', + visualDensity: density, + onPressed: _isSubmitting + ? null + : () => doWidgetReact(x.key, info.attitude), + ), + ); + }), + if (widget.isReactable) + ActionChip( + avatar: const Icon(Icons.add_reaction, color: Colors.teal), + label: Text('reactAdd'.tr), + visualDensity: density, + onPressed: () => showReactMenu(), + ), + ], + ), + ) + ], + ), + ); + } +} diff --git a/lib/widgets/posts/post_quick_action.dart b/lib/widgets/posts/post_quick_action.dart index ca65baf..a9e57de 100644 --- a/lib/widgets/posts/post_quick_action.dart +++ b/lib/widgets/posts/post_quick_action.dart @@ -33,7 +33,7 @@ class _PostQuickActionState extends State { useRootNavigator: true, context: context, builder: (context) => PostReactionPopup( - item: widget.item, + reactionList: widget.item.reactionList, onReact: (key, value) { doWidgetReact(key, value.attitude); }, @@ -109,8 +109,11 @@ class _PostQuickActionState extends State { ), if (widget.isReactable && widget.isShowReply) const VerticalDivider( - thickness: 0.3, width: 0.3, indent: 8, endIndent: 8) - .paddingSymmetric(horizontal: 8), + thickness: 0.3, + width: 0.3, + indent: 8, + endIndent: 8, + ).paddingSymmetric(horizontal: 8), Expanded( child: ListView( shrinkWrap: true, diff --git a/lib/widgets/posts/post_reaction.dart b/lib/widgets/posts/post_reaction.dart index 202e125..e8a7a32 100644 --- a/lib/widgets/posts/post_reaction.dart +++ b/lib/widgets/posts/post_reaction.dart @@ -1,13 +1,16 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:solian/models/post.dart'; import 'package:solian/models/reaction.dart'; class PostReactionPopup extends StatelessWidget { - final Post item; + final Map reactionList; final void Function(String key, ReactInfo info) onReact; - const PostReactionPopup({super.key, required this.item, required this.onReact}); + const PostReactionPopup({ + super.key, + required this.reactionList, + required this.onReact, + }); @override Widget build(BuildContext context) { @@ -30,10 +33,12 @@ class PostReactionPopup extends StatelessWidget { label: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(e.key, style: const TextStyle(fontFamily: 'monospace')), + Text(e.key, + style: const TextStyle(fontFamily: 'monospace')), const SizedBox(width: 6), - Text('x${item.reactionList[e.key]?.toString() ?? '0'}', - style: const TextStyle(fontWeight: FontWeight.bold)), + Text('x${reactionList[e.key]?.toString() ?? '0'}', + style: + const TextStyle(fontWeight: FontWeight.bold)), ], ), onPressed: () {