Skip to content

FlowComposer is the raised input card: a multiline field that grows with the draft, a send button that becomes stop while the model is generating, and slots for host actions on the design’s rail. onSend is required — a composer needs somewhere to send to. The card sits on surfaceBright behind a gradient hairline that firms on hover and focus, standing at the design’s 116px with an empty draft — which also drains the send disc to the disabled ink. The whole card is the field’s click target: a click anywhere on it that is not a control focuses the field, and the pointer reads as a text cursor over all of it.

The composer’s slots take other flow_ui components directly — here the “+” menu in leadingActions and the model selector in trailingActions:

Send, stop, and the action slots
FlowComposer(
placeholder: 'Message flow_ui…',
isStreaming: generating,
onSend: (text) => startGeneration(text),
onStop: cancelGeneration,
leadingActions: [
FlowMenu(
icon: Icons.add,
tooltip: 'Add to chat',
entries: [
FlowMenuOption(
id: 'photos', icon: Icons.image_outlined, label: 'Photos'),
FlowMenuOption(
id: 'files', icon: Icons.upload_file_outlined, label: 'Files'),
],
onSelected: (id) => pickAttachment(id),
),
],
trailingActions: [
FlowModelSelector(
models: [
FlowModelOption(id: 'sonnet', label: 'Sonnet 5'),
FlowModelOption(id: 'opus', label: 'Opus 5'),
],
selectedId: modelId,
onSelected: (id) => setModel(id),
efforts: [
FlowEffortOption(id: 'medium', label: 'Medium'),
FlowEffortOption(id: 'high', label: 'High'),
],
selectedEffortId: effortId,
onEffortSelected: (id) => setEffort(id),
),
],
)

isStreaming drives the send button: false shows send, true swaps it for stop and routes taps to onStop. Flip it from your generation state and the composer stays honest about what a tap will do.

On hardware keyboards Enter sends and Shift+Enter inserts a newline — submitOnEnter: false opts out; mobile soft keyboards keep their newline key and send from the button. The field grows with the draft to maxLines (6) and scrolls beyond it, and clearOnSend empties it after a successful send. onSend receives the trimmed text, and fires empty only when something is attached — a picture with no caption is still a message.

Three ways to open the platform’s file dialog, and they differ only in who owns the control. All three hand you decoded FlowAttachments; holding them is still yours — add them to state and pass them back through attachments.

From your own control — the design’s “+” menu — call showFlowAttachmentPicker. It resolves empty when the dialog is dismissed or everything was refused, and it never throws: a dialog that cannot open comes back through onRejected under an empty name. Call it synchronously from the tap — inside onSelected, not a post-frame callback — because on the web the dialog needs the gesture’s user activation.

The design's route: 'Add Files or Photos' in the + menu
FlowComposer(
onSend: send,
attachments: pending,
onRemoveAttachment: removePending,
leadingActions: [
FlowMenu(
icon: Icons.add,
entries: const [FlowMenuOption(id: 'files', label: 'Add Files or Photos')],
onSelected: (id) {
if (id == 'files') {
showFlowAttachmentPicker(options: options, onRejected: reject)
.then((picked) => setState(() => pending.addAll(picked)));
}
},
),
],
)

From a built-in button — onAttachmentsPicked renders an attach button first in the action row and opens the same dialog from it:

The package renders the button and opens the dialog
FlowComposer(
onSend: send,
onAttachmentsPicked: (picked) => setState(() => pending.addAll(picked)),
attachTooltip: 'Attach images',
attachments: pending,
onRemoveAttachment: removePending,
)

Picking yourself — onAttach renders that same button as a plain intent, for a photo sheet, a camera, a menu of sources, anything the package has no business knowing about; whatever you gather goes back through attachments the same way.

Or own the picking
FlowComposer(
onSend: send,
onAttach: () async {
final file = await openGallerySheet(); // your picker
if (file != null) setState(() => pending.add(toAttachment(file)));
},
attachTooltip: 'Attach files',
attachments: pending,
onRemoveAttachment: removePending,
)

Passing neither of the last two leaves the button off. Passing both asserts — one button cannot have two owners.

Presence of a callback says the feature is wired; attachmentsEnabled says it is available. Set it false — for a plan tier, a model that takes no images, a thread that has closed — and every way in stops at once without unwiring anything: no attach button, no paste, no keyboard media, and a drop on the card is swallowed — the card stays a drop zone as far as the browser is concerned, showing the refusing cursor, so a file dropped on it never navigates the tab away. FlowChatView has the same flag for the surface’s drop, so a host flips both together. Attachments already pending stay in the strip and can still be removed, and a + menu entry that calls showFlowAttachmentPicker is yours to hide alongside it.

Attachments wired, but off for this plan
FlowComposer(
attachmentsEnabled: plan.allowsUploads,
onAttachmentsPicked: addAttachments,
onAttachmentsPasted: addAttachments,
...
)

attachmentOptions says what the dialog offers and how what comes back is decoded. It defaults to images, with the full-screen preview capped at 2048px and no file-size limit — a cap is policy, and policy is yours. Tiles need no cap of their own: each one decodes to its own size and the display’s pixel ratio.

const FlowAttachmentOptions(
allowMultiple: true,
maxFileSize: 10 * 1024 * 1024,
accept: [FlowAttachmentTypeGroup.images],
)

A FlowAttachmentTypeGroup names its types four ways — extensions, mimeTypes, uniformTypeIdentifiers, webWildCards — because the platforms disagree and each one throws when the family it reads is empty: Apple platforms want the type identifiers, Windows wants the extensions, Linux and Android take either extensions or MIME types, the web wants the wildcards. Fill in all four for a group that works everywhere, the way FlowAttachmentTypeGroup.images does.

