Attachments

Overview

The Stream Chat Flutter SDK has built-in support for several attachment types that users can send in messages. On mobile, the attachment picker appears as an inline panel below the text input when the user taps the attachment button in StreamMessageComposer. On web and desktop, a system list picker is shown instead.

Built-in attachment types are represented by AttachmentPickerType:

TypeDescription
AttachmentPickerType.imagesPhotos from the device gallery
AttachmentPickerType.videosVideos from the device gallery
AttachmentPickerType.audiosAudio files from the device
AttachmentPickerType.filesAny file from the device file browser
AttachmentPickerType.pollCreate a poll

Restricting allowed attachment types

Use allowedAttachmentPickerTypes on StreamMessageComposer to limit which attachment types users can select. Only the specified types appear in the attachment picker.

StreamMessageComposer(
  allowedAttachmentPickerTypes: [
    AttachmentPickerType.images,
    AttachmentPickerType.videos,
  ],
)

To disable the attachment picker entirely, pass an empty list:

StreamMessageComposer(
  allowedAttachmentPickerTypes: [],
)

Attachment size and count limits

File size limits, allowed/blocked extensions, and allowed/blocked MIME types are configured in the Stream Dashboard and enforced automatically by StreamMessageComposer via StreamChatClient.appSettings. The SDK fetches AppSettings on connect and caches them. To refresh manually, call client.getAppSettings().

The per-message attachment count limit is controlled by the attachmentLimit parameter (defaults to 30, the backend cap):

StreamMessageComposer(
  attachmentLimit: 10,
)

Customizing picker options

Use attachmentPickerOptionsBuilder to modify, filter, reorder, or extend the default options shown in the picker.

Filter out specific tabs:

StreamMessageComposer(
  attachmentPickerOptionsBuilder: (context, defaultOptions) {
    return defaultOptions.where((o) => o.key != 'poll-creator').toList();
  },
)

Reorder tabs:

StreamMessageComposer(
  attachmentPickerOptionsBuilder: (context, defaultOptions) {
    return defaultOptions.reversed.toList();
  },
)

Add a custom tab:

StreamMessageComposer(
  attachmentPickerOptionsBuilder: (context, defaultOptions) {
    return [
      ...defaultOptions,
      TabbedAttachmentPickerOption(
        key: 'audio-picker',
        icon: Icons.audiotrack,
        supportedTypes: [AttachmentPickerType.audios],
        optionViewBuilder: (context, controller) {
          return AudioPicker(
            onAudioPicked: (audio) async {
              await controller.addAttachment(audio);
            },
          );
        },
      ),
    ];
  },
)

Option types

TypeDescription
TabbedAttachmentPickerOptionCustom inline UI shown as a tab (gallery, custom pickers)
SystemAttachmentPickerOptionLaunches a native system dialog (file browser, camera)

Both types take icon: IconData (not a Widget).

Using the system picker on mobile

Set useSystemAttachmentPicker: true to use the system list picker on mobile instead of the custom tabbed picker. This is useful for compliance with Google Play's photo and video permissions policy. See System Attachments Picker for details.

StreamMessageComposer(
  useSystemAttachmentPicker: true,
)

Thumbnail appearance in the gallery tab is configured via GalleryPickerConfig, passed when building a custom picker via tabbedAttachmentPickerBuilder or systemAttachmentPickerBuilder.

FieldDefaultDescription
mediaThumbnailSizenullnull; when null, each tile auto-calculates its size from its layout constraints and the device pixel ratio
mediaThumbnailFormatThumbnailFormat.jpegThumbnailFormat.jpeg or ThumbnailFormat.png
mediaThumbnailQuality100Quality value between 0-100
mediaThumbnailScale1Logical-pixel scale factor

Handling attachment errors

Attachment errors are strongly typed. Handle them via the onError callback on StreamMessageComposer:

StreamMessageComposer(
  onError: (error, stackTrace) {
    if (error is AttachmentTooLargeError) {
      showSnackBar('File too large');
    } else if (error is AttachmentLimitReachedError) {
      showSnackBar('Too many attachments');
    } else if (error is AttachmentBlockedError) {
      showSnackBar('File type not allowed');
    }
  },
)

The three typed errors correspond to the validation rules enforced by AppSettings:

ErrorCause
AttachmentTooLargeErrorFile exceeds the size limit configured in the Stream Dashboard
AttachmentLimitReachedErrorNumber of attachments exceeds attachmentLimit
AttachmentBlockedErrorFile extension or MIME type is blocked in the Stream Dashboard upload config

You can also catch errors directly from StreamAttachmentPickerController.addAttachment() inside a custom picker tab:

try {
  await controller.addAttachment(attachment);
} on AttachmentTooLargeError catch (e) {
  showError('File too large: ${e.fileSize} bytes (max ${e.maxSize} bytes)');
} on AttachmentLimitReachedError catch (e) {
  showError('Maximum ${e.maxCount} attachments allowed');
} on AttachmentBlockedError catch (e) {
  showError('File type not allowed: ${e.fileExtension ?? e.mimeType}');
}

Custom attachment results

For custom picker tabs that produce non-media results (for example, location or contact), use controller.notifyCustomResult() instead of addAttachment(). This emits a CustomAttachmentPickerResult that StreamMessageComposer forwards to onAttachmentPickerResult.

