Mini editor

This commit is contained in:
LittleSheep 2024-11-11 22:43:09 +08:00
parent 5b198412f6
commit e5239a6ca0
7 changed files with 341 additions and 22 deletions

View File

@ -86,6 +86,7 @@
"fieldPostTitle": "Title", "fieldPostTitle": "Title",
"fieldPostDescription": "Description", "fieldPostDescription": "Description",
"postPublish": "Publish", "postPublish": "Publish",
"postPosted": "Post has been posted.",
"postPublishedAt": "Published At", "postPublishedAt": "Published At",
"postPublishedUntil": "Published Until", "postPublishedUntil": "Published Until",
"postEditingNotice": "You're about to editing a post that posted {}.", "postEditingNotice": "You're about to editing a post that posted {}.",

View File

@ -92,6 +92,7 @@
"postReplyingNotice": "你正在回复由 {} 发布的帖子。", "postReplyingNotice": "你正在回复由 {} 发布的帖子。",
"postRepostingNotice": "你正在转发由 {} 发布的帖子。", "postRepostingNotice": "你正在转发由 {} 发布的帖子。",
"postReact": "反应", "postReact": "反应",
"postPosted": "帖子已经发表。",
"postComments": { "postComments": {
"zero": "评论", "zero": "评论",
"one": "{} 条评论", "one": "{} 条评论",

View File

@ -234,7 +234,7 @@ class PostWriteController extends ChangeNotifier {
} }
} }
void post(BuildContext context) async { Future<void> post(BuildContext context) async {
if (isBusy || publisher == null) return; if (isBusy || publisher == null) return;
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
@ -294,8 +294,9 @@ class PostWriteController extends ChangeNotifier {
data: { data: {
'publisher': publisher!.id, 'publisher': publisher!.id,
'content': contentController.text, 'content': contentController.text,
'title': titleController.text, if (titleController.text.isNotEmpty) 'title': titleController.text,
'description': descriptionController.text, if (descriptionController.text.isNotEmpty)
'description': descriptionController.text,
'attachments': attachments 'attachments': attachments
.where((e) => e.attachment != null) .where((e) => e.attachment != null)
.map((e) => e.attachment!.rid) .map((e) => e.attachment!.rid)
@ -322,8 +323,6 @@ class PostWriteController extends ChangeNotifier {
method: editingPost != null ? 'PUT' : 'POST', method: editingPost != null ? 'PUT' : 'POST',
), ),
); );
if (!context.mounted) return;
Navigator.pop(context, true);
} catch (err) { } catch (err) {
if (!context.mounted) return; if (!context.mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -368,6 +367,20 @@ class PostWriteController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void reset() {
publishedAt = null;
publishedUntil = null;
titleController.clear();
descriptionController.clear();
contentController.clear();
attachments.clear();
editingPost = null;
replyingPost = null;
repostingPost = null;
mode = kTitleMap.keys.first;
notifyListeners();
}
@override @override
void dispose() { void dispose() {
contentController.dispose(); contentController.dispose();

View File

@ -4,6 +4,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_attachment.dart';
@ -14,6 +15,7 @@ import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart'; import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_comment_list.dart'; import 'package:surface/widgets/post/post_comment_list.dart';
import 'package:surface/widgets/post/post_item.dart'; import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/post/post_mini_editor.dart';
class PostDetailScreen extends StatefulWidget { class PostDetailScreen extends StatefulWidget {
final String slug; final String slug;
@ -68,8 +70,12 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
_fetchPost(); _fetchPost();
} }
final GlobalKey<PostCommentSliverListState> _childListKey = GlobalKey();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return AppScaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
leading: BackButton( leading: BackButton(
@ -98,17 +104,47 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
), ),
if (_data != null) if (_data != null)
SliverToBoxAdapter( SliverToBoxAdapter(
child: PostItem(data: _data!), child: PostItem(data: _data!, showComments: false),
), ),
const SliverToBoxAdapter(child: Divider(height: 1)), const SliverToBoxAdapter(child: Divider(height: 1)),
if (_data != null) if (_data != null)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Text('postCommentsDetailed') child: Row(
.plural(_data!.metric.replyCount) crossAxisAlignment: CrossAxisAlignment.center,
.textStyle(Theme.of(context).textTheme.titleLarge!) children: [
.padding(horizontal: 16, top: 12, bottom: 4), const Icon(Symbols.comment, size: 24),
const Gap(16),
Text('postCommentsDetailed')
.plural(_data!.metric.replyCount)
.textStyle(Theme.of(context).textTheme.titleLarge!),
],
).padding(horizontal: 20, vertical: 12),
),
if (_data != null)
SliverToBoxAdapter(
child: Container(
height: 240,
decoration: BoxDecoration(
border: Border.symmetric(
horizontal: BorderSide(
color: Theme.of(context).dividerColor,
width: 1 / devicePixelRatio,
),
),
),
child: PostMiniEditor(
postReplyId: _data!.id,
onPost: () {
_childListKey.currentState!.refresh();
},
),
),
),
if (_data != null)
PostCommentSliverList(
key: _childListKey,
parentPostId: _data!.id,
), ),
if (_data != null) PostCommentSliverList(parentPostId: _data!.id),
SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)), SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)),
], ],
), ),

