diff --git a/assets/translations/en.json b/assets/translations/en.json index aa5d199..5c1155a 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -71,6 +71,13 @@ "postVisibilitySelected": "Selected User", "postVisibilityFiltered": "Unselected User", "postVisibilityNone": "Only Me", + "postVisibleUsers": "Visible Users", + "postInvisibleUsers": "Invisible Users", + "postSelectedUsers": { + "zero": "No user", + "one": "{} user", + "other": "{} users" + }, "fieldUsername": "Username", "fieldNickname": "Nickname", "fieldEmail": "Email address", @@ -260,6 +267,7 @@ "other": "Marked {} notifications as read." }, "notificationMarkOneReadPrompt": "Marked notification {} as read.", + "search": "Search", "postSearchResult": { "zero": "No results", "one": "{} result", diff --git a/assets/translations/zh.json b/assets/translations/zh.json index 020dd02..3114711 100644 --- a/assets/translations/zh.json +++ b/assets/translations/zh.json @@ -128,6 +128,13 @@ "postVisibilitySelected": "选定的用户可见", "postVisibilityFiltered": "选定用户不可见", "postVisibilityNone": "仅自己可见", + "postVisibleUsers": "可见的用户", + "postInvisibleUsers": "不可见的用户", + "postSelectedUsers": { + "zero": "未选择用户", + "one": "选择了 {} 个用户", + "other": "选择了 {} 个用户" + }, "postEditingNotice": "你正在修改由 {} 发布的帖子。", "postReplyingNotice": "你正在回复由 {} 发布的帖子。", "postRepostingNotice": "你正在转发由 {} 发布的帖子。", @@ -260,6 +267,7 @@ "other": "已将 {} 个通知标记为已读。" }, "notificationMarkOneReadPrompt": "已将通知 {} 标记为已读。", + "search": "搜索", "postSearchResult": { "zero": "没有搜索到结果", "one": "搜索到 {} 个结果", diff --git a/lib/controllers/post_write_controller.dart b/lib/controllers/post_write_controller.dart index 8e1b0ae..4afee1e 100644 --- a/lib/controllers/post_write_controller.dart +++ b/lib/controllers/post_write_controller.dart @@ -173,6 +173,8 @@ class PostWriteController extends ChangeNotifier { SnPost? editingPost, repostingPost, replyingPost; int visibility = 0; + List visibleUsers = List.empty(); + List invisibleUsers = List.empty(); List tags = List.empty(); List attachments = List.empty(growable: true); DateTime? publishedAt, publishedUntil; @@ -197,6 +199,8 @@ class PostWriteController extends ChangeNotifier { contentController.text = post.body['content'] ?? ''; publishedAt = post.publishedAt; publishedUntil = post.publishedUntil; + visibleUsers = List.from(post.visibleUsersList ?? []); + invisibleUsers = List.from(post.invisibleUsersList ?? []); visibility = post.visibility; tags = List.from(post.tags.map((ele) => ele.alias)); attachments.addAll( @@ -296,6 +300,8 @@ class PostWriteController extends ChangeNotifier { .toList(), 'tags': tags.map((ele) => {'alias': ele}).toList(), 'visibility': visibility, + 'visible_users_list': visibleUsers, + 'invisible_users_list': invisibleUsers, if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(), if (publishedUntil != null) @@ -367,6 +373,16 @@ class PostWriteController extends ChangeNotifier { notifyListeners(); } + void setVisibleUsers(List value) { + visibleUsers = value; + notifyListeners(); + } + + void setInvisibleUsers(List value) { + invisibleUsers = value; + notifyListeners(); + } + void setIsBusy(bool value) { isBusy = value; notifyListeners(); diff --git a/lib/widgets/account/account_select.dart b/lib/widgets/account/account_select.dart new file mode 100644 index 0000000..5d1b2de --- /dev/null +++ b/lib/widgets/account/account_select.dart @@ -0,0 +1,163 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:surface/providers/sn_network.dart'; +import 'package:surface/providers/user_directory.dart'; +import 'package:surface/types/account.dart'; +import 'package:surface/widgets/account/account_image.dart'; + +class AccountSelect extends StatefulWidget { + final String title; + final Widget? Function(SnAccount item)? trailingBuilder; + final List? initialSelection; + final Function(List)? onMultipleSelect; + + const AccountSelect({ + super.key, + required this.title, + this.trailingBuilder, + this.initialSelection, + this.onMultipleSelect, + }); + + @override + State createState() => _AccountSelectState(); +} + +class _AccountSelectState 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; + + Future _revertSelectedUsers() async { + if (widget.initialSelection?.isEmpty ?? true) return; + final ud = context.read(); + final result = await ud.listAccount(widget.initialSelection!); + + setState(() { + _selectedUsers.addAll(result.where((ele) => ele != null).cast()); + }); + } + + Future _getFriends() async { + final sn = context.read(); + final resp = await sn.client.get('/cgi/id/users/me/relations?status=1'); + + setState(() { + _relativeUsers.addAll( + resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [], + ); + }); + } + + Future _searchAccount() async { + if (_probeController.text.isEmpty) return; + + final sn = context.read(); + + final resp = await sn.client.get( + '/cgi/id/users/search?probe=${_probeController.text}', + ); + + setState(() { + _pendingUsers.clear(); + _pendingUsers.addAll( + resp.data.map((e) => SnAccount.fromJson(e)).toList().cast(), + ); + }); + } + + bool _checkSelected(SnAccount 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, + ).padding(left: 24, right: 24, top: 16, 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 user = _pendingUsers.isEmpty + ? _relativeUsers[index] + : _pendingUsers[index]; + return ListTile( + title: Text(user.nick), + subtitle: Text(user.name), + leading: AccountImage(content: user.avatar), + trailing: widget.trailingBuilder != null + ? widget.trailingBuilder!(user) + : _checkSelected(user) + ? const Icon(Icons.check) + : null, + onTap: user.id == _accountId + ? null + : () { + if (widget.onMultipleSelect == null) { + Navigator.pop(context, user); + return; + } + + setState(() { + final idx = _selectedUsers + .indexWhere((x) => x.id == user.id); + if (idx != -1) { + _selectedUsers.removeAt(idx); + } else { + _selectedUsers.add(user); + } + }); + widget.onMultipleSelect!(_selectedUsers); + }, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/post/post_meta_editor.dart b/lib/widgets/post/post_meta_editor.dart index 1f60c98..dd3e83b 100644 --- a/lib/widgets/post/post_meta_editor.dart +++ b/lib/widgets/post/post_meta_editor.dart @@ -6,6 +6,7 @@ import 'package:gap/gap.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:surface/controllers/post_write_controller.dart'; +import 'package:surface/widgets/account/account_select.dart'; import 'package:surface/widgets/post/post_tags_field.dart'; class PostMetaEditor extends StatelessWidget { @@ -15,8 +16,8 @@ class PostMetaEditor extends StatelessWidget { static Map kPostVisibilityLevel = { 0: 'postVisibilityAll', 1: 'postVisibilityFriends', - // 2: 'postVisibilitySelected', TODO impl user selection - // 3: 'postVisibilityFiltered', TODO impl user filter selection + 2: 'postVisibilitySelected', + 3: 'postVisibilityFiltered', 4: 'postVisibilityNone', }; @@ -50,6 +51,32 @@ class PostMetaEditor extends StatelessWidget { return picked; } + void _selectVisibleUser(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (context) => AccountSelect( + title: 'postVisibleUsers'.tr(), + initialSelection: controller.visibleUsers, + onMultipleSelect: (value) { + controller.setVisibleUsers(value.map((ele) => ele.id).toList()); + }, + ), + ); + } + + void _selectInvisibleUser(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (context) => AccountSelect( + title: 'postInvisibleUsers'.tr(), + initialSelection: controller.invisibleUsers, + onMultipleSelect: (value) { + controller.setInvisibleUsers(value.map((ele) => ele.id).toList()); + }, + ), + ); + } + @override Widget build(BuildContext context) { final dateFormatter = DateFormat('y/M/d HH:mm:ss'); @@ -127,6 +154,30 @@ class PostMetaEditor extends StatelessWidget { ), ), ), + if (controller.visibility == 2) + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: Icon(Symbols.person), + trailing: Icon(Symbols.chevron_right), + title: Text('postVisibleUsers').tr(), + subtitle: Text('postSelectedUsers') + .plural(controller.visibleUsers.length), + onTap: () { + _selectVisibleUser(context); + }, + ), + if (controller.visibility == 3) + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: Icon(Symbols.person), + trailing: Icon(Symbols.chevron_right), + title: Text('postInvisibleUsers').tr(), + subtitle: Text('postSelectedUsers') + .plural(controller.invisibleUsers.length), + onTap: () { + _selectInvisibleUser(context); + }, + ), ListTile( leading: const Icon(Symbols.event_available), title: Text('postPublishedAt').tr(),