diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 87ee809..fe34bb0 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -769,5 +769,7 @@ "decryptingKeyNotFound": "Key not found or exchange failed, the other party may not be online", "messageUnablePreview": "Unable preview", "messageUnablePreviewEncrypted": "Unable preview encrypted message", - "postViewInGlobalDescription": "Do not view the post in the specific realm." + "postViewInGlobalDescription": "Do not view the post in the specific realm.", + "postDraftSaved": "The draft has been saved.", + "postDraftBox": "Draft Box" } diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index 6690b7f..3580333 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -767,5 +767,7 @@ "decryptingKeyNotFound": "未找到密钥对或交换失败,对方可能不在线", "messageUnablePreview": "无法预览消息", "messageUnablePreviewEncrypted": "无法预览加密消息", - "postViewInGlobalDescription": "不查看特定领域的帖子。" + "postViewInGlobalDescription": "不查看特定领域的帖子。", + "postDraftSaved": "已保存为草稿。", + "postDraftBox": "草稿箱" } diff --git a/assets/translations/zh-HK.json b/assets/translations/zh-HK.json index 46dd946..7a81952 100644 --- a/assets/translations/zh-HK.json +++ b/assets/translations/zh-HK.json @@ -767,5 +767,7 @@ "decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線", "messageUnablePreview": "無法預覽消息", "messageUnablePreviewEncrypted": "無法預覽加密消息", - "postViewInGlobalDescription": "不查看特定領域的帖子。" + "postViewInGlobalDescription": "不查看特定領域的帖子。", + "postDraftSaved": "已保存為草稿。", + "postDraftBox": "草稿箱" } diff --git a/assets/translations/zh-TW.json b/assets/translations/zh-TW.json index 3e339a7..5ff276d 100644 --- a/assets/translations/zh-TW.json +++ b/assets/translations/zh-TW.json @@ -767,5 +767,7 @@ "decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線", "messageUnablePreview": "無法預覽消息", "messageUnablePreviewEncrypted": "無法預覽加密消息", - "postViewInGlobalDescription": "不查看特定領域的帖子。" + "postViewInGlobalDescription": "不查看特定領域的帖子。", + "postDraftSaved": "已保存為草稿。", + "postDraftBox": "草稿箱" } diff --git a/lib/controllers/post_write_controller.dart b/lib/controllers/post_write_controller.dart index 392be74..af39f01 100644 --- a/lib/controllers/post_write_controller.dart +++ b/lib/controllers/post_write_controller.dart @@ -208,6 +208,7 @@ class PostWriteController extends ChangeNotifier { SnRealm? realm; SnPublisher? publisher; SnPost? editingPost, repostingPost, replyingPost; + bool editingDraft = false; int visibility = 0; List visibleUsers = List.empty(); @@ -254,6 +255,8 @@ class PostWriteController extends ChangeNotifier { post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []); poll = post.preload?.poll; + editingDraft = post.isDraft; + if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) { thumbnail = PostWriteMedia(post.preload!.thumbnail); @@ -474,7 +477,10 @@ class PostWriteController extends ChangeNotifier { notifyListeners(); } - Future sendPost(BuildContext context) async { + Future sendPost( + BuildContext context, { + bool saveAsDraft = false, + }) async { if (isBusy || publisher == null) return; final sn = context.read(); @@ -552,7 +558,7 @@ class PostWriteController extends ChangeNotifier { // Posting the content try { final baseProgressVal = progress!; - await sn.client.request( + final resp = await sn.client.request( [ '/cgi/co/$mode', if (editingPost != null) '${editingPost!.id}', @@ -585,6 +591,7 @@ class PostWriteController extends ChangeNotifier { if (videoAttachment != null) 'video': videoAttachment!.rid, if (poll != null) 'poll': poll!.id, if (realm != null) 'realm': realm!.id, + 'is_draft': saveAsDraft, }, onSendProgress: (count, total) { progress = @@ -601,7 +608,16 @@ class PostWriteController extends ChangeNotifier { method: editingPost != null ? 'PUT' : 'POST', ), ); - reset(); + if (saveAsDraft) { + if (!context.mounted) return; + editingDraft = true; + final out = SnPost.fromJson(resp.data); + final pt = context.read(); + editingPost = await pt.completePostData(out); + notifyListeners(); + } else { + reset(); + } } catch (err) { if (!context.mounted) return; context.showErrorDialog(err); diff --git a/lib/providers/post.dart b/lib/providers/post.dart index 51b2efb..c66b04c 100644 --- a/lib/providers/post.dart +++ b/lib/providers/post.dart @@ -60,16 +60,24 @@ class SnPostContentProvider { out[i] = out[i].copyWith( preload: SnPostPreload( - thumbnail: attachments.where((ele) => ele?.rid == out[i].body['thumbnail']).firstOrNull, - attachments: attachments.where((ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false).toList(), - video: attachments.where((ele) => ele?.rid == out[i].body['video']).firstOrNull, + thumbnail: attachments + .where((ele) => ele?.rid == out[i].body['thumbnail']) + .firstOrNull, + attachments: attachments + .where((ele) => + out[i].body['attachments']?.contains(ele?.rid) ?? false) + .toList(), + video: attachments + .where((ele) => ele?.rid == out[i].body['video']) + .firstOrNull, poll: poll, realm: realm, ), ); } - uids.addAll(attachments.where((ele) => ele != null).map((ele) => ele!.accountId)); + uids.addAll( + attachments.where((ele) => ele != null).map((ele) => ele!.accountId)); await _ud.listAccount(uids); return out; @@ -107,15 +115,23 @@ class SnPostContentProvider { out = out.copyWith( preload: SnPostPreload( - thumbnail: attachments.where((ele) => ele?.rid == out.body['thumbnail']).firstOrNull, - attachments: attachments.where((ele) => out.body['attachments']?.contains(ele?.rid) ?? false).toList(), - video: attachments.where((ele) => ele?.rid == out.body['video']).firstOrNull, + thumbnail: attachments + .where((ele) => ele?.rid == out.body['thumbnail']) + .firstOrNull, + attachments: attachments + .where( + (ele) => out.body['attachments']?.contains(ele?.rid) ?? false) + .toList(), + video: attachments + .where((ele) => ele?.rid == out.body['video']) + .firstOrNull, poll: poll, realm: realm, ), ); - uids.addAll(attachments.where((ele) => ele != null).map((ele) => ele!.accountId)); + uids.addAll( + attachments.where((ele) => ele != null).map((ele) => ele!.accountId)); await _ud.listAccount(uids); return out; @@ -138,17 +154,22 @@ class SnPostContentProvider { Iterable? tags, String? realm, String? channel, + bool isDraft = false, }) async { - final resp = await _sn.client.get('/cgi/co/posts', queryParameters: { - 'take': take, - 'offset': offset, - if (type != null) 'type': type, - if (author != null) 'author': author, - if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','), - if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','), - if (realm != null) 'realm': realm, - if (channel != null) 'channel': channel, - }); + final resp = await _sn.client.get( + '/cgi/co/posts${isDraft ? '/drafts' : ''}', + queryParameters: { + 'take': take, + 'offset': offset, + if (type != null) 'type': type, + if (author != null) 'author': author, + if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','), + if (categories?.isNotEmpty ?? false) + 'categories': categories!.join(','), + if (realm != null) 'realm': realm, + if (channel != null) 'channel': channel, + }, + ); final List out = await _preloadRelatedDataInBatch( List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []), ); @@ -161,7 +182,8 @@ class SnPostContentProvider { int take = 10, int offset = 0, }) async { - final resp = await _sn.client.get('/cgi/co/posts/$parentId/replies', queryParameters: { + final resp = await _sn.client + .get('/cgi/co/posts/$parentId/replies', queryParameters: { 'take': take, 'offset': offset, }); @@ -200,4 +222,9 @@ class SnPostContentProvider { ); return out; } + + Future completePostData(SnPost post) async { + final out = await _preloadRelatedDataSingle(post); + return out; + } } diff --git a/lib/router.dart b/lib/router.dart index 5352ff1..3a13d1a 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -28,6 +28,7 @@ import 'package:surface/screens/news/news_detail.dart'; import 'package:surface/screens/news/news_list.dart'; import 'package:surface/screens/notification.dart'; import 'package:surface/screens/post/post_detail.dart'; +import 'package:surface/screens/post/post_draft.dart'; import 'package:surface/screens/post/post_editor.dart'; import 'package:surface/screens/post/publisher_page.dart'; import 'package:surface/screens/post/post_search.dart'; @@ -65,6 +66,11 @@ final _appRoutes = [ name: 'explore', builder: (context, state) => const ExploreScreen(), routes: [ + GoRoute( + path: '/draft', + name: 'postDraftBox', + builder: (context, state) => const PostDraftBox(), + ), GoRoute( path: '/write', name: 'postEditor', diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index b511715..343d4c8 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -192,6 +192,21 @@ class _ExploreScreenState extends State ), ], ), + Row( + children: [ + Text('postDraftBox').tr(), + const Gap(20), + FloatingActionButton( + heroTag: null, + tooltip: 'postDraftBox'.tr(), + onPressed: () { + GoRouter.of(context).pushNamed('postDraftBox'); + _fabKey.currentState!.toggle(); + }, + child: const Icon(Symbols.box_edit), + ), + ], + ), ], ), body: NestedScrollView( @@ -293,9 +308,11 @@ class _ExploreScreenState extends State .tr() : category.name, maxLines: 1, - ).textColor(Theme.of(context) - .appBarTheme - .foregroundColor!), + ).textColor( + Theme.of(context) + .appBarTheme + .foregroundColor!, + ), ), ], ), @@ -321,9 +338,11 @@ class _ExploreScreenState extends State child: Text( 'postChannel$channel', maxLines: 1, - ).tr().textColor(Theme.of(context) - .appBarTheme - .foregroundColor), + ).tr().textColor( + Theme.of(context) + .appBarTheme + .foregroundColor, + ), ), ], ), diff --git a/lib/screens/post/post_draft.dart b/lib/screens/post/post_draft.dart new file mode 100644 index 0000000..0e20c7f --- /dev/null +++ b/lib/screens/post/post_draft.dart @@ -0,0 +1,88 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:provider/provider.dart'; +import 'package:surface/providers/post.dart'; +import 'package:surface/types/post.dart'; +import 'package:surface/widgets/dialog.dart'; +import 'package:surface/widgets/loading_indicator.dart'; +import 'package:surface/widgets/navigation/app_scaffold.dart'; +import 'package:surface/widgets/post/post_item.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; + +class PostDraftBox extends StatefulWidget { + const PostDraftBox({super.key}); + + @override + State createState() => _PostDraftBoxState(); +} + +class _PostDraftBoxState extends State { + bool _isBusy = false; + final List _posts = List.empty(growable: true); + int? _totalCount; + + Future _fetchPosts() async { + setState(() => _isBusy = true); + try { + final pt = context.read(); + final resp = await pt.listPosts( + take: 10, + offset: _posts.length, + isDraft: true, + ); + final out = resp.$1; + _totalCount = resp.$2; + if (!mounted) return; + _posts.addAll(out); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } finally { + setState(() => _isBusy = false); + } + } + + @override + Widget build(BuildContext context) { + return AppScaffold( + appBar: AppBar( + title: Text('postDraftBox').tr(), + ), + body: Column( + children: [ + LoadingIndicator(isActive: _isBusy), + Expanded( + child: RefreshIndicator( + onRefresh: () { + _posts.clear(); + return _fetchPosts(); + }, + child: InfiniteList( + padding: EdgeInsets.only(top: 8), + hasReachedMax: + _totalCount != null && _posts.length >= _totalCount!, + itemCount: _posts.length, + onFetchData: () => _fetchPosts(), + itemBuilder: (context, idx) { + final ele = _posts[idx]; + return OpenablePostItem( + data: ele, + onChanged: (data) { + _posts[idx] = data; + }, + onDeleted: () { + _posts.clear(); + _fetchPosts(); + }, + ); + }, + separatorBuilder: (_, __) => const Gap(8), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/post/post_editor.dart b/lib/screens/post/post_editor.dart index 3783901..60516f1 100644 --- a/lib/screens/post/post_editor.dart +++ b/lib/screens/post/post_editor.dart @@ -138,6 +138,15 @@ class _PostEditorScreenState extends State ], scope: HotKeyScope.inapp, ); + final HotKey _saveDraftHotKey = HotKey( + key: PhysicalKeyboardKey.keyS, + modifiers: [ + (!kIsWeb && Platform.isMacOS) + ? HotKeyModifier.meta + : HotKeyModifier.control + ], + scope: HotKeyScope.inapp, + ); void _registerHotKey() { if (kIsWeb || Platform.isAndroid || Platform.isIOS) return; @@ -153,6 +162,11 @@ class _PostEditorScreenState extends State ]); setState(() {}); }); + hotKeyManager.register(_saveDraftHotKey, keyDownHandler: (_) async { + if (mounted) { + _writeController.sendPost(context); + } + }); } void _showPublisherPopup() { @@ -218,6 +232,7 @@ class _PostEditorScreenState extends State _writeController.dispose(); if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) { hotKeyManager.unregister(_pasteHotKey); + hotKeyManager.unregister(_saveDraftHotKey); } super.dispose(); } @@ -269,6 +284,20 @@ class _PostEditorScreenState extends State : 'untitled'.tr(), ), actions: [ + IconButton( + icon: _writeController.editingDraft + ? const Icon(Icons.save) + : const Icon(Symbols.save_as), + onPressed: () { + _writeController.sendPost(context, saveAsDraft: true).then( + (_) { + if (!context.mounted) return; + context.showSnackbar('postDraftSaved'.tr()); + HapticFeedback.mediumImpact(); + }, + ); + }, + ), IconButton( icon: const Icon(Symbols.tune), onPressed: _writeController.isBusy ? null : _updateMeta, @@ -296,7 +325,8 @@ class _PostEditorScreenState extends State ), body: Column( children: [ - if (_writeController.editingPost != null) + if (_writeController.editingPost != null && + !_writeController.editingDraft) Container( padding: const EdgeInsets.only( top: 4, bottom: 4, left: 20, right: 20), diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index ff0de44..eb6d830 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -223,7 +223,7 @@ class PostItem extends StatelessWidget { onShareImage: () => _doShareViaPicture(context), onSelectAnswer: onSelectAnswer, onDeleted: () { - if (onDeleted != null) {} + onDeleted?.call(); }, ).padding(bottom: 8), if (data.preload?.video != null) @@ -272,7 +272,7 @@ class PostItem extends StatelessWidget { onShareImage: () => _doShareViaPicture(context), onSelectAnswer: onSelectAnswer, onDeleted: () { - if (onDeleted != null) {} + onDeleted?.call(); }, ).padding(horizontal: 12, top: 8, bottom: 8), if (data.preload?.video != null) @@ -363,7 +363,7 @@ class PostItem extends StatelessWidget { onShareImage: () => _doShareViaPicture(context), onSelectAnswer: onSelectAnswer, onDeleted: () { - if (onDeleted != null) onDeleted!(); + onDeleted?.call(); }, ).padding(horizontal: 12, vertical: 8), if (data.preload?.video != null)