Auto complete in post as well

This commit is contained in:
2025-10-12 19:49:16 +08:00
parent 6124dbfd79
commit e2d315afd4
3 changed files with 178 additions and 17 deletions

View File

@@ -25,4 +25,16 @@ class AutocompleteService {
final data = response.data as List<dynamic>; final data = response.data as List<dynamic>;
return data.map((json) => AutocompleteSuggestion.fromJson(json)).toList(); return data.map((json) => AutocompleteSuggestion.fromJson(json)).toList();
} }
Future<List<AutocompleteSuggestion>> getGeneralSuggestions(
String content,
) async {
final response = await _client.post(
'/sphere/autocomplete',
data: {'content': content},
);
final data = response.data as List<dynamic>;
return data.map((json) => AutocompleteSuggestion.fromJson(json)).toList();
}
} }

View File

@@ -11,6 +11,9 @@ import "package:island/models/account.dart";
import "package:island/models/autocomplete_response.dart"; import "package:island/models/autocomplete_response.dart";
import "package:island/models/chat.dart"; import "package:island/models/chat.dart";
import "package:island/models/file.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/pods/config.dart";
import "package:island/services/autocomplete_service.dart"; import "package:island/services/autocomplete_service.dart";
import "package:island/services/responsive.dart"; import "package:island/services/responsive.dart";
@@ -448,12 +451,46 @@ class ChatInput extends HookConsumerWidget {
); );
break; break;
case 'chatroom': case 'chatroom':
final chatRoom = SnChatRoom.fromJson(
suggestion.data,
);
title = chatRoom.name ?? 'Chat Room';
leading = ProfilePictureWidget(
file: chatRoom.picture,
radius: 18,
);
break; break;
case 'realm': case 'realm':
final realm = SnRealm.fromJson(suggestion.data);
title = realm.name;
leading = ProfilePictureWidget(
file: realm.picture,
radius: 18,
);
break; break;
case 'publisher': case 'publisher':
final publisher = SnPublisher.fromJson(
suggestion.data,
);
title = publisher.name;
leading = ProfilePictureWidget(
file: publisher.picture,
radius: 18,
);
break; break;
case 'sticker': 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; break;
default: default:
} }

View File

@@ -1,11 +1,20 @@
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_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/content/cloud_files.dart';
import 'package:island/widgets/post/compose_shared.dart'; import 'package:island/widgets/post/compose_shared.dart';
/// A reusable widget for the form fields in compose screens. /// A reusable widget for the form fields in compose screens.
/// Includes title, description, and content text fields. /// Includes title, description, and content text fields.
class ComposeFormFields extends StatelessWidget { class ComposeFormFields extends HookConsumerWidget {
final ComposeState state; final ComposeState state;
final bool enabled; final bool enabled;
final bool showPublisherAvatar; final bool showPublisherAvatar;
@@ -20,7 +29,7 @@ class ComposeFormFields extends StatelessWidget {
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Row( return Row(
@@ -111,8 +120,12 @@ class ComposeFormFields extends StatelessWidget {
), ),
// Content field // Content field
TextField( TypeAheadField<AutocompleteSuggestion>(
controller: state.contentController, controller: state.contentController,
builder: (context, controller, focusNode) {
return TextField(
focusNode: focusNode,
controller: controller,
enabled: enabled && state.currentPublisher.value != null, enabled: enabled && state.currentPublisher.value != null,
style: theme.textTheme.bodyMedium, style: theme.textTheme.bodyMedium,
decoration: InputDecoration( decoration: InputDecoration(
@@ -127,6 +140,105 @@ class ComposeFormFields extends StatelessWidget {
maxLines: null, maxLines: null,
onTapOutside: onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(), (_) => 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!'),
), ),
], ],
), ),