From e2d315afd42bc6ef7f3b2c4b6f84a41b118d843b Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 12 Oct 2025 19:49:16 +0800 Subject: [PATCH] :sparkles: Auto complete in post as well --- lib/services/autocomplete_service.dart | 12 ++ lib/widgets/chat/chat_input.dart | 37 ++++++ lib/widgets/post/compose_form_fields.dart | 146 +++++++++++++++++++--- 3 files changed, 178 insertions(+), 17 deletions(-) diff --git a/lib/services/autocomplete_service.dart b/lib/services/autocomplete_service.dart index fb4d808b..6d1013c1 100644 --- a/lib/services/autocomplete_service.dart +++ b/lib/services/autocomplete_service.dart @@ -25,4 +25,16 @@ class AutocompleteService { final data = response.data as List; return data.map((json) => AutocompleteSuggestion.fromJson(json)).toList(); } + + Future> getGeneralSuggestions( + String content, + ) async { + final response = await _client.post( + '/sphere/autocomplete', + data: {'content': content}, + ); + + final data = response.data as List; + return data.map((json) => AutocompleteSuggestion.fromJson(json)).toList(); + } } diff --git a/lib/widgets/chat/chat_input.dart b/lib/widgets/chat/chat_input.dart index 77e294a1..fa17a5da 100644 --- a/lib/widgets/chat/chat_input.dart +++ b/lib/widgets/chat/chat_input.dart @@ -11,6 +11,9 @@ import "package:island/models/account.dart"; import "package:island/models/autocomplete_response.dart"; import "package:island/models/chat.dart"; import "package:island/models/file.dart"; +import "package:island/models/publisher.dart"; +import "package:island/models/realm.dart"; +import "package:island/models/sticker.dart"; import "package:island/pods/config.dart"; import "package:island/services/autocomplete_service.dart"; import "package:island/services/responsive.dart"; @@ -448,12 +451,46 @@ class ChatInput extends HookConsumerWidget { ); break; case 'chatroom': + final chatRoom = SnChatRoom.fromJson( + suggestion.data, + ); + title = chatRoom.name ?? 'Chat Room'; + leading = ProfilePictureWidget( + file: chatRoom.picture, + radius: 18, + ); break; case 'realm': + final realm = SnRealm.fromJson(suggestion.data); + title = realm.name; + leading = ProfilePictureWidget( + file: realm.picture, + radius: 18, + ); break; case 'publisher': + final publisher = SnPublisher.fromJson( + suggestion.data, + ); + title = publisher.name; + leading = ProfilePictureWidget( + file: publisher.picture, + radius: 18, + ); break; case 'sticker': + final sticker = SnSticker.fromJson(suggestion.data); + title = sticker.slug; + leading = ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 28, + height: 28, + child: CloudImageWidget( + fileId: sticker.imageId, + ), + ), + ); break; default: } diff --git a/lib/widgets/post/compose_form_fields.dart b/lib/widgets/post/compose_form_fields.dart index fc02b5aa..41f78c1a 100644 --- a/lib/widgets/post/compose_form_fields.dart +++ b/lib/widgets/post/compose_form_fields.dart @@ -1,11 +1,20 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_typeahead/flutter_typeahead.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/account.dart'; +import 'package:island/models/autocomplete_response.dart'; +import 'package:island/models/chat.dart'; +import 'package:island/models/publisher.dart'; +import 'package:island/models/realm.dart'; +import 'package:island/models/sticker.dart'; +import 'package:island/services/autocomplete_service.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/post/compose_shared.dart'; /// A reusable widget for the form fields in compose screens. /// Includes title, description, and content text fields. -class ComposeFormFields extends StatelessWidget { +class ComposeFormFields extends HookConsumerWidget { final ComposeState state; final bool enabled; final bool showPublisherAvatar; @@ -20,7 +29,7 @@ class ComposeFormFields extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); return Row( @@ -111,22 +120,125 @@ class ComposeFormFields extends StatelessWidget { ), // Content field - TextField( + TypeAheadField( controller: state.contentController, - enabled: enabled && state.currentPublisher.value != null, - style: theme.textTheme.bodyMedium, - decoration: InputDecoration( - border: InputBorder.none, - hintText: 'postContent'.tr(), - isCollapsed: true, - contentPadding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 8, - ), - ), - maxLines: null, - onTapOutside: - (_) => FocusManager.instance.primaryFocus?.unfocus(), + builder: (context, controller, focusNode) { + return TextField( + focusNode: focusNode, + controller: controller, + enabled: enabled && state.currentPublisher.value != null, + style: theme.textTheme.bodyMedium, + decoration: InputDecoration( + border: InputBorder.none, + hintText: 'postContent'.tr(), + isCollapsed: true, + contentPadding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 8, + ), + ), + maxLines: null, + onTapOutside: + (_) => FocusManager.instance.primaryFocus?.unfocus(), + ); + }, + suggestionsCallback: (pattern) async { + // Only trigger on @ or : + final atIndex = pattern.lastIndexOf('@'); + final colonIndex = pattern.lastIndexOf(':'); + final triggerIndex = + atIndex > colonIndex ? atIndex : colonIndex; + if (triggerIndex == -1) return []; + final service = ref.read(autocompleteServiceProvider); + try { + return await service.getGeneralSuggestions(pattern); + } catch (e) { + return []; + } + }, + itemBuilder: (context, suggestion) { + String title = 'unknown'.tr(); + Widget leading = Icon(Icons.help); + switch (suggestion.type) { + case 'user': + final user = SnAccount.fromJson(suggestion.data); + title = user.nick; + leading = ProfilePictureWidget( + file: user.profile.picture, + radius: 18, + ); + break; + case 'chatroom': + final chatRoom = SnChatRoom.fromJson(suggestion.data); + title = chatRoom.name ?? 'Chat Room'; + leading = ProfilePictureWidget( + file: chatRoom.picture, + radius: 18, + ); + break; + case 'realm': + final realm = SnRealm.fromJson(suggestion.data); + title = realm.name; + leading = ProfilePictureWidget( + file: realm.picture, + radius: 18, + ); + break; + case 'publisher': + final publisher = SnPublisher.fromJson(suggestion.data); + title = publisher.name; + leading = ProfilePictureWidget( + file: publisher.picture, + radius: 18, + ); + break; + case 'sticker': + final sticker = SnSticker.fromJson(suggestion.data); + title = sticker.slug; + leading = ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 28, + height: 28, + child: CloudImageWidget(fileId: sticker.imageId), + ), + ); + break; + default: + } + return ListTile( + leading: leading, + title: Text(title), + subtitle: Text(suggestion.keyword), + dense: true, + ); + }, + onSelected: (suggestion) { + final text = state.contentController.text; + final atIndex = text.lastIndexOf('@'); + final colonIndex = text.lastIndexOf(':'); + final triggerIndex = + atIndex > colonIndex ? atIndex : colonIndex; + if (triggerIndex == -1) return; + final newText = text.replaceRange( + triggerIndex, + text.length, + suggestion.keyword, + ); + state.contentController.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed( + offset: triggerIndex + suggestion.keyword.length, + ), + ); + }, + direction: VerticalDirection.down, + hideOnEmpty: true, + hideOnLoading: true, + debounceDuration: const Duration(milliseconds: 500), + loadingBuilder: (context) => const Text('Loading...'), + errorBuilder: (context, error) => const Text('Error!'), + emptyBuilder: (context) => const Text('No items found!'), ), ], ),