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",
"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"
}

View File

@@ -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),
);
}

View File

@@ -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),
);
}

View File

@@ -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<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
class ChipTagInputField extends StatelessWidget {
final InputFieldValues inputFieldValues;
@@ -98,27 +117,20 @@ class ChipTagInputField extends StatelessWidget {
}
}
class ComposeSettingsSheet extends HookWidget {
final ValueNotifier<int> 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<PostCategory>(
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 ?? <PostCategory>[]).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

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: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<Map<int, double>> attachmentProgress;
final ValueNotifier<SnPublisher?> currentPublisher;
final ValueNotifier<bool> submitting;
final ValueNotifier<List<PostCategory>> 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<List<UniversalFile>>(
originalPost?.attachments
@@ -112,7 +109,9 @@ class ComposeLogic {
attachmentProgress: ValueNotifier<Map<int, double>>({}),
currentPublisher: ValueNotifier<SnPublisher?>(originalPost?.publisher),
tagsController: tagsController,
categoriesController: categoriesController,
categories: ValueNotifier<List<PostCategory>>(
originalPost?.categories ?? [],
),
draftId: id,
postType: postType,
// initialize without poll by default
@@ -141,7 +140,7 @@ class ComposeLogic {
attachmentProgress: ValueNotifier<Map<int, double>>({}),
currentPublisher: ValueNotifier<SnPublisher?>(null),
tagsController: tagsController,
categoriesController: categoriesController,
categories: ValueNotifier<List<PostCategory>>([]),
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();
}
}