View File

@ -328,7 +328,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
), ),
onTapOutside: (_) => onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(), FocusManager.instance.primaryFocus?.unfocus(),
) ),
] ]
.expandIndexed( .expandIndexed(
(idx, ele) => [ (idx, ele) => [
@ -390,7 +390,12 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
onPressed: (_writeController.isBusy || onPressed: (_writeController.isBusy ||
_writeController.publisher == null) _writeController.publisher == null)
? null ? null
: () => _writeController.post(context), : () {
_writeController.post(context).then((_) {
if (!context.mounted) return;
Navigator.pop(context, true);
});
},
icon: const Icon(Symbols.send), icon: const Icon(Symbols.send),
label: Text('postPublish').tr(), label: Text('postPublish').tr(),
), ),

View File

@ -9,6 +9,7 @@ import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/post/post_item.dart'; import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/post/post_mini_editor.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class PostCommentSliverList extends StatefulWidget { class PostCommentSliverList extends StatefulWidget {
@ -16,10 +17,10 @@ class PostCommentSliverList extends StatefulWidget {
const PostCommentSliverList({super.key, required this.parentPostId}); const PostCommentSliverList({super.key, required this.parentPostId});
@override @override
State<PostCommentSliverList> createState() => _PostCommentSliverListState(); State<PostCommentSliverList> createState() => PostCommentSliverListState();
} }
class _PostCommentSliverListState extends State<PostCommentSliverList> { class PostCommentSliverListState extends State<PostCommentSliverList> {
bool _isBusy = true; bool _isBusy = true;
final List<SnPost> _posts = List.empty(growable: true); final List<SnPost> _posts = List.empty(growable: true);
@ -67,6 +68,11 @@ class _PostCommentSliverListState extends State<PostCommentSliverList> {
if (mounted) setState(() => _isBusy = false); if (mounted) setState(() => _isBusy = false);
} }
Future<void> refresh() async {
_posts.clear();
_fetchPosts();
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -97,7 +103,7 @@ class _PostCommentSliverListState extends State<PostCommentSliverList> {
} }
} }
class PostCommentListPopup extends StatelessWidget { class PostCommentListPopup extends StatefulWidget {
final int postId; final int postId;
final int commentCount; final int commentCount;
const PostCommentListPopup({ const PostCommentListPopup({
@ -106,8 +112,17 @@ class PostCommentListPopup extends StatelessWidget {
this.commentCount = 0, this.commentCount = 0,
}); });
@override
State<PostCommentListPopup> createState() => _PostCommentListPopupState();
}
class _PostCommentListPopupState extends State<PostCommentListPopup> {
final GlobalKey<PostCommentSliverListState> _childListKey = GlobalKey();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -117,14 +132,36 @@ class PostCommentListPopup extends StatelessWidget {
const Icon(Symbols.comment, size: 24), const Icon(Symbols.comment, size: 24),
const Gap(16), const Gap(16),
Text('postCommentsDetailed') Text('postCommentsDetailed')
.plural(commentCount) .plural(widget.commentCount)
.textStyle(Theme.of(context).textTheme.titleLarge!), .textStyle(Theme.of(context).textTheme.titleLarge!),
], ],
).padding(horizontal: 20, top: 16, bottom: 12), ).padding(horizontal: 20, top: 16, bottom: 12),
Expanded( Expanded(
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
PostCommentSliverList(parentPostId: postId), SliverToBoxAdapter(
child: Container(
height: 240,
decoration: BoxDecoration(
border: Border.symmetric(
horizontal: BorderSide(
color: Theme.of(context).dividerColor,
width: 1 / devicePixelRatio,
),
),
),
child: PostMiniEditor(
postReplyId: widget.postId,
onPost: () {
_childListKey.currentState!.refresh();
},
),
),
),
PostCommentSliverList(
key: _childListKey,
parentPostId: widget.postId,
),
], ],
), ),
), ),

