Composer
ChatComposer is where the user writes to your agent. It has a text field that grows with the text, an attachment sheet behind a leading "+" button with the camera, recent photos, the photo library and your chat options, and a microphone that dictates into the field. Put it under the conversation. Give it an AIConversation, and it sends to the conversation, turns its send button into a stop button while the agent answers, and edits the user's latest message:
Column(
children: [
Expanded(child: AIConversationView.conversation(conversation: conversation)),
ChatComposer.conversation(conversation: conversation, enableSpeechToText: true),
],
);The composer doesn't pad itself for the keyboard or the system bars. In a Scaffold, the body stays above the keyboard by default, and a SafeArea around the screen keeps the composer clear of the navigation bar and the home indicator. To let the conversation scroll under it, float it over the list.
Permissions
The attachment sheet and speech input use the camera, the photo library, the microphone and the speech recognizer, so each platform needs the matching permissions. Give each iOS and macOS key a description of why your app needs it. Skip the entries for features you turn off.
iOS, in ios/Runner/Info.plist:
<key>NSCameraUsageDescription</key>
<string>Camera access is needed to attach a new photo to your message.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Photo library access is needed to attach images to your message.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Microphone access is needed for voice input.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Speech recognition is used to convert your voice to text.</string>macOS, in macos/Runner/Info.plist:
<key>NSPhotoLibraryUsageDescription</key>
<string>Photo library access is needed to attach images to your message.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Microphone access is needed for voice input.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Speech recognition is used to convert your voice to text.</string>The macOS app sandbox also needs entitlements in DebugProfile.entitlements and Release.entitlements:
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>
<key>com.apple.security.personal-information.photos-library</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>To enable camera capture on macOS, also add NSCameraUsageDescription and the com.apple.security.device.camera entitlement.
Android, in android/app/src/main/AndroidManifest.xml. The recent-photos strip needs READ_MEDIA_IMAGES on Android 13 and later, and READ_MEDIA_VISUAL_USER_SELECTED supports the "limited access" selection on Android 14 and later. The attachment sheet asks for photos only, so READ_MEDIA_VIDEO isn't needed:
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>Speech recognition on Android 11 and later also needs package visibility for the recognizer. Add this <queries> element, or move its <intent> into the <queries> element your manifest already has. Without it, the microphone button finds no speech recognizer:
<queries>
<intent>
<action android:name="android.speech.RecognitionService"/>
</intent>
</queries>The composer asks for photo access when the user opens the attachment sheet, and for the microphone and speech recognition the first time they tap the microphone. If they decline photo access, the sheet shows a button that opens your app's settings in place of the recent photos. If they decline the microphone, it shows a message with a button that opens your app's settings. Apps that use Stream Video already declare RECORD_AUDIO and NSMicrophoneUsageDescription.
Send without a conversation
Without an AIConversation, pass onSendPressed. It receives the text, the selected chat option, and the attachments the user picked, as XFiles, every time the user taps send:
ChatComposer(
controller: controller,
onSendPressed: (text, selectedOption, attachments) =>
myBackend.sendMessage(text, option: selectedOption, attachments: attachments),
onStopPressed: stop,
);The composer clears the field, the selected option and the attachments as soon as onSendPressed is invoked, without waiting for the returned Future. If your send can fail, you own surfacing that error and restoring the text, as shown in Control it from your code. A rejected Future is reported through FlutterError.onError. To send the photos to a Stream Chat channel as attachments, see Show a message while it's sent. With Stream Agents, ask the session instead of sending to the channel, as the Quickstart does.
Control it from your code
ChatComposerController holds the composer's state: the text, the attachments, the chat options and whether the agent is answering. By default the composer creates one itself. To fill in text from elsewhere, such as a suggestion or a restored draft, or to keep what was written when sending fails, create your own and pass it as controller:
final controller = ChatComposerController(initialText: draft);
ChatComposer(
controller: controller,
onSendPressed: (text, option, attachments) async {
try {
await myBackend.sendMessage(text, option: option, attachments: attachments);
} on Object {
// Put the failed message back, with its option and photos.
controller.textEditingController.text = text;
if (option != null) controller.selectChatOption(option);
controller.addAttachments(attachments);
}
},
onStopPressed: stop,
);The composer clears itself right after onSendPressed returns its Future, so putting the message back after an await, as above, keeps it.
With a conversation, give the controller to the conversation instead, as AIConversation(transport: transport, composer: controller), and the composer shows it.
Dispose the controller when you're done with it. Its members:
| Member | Description |
|---|---|
initialText |
Prefills the field when you create the controller. |
textEditingController |
The field's TextEditingController. Set its text to prefill the field later, or to put a message back after a failed send. |
text |
What's in the field. |
clear() |
Empties the field, the selected option and the attachments at once. |
attachments |
The picked images, as XFiles. |
addAttachments(files) |
Adds some, and returns those it accepted. |
removeAttachment(file) |
Drops one. |
maxAttachments |
How many the composer holds. 3 by default. |
isEditing |
While true, the composer shows "Editing message" above the text field. A conversation sets it when the user edits their latest message. |
cancelEditing() |
Stops editing and empties the field. The button next to "Editing message" calls it. |
requestFocus() |
Puts the cursor in the field of the composer that shows the controller. A composer that appears while isEditing is true takes focus by itself. |
focusRequests |
Notifies each time requestFocus() is called, for a composer of your own to follow. |
ChatComposer itself takes:
| Parameter | Description | Default |
|---|---|---|
focusNode |
Puts the cursor in the text field with requestFocus(), for example after filling it in, or dismisses the keyboard with unfocus(). |
- |
hintText |
The hint for the empty field. It wins over the translated hint. | The translated hint |
minLines |
The fewest lines the text field shows. | 1 |
maxLines |
The most lines the text field grows to before it scrolls. | 8 |
textInputAction |
The keyboard's action key. | TextInputAction.newline |
speechToTextConfig |
How dictation listens, described in Dictation. | SpeechToTextConfig() |
Chat options
The controller's chatOptions, such as a "summarize" or "write an email" mode, are listed in the attachment sheet. Tapping one selects it, shows it as a badge in the composer, and closes the sheet. The selected option reaches your transport as the request's chatOption, and onSendPressed as its selectedOption. The user removes it from the badge:
final controller = ChatComposerController(
chatOptions: [
ChatOption(id: 'summarize', text: 'Summarize this', icon: Icons.summarize),
ChatOption(id: 'email', text: 'Write an email', icon: Icons.email),
],
);
// Offer different options:
controller.chatOptions = [
ChatOption(id: 'translate', text: 'Translate', description: 'Into any language'),
];A ChatOption can also carry a description, shown under its text in the sheet. selectChatOption and clearSelectedChatOption set and remove the badge, selectedChatOption reads it, and the chatOptions setter swaps the options listed in the sheet.
Stop the reply
While the controller's isGenerating is true, the composer shows a stop button in place of the send button, and calls onStopPressed when the user taps it. It also disables its "+" button and refuses to send. With a conversation, the composer does this for you.
Without one, set isGenerating for the whole reply, and make sure it always resets, such as when the reply is done, when the agent reports an error and when the user stops it, since while it's stuck at true the user can't send.
With Stream Chat, drive it from the AI indicator events:
final subscription = channel
.on(EventType.aiIndicatorUpdate, EventType.aiIndicatorClear, EventType.aiIndicatorStop)
.listen((event) {
controller.isGenerating = switch (event.aiState) {
AITypingState.thinking || AITypingState.checkingSources || AITypingState.generating => true,
_ => false,
};
});Cancel the subscription with the screen. isGenerating resets on ai_indicator.clear and ai_indicator.stop, and when the agent reports AI_STATE_ERROR.
With Stream Agents, stop the reply with session.interrupt(). With a backend of your own and a Stream Chat channel, call channel.stopAIResponse(), which sends an ai_indicator.stop event to the channel:
ChatComposer(
controller: controller,
onSendPressed: (text, selectedOption, attachments) =>
channel.sendMessage(Message(text: text)),
onStopPressed: () {
controller.isGenerating = false;
channel.stopAIResponse();
},
);Set controller.isGenerating to false as soon as the user taps stop, as above, without waiting for the backend, so the stop button turns back into the send button right away.
What your backend sends covers how the backend stops the reply.
Dictation
Dictation is off by default. Set enableSpeechToText: true on ChatComposer and the send button shows a microphone while the field is empty, then swaps to send as soon as the user types. Dictation turns speech into text with the platform's speech recognizer, and streams partial and final results straight into the composer, after the text already there. To draw that microphone your own way, register composerInputTrailing, as described in Customize the composer.
ChatComposer(
controller: controller,
enableSpeechToText: true,
onSendPressed: send,
);You can also place SpeechToTextButton yourself, for example in the composer's trailing slot through the composerTrailing builder, which is empty by default. Placed that way it is always visible. It dictates into the field of the ChatComposerController you give it. Pass options through a SpeechToTextConfig, which ChatComposer also takes as speechToTextConfig:
SpeechToTextButton(
controller: controller,
config: SpeechToTextConfig(
localeId: 'en-US', // optional, defaults to the device locale
listenFor: const Duration(seconds: 30),
pauseFor: const Duration(seconds: 3),
onError: (error) => debugPrint(error.errorMsg),
onPermissionDenied: showMicrophoneRationale,
),
);Here showMicrophoneRationale is a function of your app.
To use the button in an input of your own, give it a ChatComposerController, and pass the controller's textEditingController to your field:
// In your State: created once, and disposed with it.
final input = ChatComposerController();
Row(
children: [
Expanded(child: TextField(controller: input.textEditingController)),
SpeechToTextButton(controller: input),
],
);When tapped, it asks for microphone and speech recognition access the first time, and records until the user taps it again. listenFor caps a session at 30 seconds by default. pauseFor ends it after that long without a recognition result: it defaults to 3 seconds on iOS and macOS, and to no pause anywhere else, so on Android the recognizer ends the utterance on its own and listenFor is the backstop. onStatus is called when the recognizer's status changes, with strings such as 'listening', 'notListening' and 'done'. If the platform has no speech recognizer, the button is disabled, and it checks again when the app returns to the foreground. SpeechToTextController.instance.isListening tells whether it's recording, and the controller notifies its listeners when that changes.
To let the user talk to your agent instead of dictating, see the Voice agent use case.
Customize the composer
The composer has five slots you can replace, with the builders of streamAIComponentBuilders. Each slot has a default, so you register only the ones you want to change. Each builder receives a props object that carries the composer's wiring (controller, focusNode, onSend, onStop and openAttachmentSheet).
| Builder | Shows | Props | Default |
|---|---|---|---|
composerLeading |
Left of the text field | ChatComposerLeadingProps |
ComposerAttachmentButton, the "+" that opens the sheet |
composerInput |
The text field, with the microphone and send or stop button | ChatComposerInputProps |
ChatComposerInput |
composerInputTrailing |
Inside the text field, while the agent isn't answering and the field is empty or dictating | ChatComposerInputTrailingProps |
SpeechToTextButton with enableSpeechToText, otherwise the send button |
composerTrailing |
Right of the text field | ChatComposerTrailingProps |
Nothing |
composerAttachmentSheet |
The attachment sheet | ChatComposerAttachmentSheetProps |
ComposerAttachmentSheet |
For example, this replaces the "+" button with a paperclip that opens the same attachment sheet, and the microphone with a dictation button of your own:
StreamComponentFactory(
builders: StreamComponentBuilders(
extensions: streamAIComponentBuilders(
composerLeading: (context, props) => IconButton(
icon: const Icon(Icons.attach_file),
tooltip: 'Attach photos',
onPressed: props.controller.isGenerating ? null : props.openAttachmentSheet,
),
composerInputTrailing: (context, props) => IconButton(
icon: Icon(props.speech.isListening ? Icons.stop : Icons.mic_none),
tooltip: 'Dictate',
onPressed: () => props.speech.isListening
? props.speech.stop()
: props.speech.start(
onWords: (words) => props.controller.textEditingController.text = words,
config: props.speechToTextConfig,
),
),
),
),
child: ChatComposer(controller: controller, enableSpeechToText: true, onSendPressed: send),
);Register composerTrailing to add a mode toggle, a slash-command trigger, or another control next to the field. The props carry what each slot needs, such as openAttachmentSheet, which opens the attachment sheet, and the speech controller of the field's dictation. A few things to keep in mind:
- Return
ChatComposer.hiddenfromcomposerLeadingorcomposerTrailingto leave that slot out. To keep a slot's default, don't register a builder for it. - The composer places the input in an
Expanded, so don't return anExpandedfromcomposerInputyourself. props.onSendclears the controller and returns focus toprops.focusNode, so call it rather than reimplementing the send path.props.onStopisnullwhen you passed noonStopPressed.props.canSendcombines "there is something to send" and "a response isn't already streaming". Use it to enable or disable a custom send button.- In
composerInput, to change one value and keep the rest, useprops.copyWith(...). To keep the default input and add to it, such as some space under it, return aChatComposerInput(props: props)from it. - A replacement attachment sheet gets a
props.scrollController. Attach it to the sheet's scrollable, so a drag scrolls the content before it moves the sheet.
Appearance
The composer takes its colors and text styles from the StreamTheme, and ComposerThemeData overrides them, on AITheme.composerTheme or in a ComposerTheme scope, as described in Theming. It covers the composer and its attachment sheet: fillColor, borderColor, hintStyle, iconColor, disabledIconColor, sendButtonColor, stopButtonColor, recordingButtonColor, actionButtonForegroundColor, selectedOptionColor, selectedOptionForegroundColor, photoSelectionColor, attachmentPlaceholderColor and attachmentPlaceholderForegroundColor.
Its texts, such as the "Ask anything..." placeholder and the tooltips of its buttons, are members of AITranslations, as described in Localization: composerHint, send, stopGenerating, removeAttachment, clearOption, addPhotos, photos, allPhotos, allowPhotoAccess, takePhoto, voiceInput, stopRecording, microphonePermissionDenied, openSettings, photosNotAddedTitle, photosNotAddedMessage, editingMessage and cancelEditing.