Do not call Navigator.pop() from inside a picker tab. The picker is an inline widget, not a modal route, and popping would close the wrong page. notifyCustomResult is the correct way to signal completion.

Step 1 - Define your result type:

class LocationPicked extends CustomAttachmentPickerResult {
  const LocationPicked({required this.location});
  final LatLng location;
}

Step 2 - Add a custom picker tab that emits the result:

StreamMessageComposer(
  attachmentPickerOptionsBuilder: (context, defaultOptions) {
    return [
      ...defaultOptions,
      TabbedAttachmentPickerOption(
        key: 'location-picker',
        icon: Icons.location_on,
        supportedTypes: [CustomLocationPickerType()],
        optionViewBuilder: (context, controller) {
          return LocationPicker(
            onLocationPicked: (location) {
              if (location == null) return; // user cancelled
              controller.notifyCustomResult(LocationPicked(location: location));
            },
          );
        },
      ),
    ];
  },
)

Step 3 - Handle the result in onAttachmentPickerResult:

StreamMessageComposer(
  onAttachmentPickerResult: (result) {
    if (result is LocationPicked) {
      sendLocationMessage(result.location);
      return true; // handled — StreamMessageComposer closes the picker
    }
    return false; // not handled — let StreamMessageComposer deal with it
  },
)

Return true to signal you handled the result (the picker closes automatically). Return false to let StreamMessageComposer handle it.

onAttachmentPickerResult is only called for CustomAttachmentPickerResult subtypes. AttachmentsPicked, PollCreated, and AttachmentPickerError are handled internally by StreamMessageComposer.

Previewing custom attachment types in the composer

The notifyCustomResult pattern above emits a one-shot result that bypasses the attachment pipeline. It's useful when the developer wants to send a fully custom message themselves. The alternative is to add a real Attachment with a custom type via controller.addAttachment(). The attachment then flows through the composer's preview area and is included on the sent message like any built-in attachment:

TabbedAttachmentPickerOption(
  key: 'location-picker',
  icon: Icons.location_on,
  supportedTypes: [CustomLocationPickerType()],
  optionViewBuilder: (context, controller) {
    return LocationPicker(
      onLocationPicked: (location) async {
        if (location == null) return;
        await controller.addAttachment(
          Attachment(
            type: 'location',
            extraData: {
                'file_size': 0,
                'latitude': location.coordinates.latitude,
                'longitude': location.coordinates.longitude,
            },
          ),
        );
      },
    );
  },
)

The default composer preview renders only the built-in attachment types (file, audio, voice recording, image, video, giphy). Any other type falls back to a generic "unsupported attachment" placeholder. To render your own preview, register a messageComposerAttachment builder via StreamComponentFactory. The builder is called for every attachment, so dispatch on the type and delegate to DefaultMessageComposerAttachment for the types you do not handle:

StreamChat(
  client: client,
  componentBuilders: StreamComponentBuilders(
    extensions: streamChatComponentBuilders(
      messageComposerAttachment: (context, props) {
        if (props.attachment.type == 'location') {
          return LocationAttachmentPreview(
            attachment: props.attachment,
            onRemovePressed: () => props.onRemovePressed?.call(props.attachment),
          );
        }

        return DefaultMessageComposerAttachment(props: props);
      },
    ),
  ),
  child: MyApp(),
)

See Customizing UI Components for more details on StreamComponentFactory.

Rendering custom attachment types in the message list

Once a message with a custom attachment is sent, the message list also needs to know how to render it. Create a subclass of StreamAttachmentWidgetBuilder and register it via StreamChatConfigurationData.attachmentBuilders:

class LocationAttachmentBuilder extends StreamAttachmentWidgetBuilder {
  @override
  bool canHandle(Message message, Map<String, List<Attachment>> attachments) {
    return attachments['location']?.isNotEmpty ?? false;
  }

  @override
  Widget build(
    BuildContext context,
    Message message,
    Map<String, List<Attachment>> attachments,
  ) {
    final attachment = attachments['location']!.first;
    return LocationMapWidget(
      latitude: attachment.extraData['latitude']! as double,
      longitude: attachment.extraData['longitude']! as double,
    );
  }
}

StreamChat(
  client: client,
  configData: StreamChatConfigurationData(
    attachmentBuilders: [LocationAttachmentBuilder()],
  ),
  child: MyApp(),
)

Custom builders are prepended to the default builders, so they take priority for the attachment types they handle. The attachments map is keyed by Attachment.type, so attachments['location'] returns every location attachment on the message. To scope the override to a single message, pass attachmentBuilders on StreamMessageItem (or StreamMessageItemProps.attachmentBuilders) directly.

See StreamMessageItem for additional examples and per-message overrides.

StreamAttachmentPickerController

The picker tracks selected attachments through StreamAttachmentPickerController. The following methods are available inside an optionViewBuilder:

MethodDescription
addAttachment(attachment)Add a media attachment (image, video, file, audio)
removeAttachment(attachment)Remove a previously added attachment
notifyCustomResult(result)Emit a custom result (location, contact) to onAttachmentPickerResult

StreamMessageComposer manages its own internal controller automatically. You only need to create a StreamAttachmentPickerController manually if you are embedding StreamTabbedAttachmentPicker or StreamSystemAttachmentPicker directly outside of StreamMessageComposer.