View File

@ -1,10 +1,236 @@
import 'package:flutter/widgets.dart'; import 'package:dropdown_button2/dropdown_button2.dart';
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:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
class PostMiniEditor extends StatelessWidget { class PostMiniEditor extends StatefulWidget {
const PostMiniEditor({super.key}); final int? postReplyId;
final Function? onPost;
const PostMiniEditor({super.key, this.postReplyId, this.onPost});
@override
State<PostMiniEditor> createState() => _PostMiniEditorState();
}
class _PostMiniEditorState extends State<PostMiniEditor> {
final PostWriteController _writeController = PostWriteController();
bool _isFetching = false;
bool get _isLoading => _isFetching || _writeController.isLoading;
List<SnPublisher>? _publishers;
Future<void> _fetchPublishers() async {
setState(() => _isFetching = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/co/publishers');
_publishers = List<SnPublisher>.from(
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
);
_writeController.setPublisher(_publishers?.firstOrNull);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isFetching = false);
}
}
@override
void initState() {
super.initState();
_fetchPublishers();
_writeController.fetchRelatedPost(
context,
replying: widget.postReplyId,
);
}
@override
void dispose() {
_writeController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const Placeholder(); return ListenableBuilder(
listenable: _writeController,
builder: (context, _) {
return Column(
children: [
DropdownButtonHideUnderline(
child: DropdownButton2<SnPublisher>(
isExpanded: true,
hint: Text(
'fieldPostPublisher',
style: TextStyle(
fontSize: 14,
color: Theme.of(context).hintColor,
),
).tr(),
items: <DropdownMenuItem<SnPublisher>>[
...(_publishers?.map(
(item) => DropdownMenuItem<SnPublisher>(
enabled: _writeController.editingPost == null,
value: item,
child: Row(
children: [
AccountImage(content: item.avatar, radius: 16),
const Gap(8),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(item.nick).textStyle(
Theme.of(context)
.textTheme
.bodyMedium!),
Text('@${item.name}')
.textStyle(Theme.of(context)
.textTheme
.bodySmall!)
.fontSize(12),
],
),
),
],
),
),
) ??
[]),
DropdownMenuItem<SnPublisher>(
value: null,
child: Row(
children: [
CircleAvatar(
radius: 16,
backgroundColor: Colors.transparent,
foregroundColor:
Theme.of(context).colorScheme.onSurface,
child: const Icon(Symbols.add),
),
const Gap(8),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('publishersNew').tr().textStyle(
Theme.of(context).textTheme.bodyMedium!),
],
),
),
],
),
),
],
value: _writeController.publisher,
onChanged: (SnPublisher? value) {
if (value == null) {
GoRouter.of(context)
.pushNamed('accountPublisherNew')
.then((value) {
if (value == true) {
_publishers = null;
_fetchPublishers();
}
});
} else {
_writeController.setPublisher(value);
}
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.only(right: 16),
height: 48,
),
menuItemStyleData: const MenuItemStyleData(
height: 48,
),
),
),
const Divider(height: 1),
const Gap(8),
Expanded(
child: TextField(
controller: _writeController.contentController,
maxLines: null,
decoration: InputDecoration(
hintText: 'fieldPostContent'.tr(),
hintStyle: TextStyle(fontSize: 14),
isCollapsed: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
),
border: InputBorder.none,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
const Gap(8),
LoadingIndicator(isActive: _isLoading),
if (_writeController.isBusy && _writeController.progress != null)
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: _writeController.progress),
duration: Duration(milliseconds: 300),
builder: (context, value, _) =>
LinearProgressIndicator(value: value, minHeight: 2),
)
else if (_writeController.isBusy)
const LinearProgressIndicator(value: null, minHeight: 2),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(
Symbols.launch,
color: Theme.of(context).colorScheme.secondary,
),
onPressed: () {
GoRouter.of(context).pushNamed(
'postEditor',
pathParameters: {'mode': 'stories'},
queryParameters: {
if (widget.postReplyId != null)
'replying': widget.postReplyId.toString(),
},
);
},
),
TextButton.icon(
onPressed: (_writeController.isBusy ||
_writeController.publisher == null)
? null
: () {
_writeController.post(context).then((_) {
if (!context.mounted) return;
if (widget.onPost != null) widget.onPost!();
context.showSnackbar('postPosted'.tr());
_writeController.reset();
});
},
icon: const Icon(Symbols.send),
label: Text('postPublish').tr(),
),
],
).padding(left: 12, right: 16, bottom: 4),
],
);
});
} }
} }