Anything the options refuse is reported to onAttachmentRejected with the file’s name and a FlowAttachmentRejection — tooLarge, unsupportedType, unreadable. flow_ui ships no copy for these, so saying so is yours, and so is the wording.

Whatever the refusal — or any other error the composer should own up to — errorMessage raises the design’s banner: a tab in the error wash tucked behind the card, with a warning glyph and your line beside it, wrapping from the glyph when long. The words are yours, and so is clearing it: it stays until the cross at its trailing edge fires onErrorDismiss and you set it back to null — or until you clear it on your own terms, in which case leave onErrorDismiss null and no cross is drawn.

The error banner
FlowComposer(
onSend: send,
onAttachmentRejected: (name, reason) => setState(() {
rejection = '$name is not an image';
}),
errorMessage: rejection,
onErrorDismiss: () => setState(() => rejection = null),
errorDismissTooltip: 'Dismiss',
)

errorIcon swaps the drawn diamond for an icon of your own set; FlowComposerStyle.errorBackgroundColor and errorForegroundColor recolour the tab.

On iOS and Android the dialog is the system file browser, not the photo gallery — that is the cost of staying on flutter.dev packages, and exactly the case onAttach exists for.

onAttachmentsDropped makes the card itself a drop target. While a file is over it the card lights up — the hairline goes solid and the fill takes a 6% wash, both in FlowComposerStyle.dropHighlightColor (primary by default) — and letting go hands back the same decoded FlowAttachments the picker does, held to the same attachmentOptions.

Drop scoped to the composer
FlowComposer(
onSend: send,
onAttachmentsPicked: (picked) => setState(() => pending.addAll(picked)),
onAttachmentsDropped: (dropped) => setState(() => pending.addAll(dropped)),
attachments: pending,
onRemoveAttachment: removePending,
)

There are two places to wire a drop, and they differ only in scope:

Catches a drop Treatment
FlowComposer.onAttachmentsDropped over the composer card the card lights up
FlowChatView.onAttachmentsDropped anywhere on the surface full-bleed tint and invitation card

The surface is the forgiving target and what most chat apps want; the card is for an app where the rest of the page has its own meaning for a dropped file. Wiring both is fine — the innermost target under the pointer wins, so the card takes drops aimed at it and the surface takes the rest.

onAttachmentsPasted takes an image pasted into the field — Ctrl+V or Cmd+V — and hands it back decoded, held to the same attachmentOptions as everything else.

Three ways in, one handler
FlowComposer(
onSend: send,
onAttachmentsPicked: addAttachments,
onAttachmentsDropped: addAttachments,
onAttachmentsPasted: addAttachments,
attachments: pending,
onRemoveAttachment: removePending,
)

It fires only while the field has focus, so a paste meant for another input on the page is left alone, and only when the clipboard actually holds a file — pasting text stays the field’s own business, untouched. When the clipboard holds both, which is what copying a file in Finder or Explorer produces, the file is attached and the filename that would otherwise have landed in the draft is suppressed.

On Android, onContentInserted receives media the software keyboard inserts — a GIF picked inside Gboard — as mime type plus bytes, the one media path the Flutter SDK covers without a plugin. It is IME insertion only, not clipboard paste; the bytes arrive with no file behind them, so build the FlowAttachment yourself and add it to attachments.

The composer renders a pending-attachments row above the field — pass attachments: and onRemoveAttachment:. The Attachments page shows the full flow, from the “+” menu to the sent message.

FlowComposerStyle overrides the composer’s colors without touching the global tokens: backgroundColor (the raised card’s fill), outlineColor (flattens the gradient hairline to one solid color in every state), sendBackgroundColor and sendForegroundColor (the send/stop disc), textStyle (merged over the field’s default), hintColor, attachIconColor (the attach button’s resting glyph), dropHighlightColor (what the card lights up in under a drag), and errorBackgroundColor / errorForegroundColor (the error banner’s wash and ink). Install one on FlowTheme.composerStyle for every composer; the widget’s style wins field by field.

  • onSend — required; receives the trimmed draft, never empty text.
  • isStreaming + onStop — the send/stop swap.
  • enabled, submitOnEnter, clearOnSend, maxLines — field behavior; all default on, with a 6-line growth cap.
  • leadingActions / trailingActions — widget slots on the input rail; any widget works, the flow_ui menus just fit the design out of the box.
  • attachments, onRemoveAttachment, removeAttachmentTooltip — the pending row. onAttachmentTap replaces the built-in full-screen preview; previewCloseTooltip labels the preview’s close button.
  • showFlowAttachmentPicker — opens the platform’s dialog from your own control and hands back decoded attachments; never throws. onAttachmentsPicked — renders a built-in attach button that opens the same dialog. onAttach — renders that button and leaves the picking to you; the two are mutually exclusive, and neither one leaves the button off. attachmentOptions + onAttachmentRejected — what is accepted, and what wasn’t. attachTooltip names the button. onAttachmentsDropped — drag-and-drop scoped to the card (web only); FlowChatView has the whole-surface version. onAttachmentsPasted — images pasted into the field (web only). attachmentsEnabled — turns every way in off at once, handlers left wired. errorMessage + errorIcon — the error banner above the card; onErrorDismiss + errorDismissTooltip draw its cross. onContentInserted — Android keyboard media insertion.
  • controller, focusNode, placeholder — the field itself; pass your own TextEditingController to prefill or clear the draft. placeholder defaults to ‘How can I help you today?’ — the one string the package ships — so localized hosts pass their own copy, and an explicit null renders no hint.
  • padding, borderRadius — the card’s metric overrides (the design’s 24px corner by default).