From 19751617cb77638e0aea9dcbb1b013408fa8f35c Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Tue, 30 Jul 2024 20:49:01 +0800 Subject: [PATCH] :sparkles: Able to edit visibility --- lib/controllers/post_editor_controller.dart | 36 +++- lib/models/relations.dart | 8 - lib/screens/posts/post_editor.dart | 66 ++++--- lib/translations/en_us.dart | 8 + lib/translations/zh_cn.dart | 8 + lib/widgets/account/account_select.dart | 176 ++++++++++++++++++ lib/widgets/account/relative_select.dart | 16 +- ...nt_publish.dart => attachment_editor.dart} | 8 +- lib/widgets/chat/chat_message_input.dart | 4 +- .../posts/editor/post_editor_visibility.dart | 127 +++++++++++++ 10 files changed, 406 insertions(+), 51 deletions(-) create mode 100644 lib/widgets/account/account_select.dart rename lib/widgets/attachments/{attachment_publish.dart => attachment_editor.dart} (98%) create mode 100644 lib/widgets/posts/editor/post_editor_visibility.dart diff --git a/lib/controllers/post_editor_controller.dart b/lib/controllers/post_editor_controller.dart index b9971cc..8a9f7c6 100644 --- a/lib/controllers/post_editor_controller.dart +++ b/lib/controllers/post_editor_controller.dart @@ -3,11 +3,11 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:get/get_rx/get_rx.dart'; import 'package:solian/models/post.dart'; import 'package:solian/models/realm.dart'; -import 'package:solian/widgets/attachments/attachment_publish.dart'; +import 'package:solian/widgets/attachments/attachment_editor.dart'; import 'package:solian/widgets/posts/editor/post_editor_overview.dart'; +import 'package:solian/widgets/posts/editor/post_editor_visibility.dart'; import 'package:textfield_tags/textfield_tags.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -28,6 +28,10 @@ class PostEditorController extends GetxController { Rx realmZone = Rx(null); RxList attachments = RxList.empty(growable: true); + RxList visibleUsers = RxList.empty(growable: true); + RxList invisibleUsers = RxList.empty(growable: true); + + RxInt visibility = 0.obs; RxBool isDraft = false.obs; RxBool isRestoreFromLocal = false.obs; @@ -66,11 +70,20 @@ class PostEditorController extends GetxController { ); } + Future editVisibility(BuildContext context) { + return showDialog( + context: context, + builder: (context) => PostEditorVisibilityDialog( + controller: this, + ), + ); + } + Future editAttachment(BuildContext context) { return showModalBottomSheet( context: context, isScrollControlled: true, - builder: (context) => AttachmentPublishPopup( + builder: (context) => AttachmentEditorPopup( usage: 'i.attachment', current: attachments, onUpdate: (value) { @@ -116,6 +129,9 @@ class PostEditorController extends GetxController { contentController.clear(); tagController.clearTags(); attachments.clear(); + visibleUsers.clear(); + invisibleUsers.clear(); + visibility.value = 0; isDraft.value = false; isRestoreFromLocal.value = false; lastSaveTime.value = null; @@ -145,7 +161,7 @@ class PostEditorController extends GetxController { } String get typeEndpoint { - switch(mode.value) { + switch (mode.value) { case 0: return 'stories'; case 1: @@ -156,7 +172,7 @@ class PostEditorController extends GetxController { } String get type { - switch(mode.value) { + switch (mode.value) { case 0: return 'story'; case 1: @@ -193,6 +209,9 @@ class PostEditorController extends GetxController { 'tags': tagController.getTags?.map((x) => {'alias': x}).toList() ?? List.empty(), 'attachments': attachments, + 'visible_users': visibleUsers, + 'invisible_users': invisibleUsers, + 'visibility': visibility.value, 'is_draft': isDraft.value, if (replyTo.value != null) 'reply_to': replyTo.value!.id, if (repostTo.value != null) 'repost_to': repostTo.value!.id, @@ -207,7 +226,14 @@ class PostEditorController extends GetxController { contentController.text = value['content'] ?? ''; attachments.value = value['attachments'].cast() ?? List.empty(); attachments.refresh(); + visibility.value = value['visibility']; isDraft.value = value['is_draft']; + if (value['visible_users'] != null) { + visibleUsers.value = value['visible_users'].cast(); + } + if (value['invisible_users'] != null) { + invisibleUsers.value = value['invisible_users'].cast(); + } if (value['reply_to'] != null) { replyTo.value = Post.fromJson(value['reply_to']); } diff --git a/lib/models/relations.dart b/lib/models/relations.dart index c722d4f..5fd9cee 100644 --- a/lib/models/relations.dart +++ b/lib/models/relations.dart @@ -46,12 +46,4 @@ class Relationship { 'related': related.toJson(), 'status': status, }; - - Account getOtherside(int selfId) { - if (accountId != selfId) { - return account; - } else { - return related; - } - } } diff --git a/lib/screens/posts/post_editor.dart b/lib/screens/posts/post_editor.dart index bc895e4..33cadb2 100644 --- a/lib/screens/posts/post_editor.dart +++ b/lib/screens/posts/post_editor.dart @@ -148,7 +148,7 @@ class _PostPublishScreenState extends State { overflow: TextOverflow.ellipsis, ), contentPadding: const EdgeInsets.only( - left: 16, + left: 17, right: 8, top: 0, bottom: 0, @@ -225,15 +225,18 @@ class _PostPublishScreenState extends State { actions: notifyBannerActions, ), Container( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), child: TextField( maxLines: null, autofocus: true, autocorrect: true, keyboardType: TextInputType.multiline, controller: _editorController.contentController, - decoration: InputDecoration.collapsed( + decoration: InputDecoration( + border: InputBorder.none, hintText: 'postContentPlaceholder'.tr, ), onTapOutside: (_) => @@ -299,7 +302,10 @@ class _PostPublishScreenState extends State { ], ), ) - .animate(target: doShow ? 1 : 0) + .animate( + key: const Key('post-editor-hint-animation'), + target: doShow ? 1 : 0, + ) .fade(curve: Curves.easeInOut, duration: 300.ms); }), if (_editorController.mode.value == 0) @@ -325,22 +331,39 @@ class _PostPublishScreenState extends State { child: ListView( scrollDirection: Axis.horizontal, children: [ - Obx( - () => IconButton( - icon: _editorController.isDraft.value - ? const Icon(Icons.drive_file_rename_outline) - : const Icon(Icons.public), - color: _editorController.isDraft.value - ? Colors.grey.shade600 - : Colors.green.shade700, + Obx(() { + final isDraft = _editorController.isDraft.value; + return IconButton( + icon: const Icon( + Icons.drive_file_rename_outline, + color: Colors.grey, + ) + .animate( + target: isDraft ? 0 : 1, + ) + .fadeOut(duration: 150.ms) + .swap( + duration: 150.ms, + builder: (_, __) => const Icon( + Icons.public, + color: Colors.green, + ).animate().fadeIn(duration: 150.ms), + ), onPressed: () { _editorController.toggleDraftMode(); }, - ), + ); + }), + IconButton( + icon: const Icon(Icons.disabled_visible), + color: Theme.of(context).colorScheme.primary, + onPressed: () { + _editorController.editVisibility(context); + }, ), IconButton( - icon: Obx( - () => badges.Badge( + icon: Obx(() { + return badges.Badge( badgeContent: Text( _editorController.attachments.length.toString(), style: const TextStyle(color: Colors.white), @@ -351,12 +374,13 @@ class _PostPublishScreenState extends State { top: -12, end: -8, ), - child: const Icon(Icons.camera_alt), - ), - ), + child: const Icon(Icons.file_present_rounded), + ); + }), color: Theme.of(context).colorScheme.primary, - onPressed: () => - _editorController.editAttachment(context), + onPressed: () { + _editorController.editAttachment(context); + }, ), ], ).paddingSymmetric(horizontal: 6, vertical: 8), diff --git a/lib/translations/en_us.dart b/lib/translations/en_us.dart index 788468f..61078c7 100644 --- a/lib/translations/en_us.dart +++ b/lib/translations/en_us.dart @@ -98,6 +98,14 @@ const i18nEnglish = { 'unpinPost': 'Unpin this post', 'postRestoreFromLocal': 'Restore from local', 'postAutoSaveAt': 'Auto saved at @date', + 'postVisibility': 'Visibility', + 'postVisibilityAll': 'Everyone', + 'postVisibilityFriends': 'Friends', + 'postVisibilitySelected': 'Selected visible', + 'postVisibilityFiltered': 'Selected invisible', + 'postVisibilityNone': 'Only me', + 'postVisibleUsers': 'Visible users', + 'postInvisibleUsers': 'Invisible users', 'postOverview': 'Overview', 'postPinned': 'Pinned', 'postListNews': 'News', diff --git a/lib/translations/zh_cn.dart b/lib/translations/zh_cn.dart index 0d17d72..afd27aa 100644 --- a/lib/translations/zh_cn.dart +++ b/lib/translations/zh_cn.dart @@ -92,6 +92,14 @@ const i18nSimplifiedChinese = { 'unpinPost': '取消置顶本帖', 'postRestoreFromLocal': '内容从本地暂存回复', 'postAutoSaveAt': '已自动保存于 @date', + 'postVisibility': '帖子可见性', + 'postVisibilityAll': '所有人可见', + 'postVisibilityFriends': '仅好友可见', + 'postVisibilitySelected': '选中者可见', + 'postVisibilityFiltered': '选中者不可见', + 'postVisibilityNone': '仅自己可见', + 'postVisibleUsers': '可见帖子者', + 'postInvisibleUsers': '隐藏帖子者', 'postOverview': '帖子概览', 'postPinned': '已置顶', 'postEditorModeStory': '发个帖子', diff --git a/lib/widgets/account/account_select.dart b/lib/widgets/account/account_select.dart new file mode 100644 index 0000000..cd34906 --- /dev/null +++ b/lib/widgets/account/account_select.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:solian/models/account.dart'; +import 'package:solian/models/relations.dart'; +import 'package:solian/providers/auth.dart'; +import 'package:solian/providers/relation.dart'; +import 'package:solian/services.dart'; +import 'package:solian/widgets/account/account_avatar.dart'; + +class AccountSelector extends StatefulWidget { + final String title; + final Widget? Function(Account item)? trailingBuilder; + final List? initialSelection; + final Function(List)? onMultipleSelect; + + const AccountSelector({ + super.key, + required this.title, + this.trailingBuilder, + this.initialSelection, + this.onMultipleSelect, + }); + + @override + State createState() => _AccountSelectorState(); +} + +class _AccountSelectorState extends State { + final TextEditingController _probeController = TextEditingController(); + + final List _relativeUsers = List.empty(growable: true); + final List _pendingUsers = List.empty(growable: true); + final List _selectedUsers = List.empty(growable: true); + + int _accountId = 0; + + _revertSelectedUsers() async { + if (widget.initialSelection?.isEmpty ?? true) return; + final client = ServiceFinder.configureClient('auth'); + final idQuery = widget.initialSelection!.join(','); + final resp = await client.get('/users?id=$idQuery'); + + setState(() { + _selectedUsers.addAll( + resp.body + .map((e) => Account.fromJson(e)) + .toList() + .cast(), + ); + }); + } + + _getFriends() async { + final AuthProvider auth = Get.find(); + _accountId = auth.userProfile.value!['id']; + + final RelationshipProvider provider = Get.find(); + final resp = await provider.listRelationWithStatus(1); + + setState(() { + _relativeUsers.addAll( + resp.body + .map((e) => Relationship.fromJson(e).related) + .toList() + .cast(), + ); + }); + } + + _searchAccount() async { + final AuthProvider auth = Get.find(); + _accountId = auth.userProfile.value!['id']; + + if (_probeController.text.isEmpty) return; + + final client = auth.configureClient('auth'); + final resp = await client.get( + '/users/search?probe=${_probeController.text}', + ); + + setState(() { + _pendingUsers.clear(); + _pendingUsers.addAll( + resp.body.map((e) => Account.fromJson(e)).toList().cast(), + ); + }); + } + + bool _checkSelected(Account item) { + return _selectedUsers.any((x) => x.id == item.id); + } + + @override + void initState() { + super.initState(); + _getFriends(); + _revertSelectedUsers(); + } + + @override + void dispose() { + super.dispose(); + _probeController.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: MediaQuery.of(context).size.height * 0.85, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: Theme.of(context).textTheme.headlineSmall, + ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), + Container( + color: Theme.of(context).colorScheme.secondaryContainer, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: TextField( + controller: _probeController, + decoration: InputDecoration( + isCollapsed: true, + border: InputBorder.none, + hintText: 'search'.tr, + ), + onSubmitted: (_) { + _searchAccount(); + }, + ), + ), + Expanded( + child: ListView.builder( + itemCount: _pendingUsers.isEmpty + ? _relativeUsers.length + : _pendingUsers.length, + itemBuilder: (context, index) { + var element = _pendingUsers.isEmpty + ? _relativeUsers[index] + : _pendingUsers[index]; + return ListTile( + title: Text(element.nick), + subtitle: Text(element.name), + leading: AccountAvatar(content: element.avatar), + trailing: widget.trailingBuilder != null + ? widget.trailingBuilder!(element) + : _checkSelected(element) + ? const Icon(Icons.check) + : null, + onTap: element.id == _accountId + ? null + : () { + if (widget.onMultipleSelect == null) { + Navigator.pop(context, element); + return; + } + + setState(() { + final idx = _selectedUsers.indexWhere((x) => x.id == element.id); + if (idx != -1) { + _selectedUsers.removeAt(idx); + } else { + _selectedUsers.add(element); + } + }); + widget.onMultipleSelect!(_selectedUsers); + }, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/account/relative_select.dart b/lib/widgets/account/relative_select.dart index 8218938..539d9ea 100644 --- a/lib/widgets/account/relative_select.dart +++ b/lib/widgets/account/relative_select.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:solian/models/account.dart'; import 'package:solian/models/relations.dart'; -import 'package:solian/providers/auth.dart'; import 'package:solian/providers/relation.dart'; import 'package:solian/widgets/account/account_avatar.dart'; @@ -10,21 +9,17 @@ class RelativeSelector extends StatefulWidget { final String title; final Widget? Function(Account item)? trailingBuilder; - const RelativeSelector({super.key, required this.title, this.trailingBuilder}); + const RelativeSelector( + {super.key, required this.title, this.trailingBuilder}); @override State createState() => _RelativeSelectorState(); } class _RelativeSelectorState extends State { - int _accountId = 0; - final List _friends = List.empty(growable: true); - getFriends() async { - final AuthProvider auth = Get.find(); - _accountId = auth.userProfile.value!['id']; - + _getFriends() async { final RelationshipProvider provider = Get.find(); final resp = await provider.listRelationWithStatus(1); @@ -39,8 +34,7 @@ class _RelativeSelectorState extends State { @override void initState() { super.initState(); - - getFriends(); + _getFriends(); } @override @@ -58,7 +52,7 @@ class _RelativeSelectorState extends State { child: ListView.builder( itemCount: _friends.length, itemBuilder: (context, index) { - var element = _friends[index].getOtherside(_accountId); + var element = _friends[index].related; return ListTile( title: Text(element.nick), subtitle: Text(element.name), diff --git a/lib/widgets/attachments/attachment_publish.dart b/lib/widgets/attachments/attachment_editor.dart similarity index 98% rename from lib/widgets/attachments/attachment_publish.dart rename to lib/widgets/attachments/attachment_editor.dart index bfeecc7..54ea82e 100644 --- a/lib/widgets/attachments/attachment_publish.dart +++ b/lib/widgets/attachments/attachment_editor.dart @@ -18,12 +18,12 @@ import 'package:solian/providers/auth.dart'; import 'package:solian/providers/content/attachment.dart'; import 'package:solian/widgets/attachments/attachment_item.dart'; -class AttachmentPublishPopup extends StatefulWidget { +class AttachmentEditorPopup extends StatefulWidget { final String usage; final List current; final void Function(List data) onUpdate; - const AttachmentPublishPopup({ + const AttachmentEditorPopup({ super.key, required this.usage, required this.current, @@ -31,10 +31,10 @@ class AttachmentPublishPopup extends StatefulWidget { }); @override - State createState() => _AttachmentPublishPopupState(); + State createState() => _AttachmentEditorPopupState(); } -class _AttachmentPublishPopupState extends State { +class _AttachmentEditorPopupState extends State { final _imagePicker = ImagePicker(); bool _isBusy = false; diff --git a/lib/widgets/chat/chat_message_input.dart b/lib/widgets/chat/chat_message_input.dart index dea6695..5065081 100644 --- a/lib/widgets/chat/chat_message_input.dart +++ b/lib/widgets/chat/chat_message_input.dart @@ -6,7 +6,7 @@ import 'package:solian/models/account.dart'; import 'package:solian/models/channel.dart'; import 'package:solian/models/event.dart'; import 'package:solian/providers/auth.dart'; -import 'package:solian/widgets/attachments/attachment_publish.dart'; +import 'package:solian/widgets/attachments/attachment_editor.dart'; import 'package:solian/widgets/chat/chat_event.dart'; import 'package:uuid/uuid.dart'; @@ -47,7 +47,7 @@ class _ChatMessageInputState extends State { showModalBottomSheet( context: context, isScrollControlled: true, - builder: (context) => AttachmentPublishPopup( + builder: (context) => AttachmentEditorPopup( usage: 'm.attachment', current: _attachments, onUpdate: (value) => _attachments = value, diff --git a/lib/widgets/posts/editor/post_editor_visibility.dart b/lib/widgets/posts/editor/post_editor_visibility.dart new file mode 100644 index 0000000..e9b4713 --- /dev/null +++ b/lib/widgets/posts/editor/post_editor_visibility.dart @@ -0,0 +1,127 @@ +import 'package:dropdown_button2/dropdown_button2.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:solian/controllers/post_editor_controller.dart'; +import 'package:solian/widgets/account/account_select.dart'; + +class PostEditorVisibilityDialog extends StatelessWidget { + final PostEditorController controller; + + const PostEditorVisibilityDialog({super.key, required this.controller}); + + static List<(int, String)> visibilityLevels = [ + (0, 'postVisibilityAll'), + (1, 'postVisibilityFriends'), + (2, 'postVisibilitySelected'), + (3, 'postVisibilityFiltered'), + (4, 'postVisibilityNone'), + ]; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('postVisibility'.tr), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Obx(() { + return DropdownButtonFormField2( + isExpanded: true, + decoration: const InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + ), + items: visibilityLevels + .map( + (entry) => DropdownMenuItem( + value: entry.$1, + child: Text( + entry.$2.tr, + style: const TextStyle(fontSize: 14), + ), + ), + ) + .toList(), + value: controller.visibility.value, + onChanged: (int? value) { + if (value != null) { + controller.visibility.value = value; + } + }, + buttonStyleData: const ButtonStyleData(height: 20), + menuItemStyleData: const MenuItemStyleData(height: 40), + ); + }), + Obx(() { + if (controller.visibility.value != 2 && + controller.visibility.value != 3) { + return const SizedBox(height: 8); + } + return const SizedBox(); + }), + Obx(() { + if (controller.visibility.value == 2) { + return ListTile( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + contentPadding: const EdgeInsets.only(left: 16, right: 13), + trailing: const Icon(Icons.chevron_right), + title: Text('postVisibleUsers'.tr), + onTap: () { + showModalBottomSheet( + context: context, + builder: (context) => AccountSelector( + title: 'postVisibleUsers'.tr, + initialSelection: controller.visibleUsers, + onMultipleSelect: (value) { + controller.visibleUsers.value = + value.map((e) => e.id).toList(); + controller.visibleUsers.refresh(); + }, + ), + ); + }, + ); + } + return const SizedBox(); + }), + Obx(() { + if (controller.visibility.value == 3) { + return ListTile( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + contentPadding: const EdgeInsets.only(left: 16, right: 13), + trailing: const Icon(Icons.chevron_right), + title: Text('postInvisibleUsers'.tr), + onTap: () { + showModalBottomSheet( + context: context, + builder: (context) => AccountSelector( + title: 'postInvisibleUsers'.tr, + initialSelection: controller.invisibleUsers, + onMultipleSelect: (value) { + controller.invisibleUsers.value = + value.map((e) => e.id).toList(); + controller.invisibleUsers.refresh(); + }, + ), + ); + }, + ); + } + return const SizedBox(); + }), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('confirm'.tr), + ), + ], + ); + } +}