Post categories selection

This commit is contained in:
2025-08-08 17:57:47 +08:00
parent 43c90da4e3
commit e2e103fa67
6 changed files with 189 additions and 62 deletions

View File

@@ -769,5 +769,18 @@
"addPack": "Add Pack", "addPack": "Add Pack",
"removePack": "Remove Pack", "removePack": "Remove Pack",
"browseAndAddStickers": "Browse and add sticker packs", "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"
} }

View File

@@ -205,15 +205,7 @@ class PostComposeScreen extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: builder: (context) => ComposeSettingsSheet(state: state),
(context) => ComposeSettingsSheet(
visibility: state.visibility,
tagsController: state.tagsController,
categoriesController: state.categoriesController,
onVisibilityChanged: () {
// Trigger rebuild if needed
},
),
); );
} }

View File

@@ -138,15 +138,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: builder: (context) => ComposeSettingsSheet(state: state),
(context) => ComposeSettingsSheet(
visibility: state.visibility,
tagsController: state.tagsController,
categoriesController: state.categoriesController,
onVisibilityChanged: () {
// Trigger rebuild if needed
},
),
); );
} }

View File

@@ -1,11 +1,30 @@
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.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/content/sheet.dart';
import 'package:island/widgets/post/compose_shared.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:textfield_tags/textfield_tags.dart'; import 'package:textfield_tags/textfield_tags.dart';
part 'compose_settings_sheet.g.dart';
@riverpod
Future<List<PostCategory>> 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<PostCategory>()
.toList();
}
/// A reusable widget for tag input fields with chip display /// A reusable widget for tag input fields with chip display
class ChipTagInputField extends StatelessWidget { class ChipTagInputField extends StatelessWidget {
final InputFieldValues inputFieldValues; final InputFieldValues inputFieldValues;
@@ -98,27 +117,20 @@ class ChipTagInputField extends StatelessWidget {
} }
} }
class ComposeSettingsSheet extends HookWidget { class ComposeSettingsSheet extends HookConsumerWidget {
final ValueNotifier<int> visibility; final ComposeState state;
final VoidCallback? onVisibilityChanged;
final StringTagController tagsController;
final StringTagController categoriesController;
const ComposeSettingsSheet({ const ComposeSettingsSheet({super.key, required this.state});
super.key,
required this.visibility,
this.onVisibilityChanged,
required this.tagsController,
required this.categoriesController,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
// Listen to visibility changes to trigger rebuilds // 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) { IconData getVisibilityIcon(int visibilityValue) {
switch (visibilityValue) { switch (visibilityValue) {
@@ -156,11 +168,10 @@ class ComposeSettingsSheet extends HookWidget {
leading: Icon(icon), leading: Icon(icon),
title: Text(textKey.tr()), title: Text(textKey.tr()),
onTap: () { onTap: () {
visibility.value = value; state.visibility.value = value;
onVisibilityChanged?.call();
Navigator.pop(context); Navigator.pop(context);
}, },
selected: visibility.value == value, selected: state.visibility.value == value,
contentPadding: const EdgeInsets.symmetric(horizontal: 20), 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( return SheetScaffold(
titleText: 'postSettings'.tr(), titleText: 'postSettings'.tr(),
child: SingleChildScrollView( child: SingleChildScrollView(
@@ -214,7 +233,7 @@ class ComposeSettingsSheet extends HookWidget {
children: [ children: [
// Tags field // Tags field
TextFieldTags( TextFieldTags(
textfieldTagsController: tagsController, textfieldTagsController: state.tagsController,
textSeparators: const [' ', ','], textSeparators: const [' ', ','],
letterCase: LetterCase.normal, letterCase: LetterCase.normal,
validator: (String tag) { validator: (String tag) {
@@ -233,22 +252,105 @@ class ComposeSettingsSheet extends HookWidget {
), ),
// Categories field // Categories field
TextFieldTags( // 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.
textfieldTagsController: categoriesController, DropdownButtonFormField2<PostCategory>(
textSeparators: const [' ', ','], isExpanded: true,
letterCase: LetterCase.small, decoration: InputDecoration(
validator: (String tag) { contentPadding: const EdgeInsets.symmetric(vertical: 9),
if (tag.isEmpty) return 'No, cannot be empty'; border: OutlineInputBorder(
if (tag.contains(' ')) return 'Tags should be URL-safe'; borderRadius: BorderRadius.circular(12),
return null; ),
}, ),
inputFieldBuilder: (context, inputFieldValues) { hint: Text('categories'.tr(), style: TextStyle(fontSize: 15)),
return ChipTagInputField( items:
inputFieldValues: inputFieldValues, (postCategories.value ?? <PostCategory>[]).map((item) {
labelText: 'categories', return DropdownMenuItem(
hintText: 'categoriesHint', 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 // Visibility setting

View File

@@ -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<List<PostCategory>>.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<List<PostCategory>>;
// 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

View File

@@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/models/post.dart'; import 'package:island/models/post.dart';
import 'package:island/models/post_category.dart';
import 'package:island/models/publisher.dart'; import 'package:island/models/publisher.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
@@ -30,8 +31,8 @@ class ComposeState {
final ValueNotifier<Map<int, double>> attachmentProgress; final ValueNotifier<Map<int, double>> attachmentProgress;
final ValueNotifier<SnPublisher?> currentPublisher; final ValueNotifier<SnPublisher?> currentPublisher;
final ValueNotifier<bool> submitting; final ValueNotifier<bool> submitting;
final ValueNotifier<List<PostCategory>> categories;
StringTagController tagsController; StringTagController tagsController;
StringTagController categoriesController;
final String draftId; final String draftId;
int postType; int postType;
// Linked poll id for this compose session (nullable) // Linked poll id for this compose session (nullable)
@@ -48,7 +49,7 @@ class ComposeState {
required this.currentPublisher, required this.currentPublisher,
required this.submitting, required this.submitting,
required this.tagsController, required this.tagsController,
required this.categoriesController, required this.categories,
required this.draftId, required this.draftId,
this.postType = 0, this.postType = 0,
String? pollId, String? pollId,
@@ -80,11 +81,7 @@ class ComposeLogic {
}) { }) {
final id = draftId ?? DateTime.now().millisecondsSinceEpoch.toString(); final id = draftId ?? DateTime.now().millisecondsSinceEpoch.toString();
final tagsController = StringTagController(); final tagsController = StringTagController();
final categoriesController = StringTagController();
originalPost?.tags.forEach((x) => tagsController.addTag(x.slug)); originalPost?.tags.forEach((x) => tagsController.addTag(x.slug));
originalPost?.categories.forEach(
(x) => categoriesController.addTag(x.slug),
);
return ComposeState( return ComposeState(
attachments: ValueNotifier<List<UniversalFile>>( attachments: ValueNotifier<List<UniversalFile>>(
originalPost?.attachments originalPost?.attachments
@@ -112,7 +109,9 @@ class ComposeLogic {
attachmentProgress: ValueNotifier<Map<int, double>>({}), attachmentProgress: ValueNotifier<Map<int, double>>({}),
currentPublisher: ValueNotifier<SnPublisher?>(originalPost?.publisher), currentPublisher: ValueNotifier<SnPublisher?>(originalPost?.publisher),
tagsController: tagsController, tagsController: tagsController,
categoriesController: categoriesController, categories: ValueNotifier<List<PostCategory>>(
originalPost?.categories ?? [],
),
draftId: id, draftId: id,
postType: postType, postType: postType,
// initialize without poll by default // initialize without poll by default
@@ -141,7 +140,7 @@ class ComposeLogic {
attachmentProgress: ValueNotifier<Map<int, double>>({}), attachmentProgress: ValueNotifier<Map<int, double>>({}),
currentPublisher: ValueNotifier<SnPublisher?>(null), currentPublisher: ValueNotifier<SnPublisher?>(null),
tagsController: tagsController, tagsController: tagsController,
categoriesController: categoriesController, categories: ValueNotifier<List<PostCategory>>([]),
draftId: draft.id, draftId: draft.id,
postType: postType, postType: postType,
pollId: null, pollId: null,
@@ -640,7 +639,7 @@ class ComposeLogic {
if (repliedPost != null) 'replied_post_id': repliedPost.id, if (repliedPost != null) 'replied_post_id': repliedPost.id,
if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id, if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,
'tags': state.tagsController.getTags, '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, if (state.pollId.value != null) 'poll_id': state.pollId.value,
}; };
@@ -733,7 +732,7 @@ class ComposeLogic {
state.attachmentProgress.dispose(); state.attachmentProgress.dispose();
state.currentPublisher.dispose(); state.currentPublisher.dispose();
state.tagsController.dispose(); state.tagsController.dispose();
state.categoriesController.dispose(); state.categories.dispose();
state.pollId.dispose(); state.pollId.dispose();
} }
} }