diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 02fba8d..601ff58 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -139,6 +139,7 @@ "fieldPostTitle": "Title", "fieldPostDescription": "Description", "fieldPostTags": "Tags", + "fieldPostCategories": "Categories", "fieldPostAlias": "Alias", "fieldPostAliasHint": "Optional, used to represent the post in URL, should follow URL-Safe.", "postPublish": "Publish", diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index f1de935..0ad7b16 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -123,6 +123,7 @@ "fieldPostTitle": "标题", "fieldPostDescription": "描述", "fieldPostTags": "标签", + "fieldPostCategories": "分类", "fieldPostAlias": "别名", "fieldPostAliasHint": "可选项,用于在 URL 中表示该帖子,应遵循 URL-Safe 的原则。", "postPublish": "发布", diff --git a/assets/translations/zh-HK.json b/assets/translations/zh-HK.json index fcd16a8..b7edfa4 100644 --- a/assets/translations/zh-HK.json +++ b/assets/translations/zh-HK.json @@ -123,6 +123,7 @@ "fieldPostTitle": "標題", "fieldPostDescription": "描述", "fieldPostTags": "標籤", + "fieldPostCategories": "分類", "fieldPostAlias": "別名", "fieldPostAliasHint": "可選項,用於在 URL 中表示該帖子,應遵循 URL-Safe 的原則。", "postPublish": "發佈", diff --git a/assets/translations/zh-TW.json b/assets/translations/zh-TW.json index 7760052..b1c0b7a 100644 --- a/assets/translations/zh-TW.json +++ b/assets/translations/zh-TW.json @@ -123,6 +123,7 @@ "fieldPostTitle": "標題", "fieldPostDescription": "描述", "fieldPostTags": "標籤", + "fieldPostCategories": "分類", "fieldPostAlias": "別名", "fieldPostAliasHint": "可選項,用於在 URL 中表示該帖子,應遵循 URL-Safe 的原則。", "postPublish": "釋出", diff --git a/lib/controllers/post_write_controller.dart b/lib/controllers/post_write_controller.dart index aae9232..9c4913e 100644 --- a/lib/controllers/post_write_controller.dart +++ b/lib/controllers/post_write_controller.dart @@ -178,6 +178,7 @@ class PostWriteController extends ChangeNotifier { List visibleUsers = List.empty(); List invisibleUsers = List.empty(); List tags = List.empty(); + List categories = List.empty(); PostWriteMedia? thumbnail; List attachments = List.empty(growable: true); DateTime? publishedAt, publishedUntil; @@ -207,6 +208,7 @@ class PostWriteController extends ChangeNotifier { invisibleUsers = List.from(post.invisibleUsersList ?? []); visibility = post.visibility; tags = List.from(post.tags.map((ele) => ele.alias)); + categories = List.from(post.categories.map((ele) => ele.alias)); attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []); if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) { @@ -345,6 +347,7 @@ class PostWriteController extends ChangeNotifier { if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid, 'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(), 'tags': tags.map((ele) => {'alias': ele}).toList(), + 'categories': categories.map((ele) => {'alias': ele}).toList(), 'visibility': visibility, 'visible_users_list': visibleUsers, 'invisible_users_list': invisibleUsers, @@ -431,6 +434,11 @@ class PostWriteController extends ChangeNotifier { notifyListeners(); } + void setCategories(List value) { + categories = value; + notifyListeners(); + } + void setVisibility(int value) { visibility = value; notifyListeners(); @@ -467,6 +475,9 @@ class PostWriteController extends ChangeNotifier { titleController.clear(); descriptionController.clear(); contentController.clear(); + aliasController.clear(); + tags.clear(); + categories.clear(); attachments.clear(); editingPost = null; replyingPost = null; @@ -480,6 +491,7 @@ class PostWriteController extends ChangeNotifier { contentController.dispose(); titleController.dispose(); descriptionController.dispose(); + aliasController.dispose(); super.dispose(); } } diff --git a/lib/providers/userinfo.dart b/lib/providers/userinfo.dart index 03a4345..105d418 100644 --- a/lib/providers/userinfo.dart +++ b/lib/providers/userinfo.dart @@ -1,7 +1,6 @@ import 'dart:developer'; import 'package:flutter/material.dart'; -import 'package:path/path.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:surface/providers/config.dart'; diff --git a/lib/widgets/post/post_meta_editor.dart b/lib/widgets/post/post_meta_editor.dart index 1bbd710..85aea48 100644 --- a/lib/widgets/post/post_meta_editor.dart +++ b/lib/widgets/post/post_meta_editor.dart @@ -83,167 +83,178 @@ class PostMetaEditor extends StatelessWidget { return ListenableBuilder( listenable: controller, builder: (context, _) { - return Column( - children: [ - TextField( - controller: controller.titleController, - decoration: InputDecoration( - labelText: 'fieldPostTitle'.tr(), - border: UnderlineInputBorder(), - ), - onTapOutside: (_) => - FocusManager.instance.primaryFocus?.unfocus(), - ).padding(horizontal: 24), - if (controller.mode == 'articles') const Gap(4), - if (controller.mode == 'articles') + return SingleChildScrollView( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8), + child: Column( + children: [ TextField( - controller: controller.descriptionController, - maxLines: null, + controller: controller.titleController, decoration: InputDecoration( - labelText: 'fieldPostDescription'.tr(), + labelText: 'fieldPostTitle'.tr(), border: UnderlineInputBorder(), ), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), ).padding(horizontal: 24), - const Gap(4), - PostTagsField( - initialTags: controller.tags, - labelText: 'fieldPostTags'.tr(), - onUpdate: (value) { - controller.setTags(value); - }, - ).padding(horizontal: 24), - const Gap(4), - TextField( - controller: controller.aliasController, - decoration: InputDecoration( - labelText: 'fieldPostAlias'.tr(), - helperText: 'fieldPostAliasHint'.tr(), - helperMaxLines: 2, - border: UnderlineInputBorder(), - ), - onTapOutside: (_) => - FocusManager.instance.primaryFocus?.unfocus(), - ).padding(horizontal: 24), - const Gap(12), - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - leading: const Icon(Symbols.visibility), - title: Text('postVisibility').tr(), - subtitle: Text('postVisibilityDescription').tr(), - trailing: SizedBox( - width: 180, - child: DropdownButtonHideUnderline( - child: DropdownButton2( - isExpanded: true, - items: kPostVisibilityLevel.entries - .map( - (entry) => DropdownMenuItem( - value: entry.key, - child: Text( - entry.value, - style: const TextStyle(fontSize: 14), - ).tr(), - ), - ) - .toList(), - value: controller.visibility, - onChanged: (int? value) { - if (value != null) { - controller.setVisibility(value); - } - }, - buttonStyleData: const ButtonStyleData( - height: 40, - padding: EdgeInsets.symmetric( - horizontal: 4, - vertical: 8, + if (controller.mode == 'articles') const Gap(4), + if (controller.mode == 'articles') + TextField( + controller: controller.descriptionController, + maxLines: null, + decoration: InputDecoration( + labelText: 'fieldPostDescription'.tr(), + border: UnderlineInputBorder(), + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ).padding(horizontal: 24), + const Gap(4), + PostTagsField( + initialTags: controller.tags, + labelText: 'fieldPostTags'.tr(), + onUpdate: (value) { + controller.setTags(value); + }, + ).padding(horizontal: 24), + const Gap(4), + PostCategoriesField( + initialCategories: controller.categories, + labelText: 'fieldPostCategories'.tr(), + onUpdate: (value) { + controller.setCategories(value); + }, + ).padding(horizontal: 24), + const Gap(4), + TextField( + controller: controller.aliasController, + decoration: InputDecoration( + labelText: 'fieldPostAlias'.tr(), + helperText: 'fieldPostAliasHint'.tr(), + helperMaxLines: 2, + border: UnderlineInputBorder(), + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ).padding(horizontal: 24), + const Gap(12), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Symbols.visibility), + title: Text('postVisibility').tr(), + subtitle: Text('postVisibilityDescription').tr(), + trailing: SizedBox( + width: 180, + child: DropdownButtonHideUnderline( + child: DropdownButton2( + isExpanded: true, + items: kPostVisibilityLevel.entries + .map( + (entry) => DropdownMenuItem( + value: entry.key, + child: Text( + entry.value, + style: const TextStyle(fontSize: 14), + ).tr(), + ), + ) + .toList(), + value: controller.visibility, + onChanged: (int? value) { + if (value != null) { + controller.setVisibility(value); + } + }, + buttonStyleData: const ButtonStyleData( + height: 40, + padding: EdgeInsets.symmetric( + horizontal: 4, + vertical: 8, + ), ), + menuItemStyleData: const MenuItemStyleData(height: 40), ), - menuItemStyleData: const MenuItemStyleData(height: 40), ), ), ), - ), - if (controller.visibility == 2) + 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( - 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), + leading: const Icon(Symbols.event_available), + title: Text('postPublishedAt').tr(), + subtitle: Text( + controller.publishedAt != null + ? dateFormatter.format(controller.publishedAt!) + : 'unset'.tr(), + ), + trailing: controller.publishedAt != null + ? IconButton( + icon: const Icon(Symbols.cancel), + onPressed: () { + controller.setPublishedAt(null); + }, + ) + : null, + contentPadding: const EdgeInsets.only(left: 24, right: 18), onTap: () { - _selectVisibleUser(context); + _selectDate( + context, + initialDateTime: controller.publishedAt, + ).then((value) { + controller.setPublishedAt(value); + }); }, ), - 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), + leading: const Icon(Symbols.event_busy), + title: Text('postPublishedUntil').tr(), + subtitle: Text( + controller.publishedUntil != null + ? dateFormatter.format(controller.publishedUntil!) + : 'unset'.tr(), + ), + trailing: controller.publishedUntil != null + ? IconButton( + icon: const Icon(Symbols.cancel), + onPressed: () { + controller.setPublishedUntil(null); + }, + ) + : null, + contentPadding: const EdgeInsets.only(left: 24, right: 18), onTap: () { - _selectInvisibleUser(context); + _selectDate( + context, + initialDateTime: controller.publishedUntil, + ).then((value) { + controller.setPublishedUntil(value); + }); }, ), - ListTile( - leading: const Icon(Symbols.event_available), - title: Text('postPublishedAt').tr(), - subtitle: Text( - controller.publishedAt != null - ? dateFormatter.format(controller.publishedAt!) - : 'unset'.tr(), - ), - trailing: controller.publishedAt != null - ? IconButton( - icon: const Icon(Symbols.cancel), - onPressed: () { - controller.setPublishedAt(null); - }, - ) - : null, - contentPadding: const EdgeInsets.only(left: 24, right: 18), - onTap: () { - _selectDate( - context, - initialDateTime: controller.publishedAt, - ).then((value) { - controller.setPublishedAt(value); - }); - }, - ), - ListTile( - leading: const Icon(Symbols.event_busy), - title: Text('postPublishedUntil').tr(), - subtitle: Text( - controller.publishedUntil != null - ? dateFormatter.format(controller.publishedUntil!) - : 'unset'.tr(), - ), - trailing: controller.publishedUntil != null - ? IconButton( - icon: const Icon(Symbols.cancel), - onPressed: () { - controller.setPublishedUntil(null); - }, - ) - : null, - contentPadding: const EdgeInsets.only(left: 24, right: 18), - onTap: () { - _selectDate( - context, - initialDateTime: controller.publishedUntil, - ).then((value) { - controller.setPublishedUntil(value); - }); - }, - ), - ], - ).padding(vertical: 8); + ], + ).padding(vertical: 8), + ); }, ); } diff --git a/lib/widgets/post/post_tags_field.dart b/lib/widgets/post/post_tags_field.dart index a81d1bd..7b6c167 100644 --- a/lib/widgets/post/post_tags_field.dart +++ b/lib/widgets/post/post_tags_field.dart @@ -21,9 +21,9 @@ class PostTagsField extends StatefulWidget { State createState() => _PostTagsFieldState(); } -class _PostTagsFieldState extends State { - static const List kTagsDividers = [' ', ',']; +const List kTagsDividers = [' ', ',']; +class _PostTagsFieldState extends State { late final _Debounceable?, String> _debouncedSearch; final List _currentTags = List.empty(growable: true); @@ -155,6 +155,154 @@ class _PostTagsFieldState extends State { } } +class PostCategoriesField extends StatefulWidget { + final List? initialCategories; + final String labelText; + final Function(List) onUpdate; + + const PostCategoriesField({ + super.key, + this.initialCategories, + required this.labelText, + required this.onUpdate, + }); + + @override + State createState() => _PostCategoriesFieldState(); +} + +class _PostCategoriesFieldState extends State { + late final _Debounceable?, String> _debouncedSearch; + + final List _currentCategories = List.empty(growable: true); + + String? _currentSearchProbe; + List _lastAutocompleteResult = List.empty(); + TextEditingController? _textEditingController; + + Future?> _searchCategories(String probe) async { + _currentSearchProbe = probe; + + final sn = context.read(); + final resp = await sn.client.get( + '/cgi/co/categories?take=10&probe=$_currentSearchProbe', + ); + + if (_currentSearchProbe != probe) { + return null; + } + _currentSearchProbe = null; + + return resp.data.map((x) => x['alias']).toList().cast(); + } + + @override + void initState() { + super.initState(); + _debouncedSearch = _debounce?, String>(_searchCategories); + if (widget.initialCategories != null) { + _currentCategories.addAll(widget.initialCategories!); + } + } + + @override + Widget build(BuildContext context) { + return Autocomplete( + optionsBuilder: (TextEditingValue textEditingValue) async { + final result = await _debouncedSearch(textEditingValue.text); + if (result == null) { + return _lastAutocompleteResult; + } + _lastAutocompleteResult = result; + return result; + }, + onSelected: (String value) { + if (value.isEmpty) return; + if (!_currentCategories.contains(value)) { + setState(() => _currentCategories.add(value)); + } + _textEditingController?.clear(); + widget.onUpdate(_currentCategories); + }, + fieldViewBuilder: (context, controller, focusNode, onSubmitted) { + _textEditingController = controller; + return TextField( + controller: controller, + focusNode: focusNode, + decoration: InputDecoration( + label: Text(widget.labelText), + border: const UnderlineInputBorder(), + prefixIconConstraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.75, + ), + prefixIcon: _currentCategories.isNotEmpty + ? SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: _currentCategories.map((String category) { + return Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all( + Radius.circular(20.0), + ), + color: Theme.of(context).colorScheme.primary, + ), + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric( + horizontal: 10.0, vertical: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + InkWell( + child: Text( + '#$category', + style: const TextStyle(color: Colors.white), + ), + ), + const Gap(4), + InkWell( + child: const Icon( + Icons.cancel, + size: 14.0, + color: Color.fromARGB(255, 233, 233, 233), + ), + onTap: () { + setState(() => _currentCategories.remove(category)); + widget.onUpdate(_currentCategories); + }, + ) + ], + ), + ); + }).toList(), + ), + ) + : null, + ), + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + onChanged: (value) { + for (final divider in kTagsDividers) { + if (value.endsWith(divider)) { + final tagValue = value.substring(0, value.length - 1); + if (tagValue.isEmpty) return; + if (!_currentCategories.contains(tagValue)) { + setState(() => _currentCategories.add(tagValue)); + } + controller.clear(); + widget.onUpdate(_currentCategories); + break; + } + } + }, + onSubmitted: (_) { + onSubmitted(); + }, + ); + }, + ); + } +} + typedef _Debounceable = Future Function(T parameter); _Debounceable _debounce(_Debounceable function) {