# 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](https://getstream.io/chat/docs/sdk/flutter/ai-components/conversation-view/). Give it an [`AIConversation`](https://getstream.io/chat/docs/sdk/flutter/ai-components/conversation-state/), 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:

```dart
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](https://getstream.io/chat/docs/sdk/flutter/ai-components/conversation-view/#float-the-composer-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`:

```xml
<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`:

```xml
<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`:

```xml
<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:

```xml
<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:

```xml
<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](#chat-options), and the `attachments` the user picked, as `XFile`s, every time the user taps send:

```dart
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](#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](https://getstream.io/chat/docs/sdk/flutter/ai-components/conversation-view/#show-a-message-while-its-sent). With [Stream Agents](https://getstream.io/agents/docs/quickstart/?client=flutter), ask the session instead of sending to the channel, as the [Quickstart](https://getstream.io/chat/docs/sdk/flutter/ai-components/) 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](https://getstream.io/chat/docs/sdk/flutter/ai-components/conversation-view/#suggestions) or a restored draft, or to keep what was written when sending fails, create your own and pass it as `controller`:

```dart
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 `XFile`s.                                                                                                                    |
| `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](#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:

```dart
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:

```dart
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:

```dart
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](https://getstream.io/chat/docs/sdk/flutter/ai-components/conversation-state/#stop-the-reply) 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](#customize-the-composer).

```dart
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`:

```dart
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:

```dart
// 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](https://getstream.io/chat/docs/sdk/flutter/ai-components/use-cases/voice-agent/) use case.

## Customize the composer

The composer has five slots you can replace, with the builders of [`streamAIComponentBuilders`](https://getstream.io/chat/docs/sdk/flutter/ai-components/#replacing-components). 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:

```dart
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.hidden` from `composerLeading` or `composerTrailing` to 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 an `Expanded` from `composerInput` yourself.
- `props.onSend` clears the controller and returns focus to `props.focusNode`, so call it rather than reimplementing the send path. `props.onStop` is `null` when you passed no `onStopPressed`.
- `props.canSend` combines "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, use `props.copyWith(...)`. To keep the default input and add to it, such as some space under it, return a `ChatComposerInput(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](https://getstream.io/chat/docs/sdk/flutter/ai-components/#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](https://getstream.io/chat/docs/sdk/flutter/ai-components/#localization): `composerHint`, `send`, `stopGenerating`, `removeAttachment`, `clearOption`, `addPhotos`, `photos`, `allPhotos`, `allowPhotoAccess`, `takePhoto`, `voiceInput`, `stopRecording`, `microphonePermissionDenied`, `openSettings`, `photosNotAddedTitle`, `photosNotAddedMessage`, `editingMessage` and `cancelEditing`.

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/flutter/ai-components/composer/](https://getstream.io/chat/docs/sdk/flutter/ai-components/composer/).