From e2e103fa670104b2262d69717502b889196d6880 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Fri, 8 Aug 2025 17:57:47 +0800 Subject: [PATCH] :sparkles: Post categories selection --- assets/i18n/en-US.json | 15 +- lib/screens/posts/compose.dart | 10 +- lib/screens/posts/compose_article.dart | 10 +- lib/widgets/post/compose_settings_sheet.dart | 168 ++++++++++++++---- .../post/compose_settings_sheet.g.dart | 29 +++ lib/widgets/post/compose_shared.dart | 19 +- 6 files changed, 189 insertions(+), 62 deletions(-) create mode 100644 lib/widgets/post/compose_settings_sheet.g.dart diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index d83e837..f35ddd3 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -769,5 +769,18 @@ "addPack": "Add Pack", "removePack": "Remove Pack", "browseAndAddStickers": "Browse and add sticker packs", - "stickerPack": "Sticker Pack" + "stickerPack": "Sticker Pack", + "postCategoryTechnology": "Technology", + "postCategoryTravel": "Travel", + "postCategoryFood": "Food", + "postCategoryHealth": "Health", + "postCategoryScience": "Science", + "postCategorySports": "Sports", + "postCategoryFinance": "Finance", + "postCategoryLife": "Life", + "postCategoryArt": "Art", + "postCategoryStudy": "Study", + "postCategoryGaming": "Gaming", + "postCategoryProgramming": "Programming", + "postCategoryMusic": "Music" } diff --git a/lib/screens/posts/compose.dart b/lib/screens/posts/compose.dart index 55a4dc9..0e45d0b 100644 --- a/lib/screens/posts/compose.dart +++ b/lib/screens/posts/compose.dart @@ -205,15 +205,7 @@ class PostComposeScreen extends HookConsumerWidget { showModalBottomSheet( context: context, isScrollControlled: true, - builder: - (context) => ComposeSettingsSheet( - visibility: state.visibility, - tagsController: state.tagsController, - categoriesController: state.categoriesController, - onVisibilityChanged: () { - // Trigger rebuild if needed - }, - ), + builder: (context) => ComposeSettingsSheet(state: state), ); } diff --git a/lib/screens/posts/compose_article.dart b/lib/screens/posts/compose_article.dart index 0d1456c..aea2fac 100644 --- a/lib/screens/posts/compose_article.dart +++ b/lib/screens/posts/compose_article.dart @@ -138,15 +138,7 @@ class ArticleComposeScreen extends HookConsumerWidget { showModalBottomSheet( context: context, isScrollControlled: true, - builder: - (context) => ComposeSettingsSheet( - visibility: state.visibility, - tagsController: state.tagsController, - categoriesController: state.categoriesController, - onVisibilityChanged: () { - // Trigger rebuild if needed - }, - ), + builder: (context) => ComposeSettingsSheet(state: state), ); } diff --git a/lib/widgets/post/compose_settings_sheet.dart b/lib/widgets/post/compose_settings_sheet.dart index 29c1e4f..5bb8edf 100644 --- a/lib/widgets/post/compose_settings_sheet.dart +++ b/lib/widgets/post/compose_settings_sheet.dart @@ -1,11 +1,30 @@ +import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/post_category.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/services/text.dart'; import 'package:island/widgets/content/sheet.dart'; +import 'package:island/widgets/post/compose_shared.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:textfield_tags/textfield_tags.dart'; +part 'compose_settings_sheet.g.dart'; + +@riverpod +Future> postCategories(Ref ref) async { + final apiClient = ref.watch(apiClientProvider); + final resp = await apiClient.get('/sphere/posts/categories'); + return resp.data + .map((e) => PostCategory.fromJson(e)) + .cast() + .toList(); +} + /// A reusable widget for tag input fields with chip display class ChipTagInputField extends StatelessWidget { final InputFieldValues inputFieldValues; @@ -98,27 +117,20 @@ class ChipTagInputField extends StatelessWidget { } } -class ComposeSettingsSheet extends HookWidget { - final ValueNotifier visibility; - final VoidCallback? onVisibilityChanged; - final StringTagController tagsController; - final StringTagController categoriesController; +class ComposeSettingsSheet extends HookConsumerWidget { + final ComposeState state; - const ComposeSettingsSheet({ - super.key, - required this.visibility, - this.onVisibilityChanged, - required this.tagsController, - required this.categoriesController, - }); + const ComposeSettingsSheet({super.key, required this.state}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; // Listen to visibility changes to trigger rebuilds - final currentVisibility = useValueListenable(visibility); + final currentVisibility = useValueListenable(state.visibility); + final currentCategories = useValueListenable(state.categories); + final postCategories = ref.watch(postCategoriesProvider); IconData getVisibilityIcon(int visibilityValue) { switch (visibilityValue) { @@ -156,11 +168,10 @@ class ComposeSettingsSheet extends HookWidget { leading: Icon(icon), title: Text(textKey.tr()), onTap: () { - visibility.value = value; - onVisibilityChanged?.call(); + state.visibility.value = value; Navigator.pop(context); }, - selected: visibility.value == value, + selected: state.visibility.value == value, contentPadding: const EdgeInsets.symmetric(horizontal: 20), ); } @@ -204,6 +215,14 @@ class ComposeSettingsSheet extends HookWidget { ); } + String getCategoryDisplayTitle(PostCategory category) { + final capitalizedSlug = category.slug.capitalizeEachWord(); + if ('postCategory$capitalizedSlug'.trExists()) { + return 'postCategory$capitalizedSlug'.tr(); + } + return category.name ?? category.slug; + } + return SheetScaffold( titleText: 'postSettings'.tr(), child: SingleChildScrollView( @@ -214,7 +233,7 @@ class ComposeSettingsSheet extends HookWidget { children: [ // Tags field TextFieldTags( - textfieldTagsController: tagsController, + textfieldTagsController: state.tagsController, textSeparators: const [' ', ','], letterCase: LetterCase.normal, validator: (String tag) { @@ -233,22 +252,105 @@ class ComposeSettingsSheet extends HookWidget { ), // Categories field - TextFieldTags( - textfieldTagsController: categoriesController, - textSeparators: const [' ', ','], - letterCase: LetterCase.small, - validator: (String tag) { - if (tag.isEmpty) return 'No, cannot be empty'; - if (tag.contains(' ')) return 'Tags should be URL-safe'; - return null; - }, - inputFieldBuilder: (context, inputFieldValues) { - return ChipTagInputField( - inputFieldValues: inputFieldValues, - labelText: 'categories', - hintText: 'categoriesHint', - ); + // FIXME: Sometimes the entire dropdown crashes: 'package:flutter/src/rendering/stack.dart': Failed assertion: line 799 pos 12: 'firstChild == null || child != null': is not true. + DropdownButtonFormField2( + isExpanded: true, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(vertical: 9), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + hint: Text('categories'.tr(), style: TextStyle(fontSize: 15)), + items: + (postCategories.value ?? []).map((item) { + return DropdownMenuItem( + value: item, + enabled: false, + child: StatefulBuilder( + builder: (context, menuSetState) { + final isSelected = state.categories.value.contains( + item, + ); + return InkWell( + onTap: () { + isSelected + ? state.categories.value = + state.categories.value + .where((e) => e != item) + .toList() + : state.categories.value = [ + ...state.categories.value, + item, + ]; + menuSetState(() {}); + }, + child: Container( + height: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + ), + child: Row( + children: [ + if (isSelected) + const Icon(Icons.check_box_outlined) + else + const Icon(Icons.check_box_outline_blank), + const SizedBox(width: 16), + Expanded( + child: Text( + getCategoryDisplayTitle(item), + style: const TextStyle(fontSize: 14), + ), + ), + ], + ), + ), + ); + }, + ), + ); + }).toList(), + value: currentCategories.isEmpty ? null : currentCategories.last, + onChanged: (_) {}, + selectedItemBuilder: (context) { + return currentCategories.map((item) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final category in currentCategories) + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: Theme.of(context).colorScheme.primary, + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + margin: const EdgeInsets.only(right: 4), + child: Text( + getCategoryDisplayTitle(category), + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + fontSize: 13, + ), + ), + ), + ], + ), + ); + }).toList(); }, + buttonStyleData: const ButtonStyleData( + padding: EdgeInsets.only(left: 16, right: 8), + height: 40, + ), + menuItemStyleData: const MenuItemStyleData( + height: 40, + padding: EdgeInsets.zero, + ), ), // Visibility setting diff --git a/lib/widgets/post/compose_settings_sheet.g.dart b/lib/widgets/post/compose_settings_sheet.g.dart new file mode 100644 index 0000000..4ae7e40 --- /dev/null +++ b/lib/widgets/post/compose_settings_sheet.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'compose_settings_sheet.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$postCategoriesHash() => r'503ce6f0fdd728a8cb991665006c3b4bffbb94d4'; + +/// See also [postCategories]. +@ProviderFor(postCategories) +final postCategoriesProvider = + AutoDisposeFutureProvider>.internal( + postCategories, + name: r'postCategoriesProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$postCategoriesHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef PostCategoriesRef = AutoDisposeFutureProviderRef>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/widgets/post/compose_shared.dart b/lib/widgets/post/compose_shared.dart index 38cc3c6..478edf4 100644 --- a/lib/widgets/post/compose_shared.dart +++ b/lib/widgets/post/compose_shared.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:island/models/file.dart'; import 'package:island/models/post.dart'; +import 'package:island/models/post_category.dart'; import 'package:island/models/publisher.dart'; import 'package:island/pods/config.dart'; import 'package:island/pods/network.dart'; @@ -30,8 +31,8 @@ class ComposeState { final ValueNotifier> attachmentProgress; final ValueNotifier currentPublisher; final ValueNotifier submitting; + final ValueNotifier> categories; StringTagController tagsController; - StringTagController categoriesController; final String draftId; int postType; // Linked poll id for this compose session (nullable) @@ -48,7 +49,7 @@ class ComposeState { required this.currentPublisher, required this.submitting, required this.tagsController, - required this.categoriesController, + required this.categories, required this.draftId, this.postType = 0, String? pollId, @@ -80,11 +81,7 @@ class ComposeLogic { }) { final id = draftId ?? DateTime.now().millisecondsSinceEpoch.toString(); final tagsController = StringTagController(); - final categoriesController = StringTagController(); originalPost?.tags.forEach((x) => tagsController.addTag(x.slug)); - originalPost?.categories.forEach( - (x) => categoriesController.addTag(x.slug), - ); return ComposeState( attachments: ValueNotifier>( originalPost?.attachments @@ -112,7 +109,9 @@ class ComposeLogic { attachmentProgress: ValueNotifier>({}), currentPublisher: ValueNotifier(originalPost?.publisher), tagsController: tagsController, - categoriesController: categoriesController, + categories: ValueNotifier>( + originalPost?.categories ?? [], + ), draftId: id, postType: postType, // initialize without poll by default @@ -141,7 +140,7 @@ class ComposeLogic { attachmentProgress: ValueNotifier>({}), currentPublisher: ValueNotifier(null), tagsController: tagsController, - categoriesController: categoriesController, + categories: ValueNotifier>([]), draftId: draft.id, postType: postType, pollId: null, @@ -640,7 +639,7 @@ class ComposeLogic { if (repliedPost != null) 'replied_post_id': repliedPost.id, if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id, 'tags': state.tagsController.getTags, - 'categories': state.categoriesController.getTags, + 'categories': state.categories.value.map((e) => e.slug).toList(), if (state.pollId.value != null) 'poll_id': state.pollId.value, }; @@ -733,7 +732,7 @@ class ComposeLogic { state.attachmentProgress.dispose(); state.currentPublisher.dispose(); state.tagsController.dispose(); - state.categoriesController.dispose(); + state.categories.dispose(); state.pollId.dispose(); } }