Basic article rendering (overview)

This commit is contained in:
LittleSheep 2024-07-10 00:44:10 +08:00
parent 065cda27e9
commit 505290b2ae
9 changed files with 640 additions and 12 deletions

107
lib/models/articles.dart Normal file
View File

@ -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<Tag>? tags;
List<Category>? categories;
List<int>? attachments;
int? realmId;
Realm? realm;
DateTime? publishedAt;
bool? isDraft;
int authorId;
Account author;
int reactionCount;
Map<String, int> 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<String, dynamic> 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<Tag>(),
categories: json['categories']
?.map((x) => Category.fromJson(x))
.toList()
.cast<Category>(),
attachments: json['attachments'] != null
? List<int>.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<String, int>()
: {},
);
Map<String, dynamic> 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,
};
}

View File

@ -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({

View File

@ -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<DraftBoxScreen> {
});
},
).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();
}

View File

@ -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<ArticleAction> createState() => _ArticleActionState();
}
class _ArticleActionState extends State<ArticleAction> {
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<ArticleDeletionDialog> createState() => _ArticleDeletionDialogState();
}
class _ArticleDeletionDialogState extends State<ArticleDeletionDialog> {
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: <Widget>[
TextButton(
onPressed: _isBusy ? null : () => Navigator.pop(context),
child: Text('cancel'.tr),
),
TextButton(
onPressed: _isBusy ? null : () => performAction(),
child: Text('confirm'.tr),
),
],
);
}
}

View File

@ -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<ArticleItem> createState() => _ArticleItemState();
}
class _ArticleItemState extends State<ArticleItem> {
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<String> 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<Widget> 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),
],
);
}
}

View File

@ -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(),
),
);
}
}

View File

@ -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<ArticleQuickAction> createState() => _ArticleQuickActionState();
}
class _ArticleQuickActionState extends State<ArticleQuickAction> {
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<void> 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(),
),
],
),
)
],
),
);
}
}

View File

@ -33,7 +33,7 @@ class _PostQuickActionState extends State<PostQuickAction> {
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<PostQuickAction> {
),
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,

View File

@ -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<String, int> 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: () {