From f28a73ff9c0899e2e0231a0e992563d6e418cbc8 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Mon, 13 Oct 2025 00:53:45 +0800 Subject: [PATCH] :recycle: Refactor post tags --- assets/i18n/en-US.json | 3 +- lib/widgets/post/compose_settings_sheet.dart | 152 ++++++++++++++++--- lib/widgets/post/compose_shared.dart | 26 ++-- lib/widgets/post/compose_state_utils.dart | 2 +- lib/widgets/post/compose_submit_utils.dart | 2 +- 5 files changed, 148 insertions(+), 37 deletions(-) diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 055b2469..514818ae 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -1251,5 +1251,6 @@ "preview": "Preview", "availableWithYourPlan": "Available with your plan", "upgradeRequired": "Upgrade required", - "settingsDisableAnimation": "Disable Animation" + "settingsDisableAnimation": "Disable Animation", + "addTag": "Add Tag" } diff --git a/lib/widgets/post/compose_settings_sheet.dart b/lib/widgets/post/compose_settings_sheet.dart index 63e49a89..6d66329d 100644 --- a/lib/widgets/post/compose_settings_sheet.dart +++ b/lib/widgets/post/compose_settings_sheet.dart @@ -2,9 +2,11 @@ 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:flutter_typeahead/flutter_typeahead.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/post_category.dart'; +import 'package:island/models/post_tag.dart'; import 'package:island/models/realm.dart'; import 'package:island/pods/network.dart'; import 'package:island/screens/realm/realms.dart'; @@ -132,6 +134,26 @@ class ComposeSettingsSheet extends HookConsumerWidget { const ComposeSettingsSheet({super.key, required this.state}); + Future> _fetchTagSuggestions( + String query, + WidgetRef ref, + ) async { + if (query.isEmpty) return []; + + try { + final client = ref.read(apiClientProvider); + final response = await client.get( + '/sphere/posts/tags', + queryParameters: {'query': query}, + ); + return response.data + .map((json) => SnPostTag.fromJson(json)) + .toList(); + } catch (e) { + return []; + } + } + @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); @@ -140,6 +162,7 @@ class ComposeSettingsSheet extends HookConsumerWidget { // Listen to visibility changes to trigger rebuilds final currentVisibility = useValueListenable(state.visibility); final currentCategories = useValueListenable(state.categories); + final currentTags = useValueListenable(state.tags); final currentRealm = useValueListenable(state.realm); final postCategories = ref.watch(postCategoriesProvider); final userRealms = ref.watch(realmsJoinedProvider); @@ -255,23 +278,118 @@ class ComposeSettingsSheet extends HookConsumerWidget { ), // Tags field - TextFieldTags( - textfieldTagsController: state.tagsController, - textSeparators: const [' ', ','], - letterCase: LetterCase.normal, - validator: (String tag) { - if (tag.isEmpty) { - return 'No, cannot be empty'; - } - return null; - }, - inputFieldBuilder: (context, inputFieldValues) { - return ChipTagInputField( - inputFieldValues: inputFieldValues, - labelText: 'tags', - hintText: 'tagsHint', - ); - }, + Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.outline, + width: 1, + ), + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, + children: [ + Text( + 'tags'.tr(), + style: Theme.of(context).textTheme.labelLarge, + ), + // Existing tags display + if (currentTags.isNotEmpty) + Wrap( + spacing: 8, + runSpacing: 8, + children: + currentTags.map((tag) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '#$tag', + style: TextStyle( + color: + Theme.of( + context, + ).colorScheme.onPrimary, + fontSize: 14, + ), + ), + const Gap(4), + InkWell( + onTap: () { + final newTags = List.from( + state.tags.value, + )..remove(tag); + state.tags.value = newTags; + }, + child: Icon( + Icons.close, + size: 16, + color: + Theme.of( + context, + ).colorScheme.onPrimary, + ), + ), + ], + ), + ); + }).toList(), + ), + // Tag input with autocomplete + TypeAheadField( + builder: (context, controller, focusNode) { + return TextField( + controller: controller, + focusNode: focusNode, + decoration: InputDecoration( + hintText: 'addTag'.tr(), + border: InputBorder.none, + isCollapsed: true, + contentPadding: EdgeInsets.zero, + ), + onSubmitted: (value) { + state.tags.value = [...state.tags.value, value]; + controller.clear(); + }, + ); + }, + suggestionsCallback: + (pattern) => _fetchTagSuggestions(pattern, ref), + itemBuilder: (context, suggestion) { + return ListTile( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + title: Text('#${suggestion.slug}'), + dense: true, + ); + }, + onSelected: (suggestion) { + if (!state.tags.value.contains(suggestion.slug)) { + state.tags.value = [ + ...state.tags.value, + suggestion.slug, + ]; + } + }, + direction: VerticalDirection.down, + hideOnEmpty: true, + hideOnLoading: true, + debounceDuration: const Duration(milliseconds: 300), + ), + ], + ), ), // Categories field diff --git a/lib/widgets/post/compose_shared.dart b/lib/widgets/post/compose_shared.dart index e335ff23..6ec2cf30 100644 --- a/lib/widgets/post/compose_shared.dart +++ b/lib/widgets/post/compose_shared.dart @@ -23,7 +23,6 @@ import 'package:island/widgets/post/compose_poll.dart'; import 'package:island/widgets/post/compose_recorder.dart'; import 'package:island/pods/file_pool.dart'; import 'package:pasteboard/pasteboard.dart'; -import 'package:textfield_tags/textfield_tags.dart'; import 'package:island/talker.dart'; class ComposeState { @@ -37,7 +36,7 @@ class ComposeState { final ValueNotifier currentPublisher; final ValueNotifier submitting; final ValueNotifier> categories; - StringTagController tagsController; + final ValueNotifier> tags; final ValueNotifier realm; final ValueNotifier embedView; final String draftId; @@ -56,7 +55,7 @@ class ComposeState { required this.attachmentProgress, required this.currentPublisher, required this.submitting, - required this.tagsController, + required this.tags, required this.categories, required this.realm, required this.embedView, @@ -90,14 +89,10 @@ class ComposeLogic { int postType = 0, }) { final id = draftId ?? DateTime.now().millisecondsSinceEpoch.toString(); - final tagsController = StringTagController(); // Initialize tags from original post - if (originalPost != null) { - for (var tag in originalPost.tags) { - tagsController.addTag(tag.slug); - } - } + final tags = + originalPost?.tags.map((tag) => tag.slug).toList() ?? []; // Initialize categories from original post final categories = originalPost?.categories ?? []; @@ -129,7 +124,7 @@ class ComposeLogic { submitting: ValueNotifier(false), attachmentProgress: ValueNotifier>({}), currentPublisher: ValueNotifier(originalPost?.publisher), - tagsController: tagsController, + tags: ValueNotifier>(tags), categories: ValueNotifier>(categories), realm: ValueNotifier(originalPost?.realm), embedView: ValueNotifier(originalPost?.embedView), @@ -141,10 +136,7 @@ class ComposeLogic { } static ComposeState createStateFromDraft(SnPost draft, {int postType = 0}) { - final tagsController = StringTagController(); - for (var x in draft.tags) { - tagsController.addTag(x.slug); - } + final tags = draft.tags.map((tag) => tag.slug).toList(); return ComposeState( attachments: ValueNotifier>( @@ -158,7 +150,7 @@ class ComposeLogic { submitting: ValueNotifier(false), attachmentProgress: ValueNotifier>({}), currentPublisher: ValueNotifier(null), - tagsController: tagsController, + tags: ValueNotifier>(tags), categories: ValueNotifier>(draft.categories), realm: ValueNotifier(draft.realm), embedView: ValueNotifier(draft.embedView), @@ -685,7 +677,7 @@ class ComposeLogic { 'type': state.postType, if (repliedPost != null) 'replied_post_id': repliedPost.id, if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id, - 'tags': state.tagsController.getTags, + 'tags': state.tags.value, 'categories': state.categories.value.map((e) => e.slug).toList(), if (state.realm.value != null) 'realm_id': state.realm.value?.id, if (state.pollId.value != null) 'poll_id': state.pollId.value, @@ -781,7 +773,7 @@ class ComposeLogic { state.submitting.dispose(); state.attachmentProgress.dispose(); state.currentPublisher.dispose(); - state.tagsController.dispose(); + state.tags.dispose(); state.categories.dispose(); state.realm.dispose(); state.embedView.dispose(); diff --git a/lib/widgets/post/compose_state_utils.dart b/lib/widgets/post/compose_state_utils.dart index 29712be3..29466f8e 100644 --- a/lib/widgets/post/compose_state_utils.dart +++ b/lib/widgets/post/compose_state_utils.dart @@ -173,7 +173,7 @@ class ComposeStateUtils { state.attachmentProgress.value = {}; // Clear tags - state.tagsController.clearTags(); + state.tags.value = []; // Clear categories state.categories.value = []; diff --git a/lib/widgets/post/compose_submit_utils.dart b/lib/widgets/post/compose_submit_utils.dart index eb9794b7..7e8ec4ed 100644 --- a/lib/widgets/post/compose_submit_utils.dart +++ b/lib/widgets/post/compose_submit_utils.dart @@ -75,7 +75,7 @@ class ComposeSubmitUtils { 'type': state.postType, if (repliedPost != null) 'replied_post_id': repliedPost.id, if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id, - 'tags': state.tagsController.getTags, + 'tags': state.tags.value, 'categories': state.categories.value.map((e) => e.slug).toList(), if (state.realm.value != null) 'realm_id': state.realm.value?.id, if (state.pollId.value != null) 'poll_id': state.pollId.value,