flow_ui 0.3.0 copy "flow_ui: ^0.3.0" to clipboard
flow_ui: ^0.3.0 copied to clipboard

Flow UI is an open-source Flutter UI library to build production-grade Chat & AI assistant interfaces.

example/lib/main.dart

import 'dart:async';

import 'package:flow_ui/flow_ui.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';

// The API key lives in env.g.dart — paste yours there.
import 'env.g.dart';
import 'gemini_api.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    // FlowTheme installs the design tokens as theme extensions — the
    // canonical host wiring, in both brightnesses so the app follows the
    // device setting.
    return MaterialApp(
      title: 'Flow UI Example',
      theme: ThemeData(extensions: [FlowTheme.light()]),
      darkTheme: ThemeData(
        brightness: Brightness.dark,
        extensions: [FlowTheme.dark()],
      ),
      home: const ChatScreen(),
    );
  }
}

/// The minimal live host: flow_ui renders the state, [GeminiApi] is the
/// transport, and this screen is the fold between them — messages are
/// pure view models, and streaming is data: each delta rebuilds the
/// reply's message with the grown text.
class ChatScreen extends StatefulWidget {
  const ChatScreen({super.key});

  @override
  State<ChatScreen> createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  /// The models offered in the composer's selector. The selection is host
  /// state: flow_ui reports the picked id and this screen hands it to the
  /// transport on the next turn.
  static const List<FlowModelOption> _models = [
    FlowModelOption(
      id: 'gemini-3.6-flash',
      label: 'Gemini 3.6 Flash',
      description: 'Fast, general-purpose replies',
    ),
    FlowModelOption(
      id: 'gemini-3.5-flash-lite',
      label: 'Gemini 3.5 Flash Lite',
      description: 'Lightest and quickest',
    ),
    FlowModelOption(
      id: 'gemini-3.1-flash-image',
      label: 'Gemini 3.1 Flash Image',
      description: 'Generates pictures',
    ),
  ];

  /// The models that answer with pictures. Only these are asked for image
  /// output; a text model refuses the request outright.
  static const Set<String> _imageModels = {'gemini-3.1-flash-image'};

  final ScrollController _scroll = ScrollController();

  List<FlowMessageData> _messages = const [];
  StreamSubscription<GeminiDelta>? _reply;
  int _nextId = 0;
  String _model = 'gemini-3.6-flash';

  /// Feedback per message id — true thumbed up, false down. Host state,
  /// like everything else: the actions row only reports the taps.
  final Map<String, bool> _feedback = {};

  /// Attachments picked or dropped but not yet sent. flow_ui opens the
  /// dialog, detects the drop and decodes the bytes; holding what comes
  /// back — and sending it — is this screen's.
  final List<FlowAttachment> _pending = [];
  int _nextAttachmentId = 0;

  /// The last refused file, in this app's words. The package reports the
  /// name and the reason and ships no copy of its own.
  String? _rejection;

  /// Shared by the attach button and the drop target, so a file gets the
  /// same answer whichever way it arrives. The 10 MB cap is this app's
  /// policy — flow_ui defaults to none.
  static const FlowAttachmentOptions _attachmentOptions = FlowAttachmentOptions(
    maxFileSize: 10 * 1024 * 1024,
  );

  /// The code part whose copy confirmation is showing; cleared after a
  /// beat. Copying is intent out: the block reports the part, the host
  /// writes the clipboard.
  FlowCodePart? _copiedCode;
  Timer? _copiedReset;

  bool get _generating => _reply != null;

  @override
  void dispose() {
    _copiedReset?.cancel();
    _reply?.cancel();
    _scroll.dispose();
    super.dispose();
  }

  void _send(String text) {
    if (_generating) return;
    setState(() {
      _rejection = null;
      _messages = [
        ..._messages,
        FlowMessageData(
          id: 'u${_nextId++}',
          role: FlowMessageRole.user,
          parts: [
            // Attachments lead the bubble and the caption follows, the
            // chat convention — parts render in the order given, so this
            // list *is* the layout.
            //
            // They also go up to Gemini with the turn: GeminiApi reads
            // the bytes flow_ui left on each attachment and inlines them.
            if (_pending.isNotEmpty) FlowAttachmentPart(List.of(_pending)),
            // Empty when a picture was sent with no caption, which the
            // composer allows — a photo on its own is a message.
            if (text.isNotEmpty) FlowTextPart(text),
          ],
        ),
      ];
      _pending.clear();
    });
    _generate();
  }

  /// Everything flow_ui hands back — picked from the dialog or dropped
  /// on the surface — lands here. The package read and decoded it; this
  /// app only has to hold it until send.
  void _addAttachments(List<FlowAttachment> attachments) {
    setState(() {
      _rejection = null;
      _pending.addAll(attachments);
    });
  }

  /// 'Add Files or Photos': the package's dialog, opened from this
  /// screen's menu. Called synchronously from the tap so the web keeps
  /// the gesture's user activation; the result is held here like any
  /// other attachment.
  Future<void> _pickFiles() async {
    final picked = await showFlowAttachmentPicker(
      options: _attachmentOptions,
      onRejected: _rejectAttachment,
    );
    if (!mounted || picked.isEmpty) return;
    _addAttachments(picked);
  }

  /// A file the options refused. flow_ui says which and why and stops
  /// there; the wording is the app's, and so is where it shows.
  void _rejectAttachment(String name, FlowAttachmentRejection reason) {
    final why = switch (reason) {
      FlowAttachmentRejection.tooLarge => 'is larger than 10 MB',
      FlowAttachmentRejection.unsupportedType => 'is not an image',
      FlowAttachmentRejection.unreadable => 'could not be read',
    };
    setState(() => _rejection = '$name $why');
  }

  /// Android's keyboard media insertion — a GIF picked inside the IME
  /// arrives as bytes rather than as a file, so it skips the picker and
  /// joins the pending strip directly.
  ///
  /// Carrying `bytes` and `mimeType` is what makes it upload like a
  /// picked file: the package fills those in for what it read itself, and
  /// a host building an attachment by hand fills them in the same way.
  void _insertContent(KeyboardInsertedContent content) {
    final data = content.data;
    if (data == null || data.isEmpty) return;
    setState(() {
      _rejection = null;
      _pending.add(
        FlowAttachment(
          id: 'p${_nextAttachmentId++}',
          thumbnail: MemoryImage(data),
          label: 'keyboard image',
          bytes: data,
          mimeType: content.mimeType,
        ),
      );
    });
  }

  void _generate() {
    final id = 'a${_nextId++}';
    final history = List.of(_messages);
    final generatesImages = _imageModels.contains(_model);
    setState(() {
      _messages = [
        ..._messages,
        // A text model's turn starts pending, which renders the thinking
        // indicator until the first delta flips it to streaming. An image
        // model's turn starts streaming with the picture slot already in
        // it: the image models send nothing before the picture, so a
        // pending turn would show 'thinking' right up to the moment the
        // bytes land, and the generating block would never be seen.
        FlowMessageData(
          id: id,
          role: FlowMessageRole.assistant,
          status: generatesImages
              ? FlowMessageStatus.streaming
              : FlowMessageStatus.pending,
          parts: generatesImages
              ? const [FlowImagePart(semanticLabel: 'Generated image')]
              : const [],
        ),
      ];
    });

    var reply = '';
    Uint8List? picture;
    String? pictureType;
    var aspectRatio = 1.0;
    // Once the turn has failed its parts are the error card's, and a
    // measurement that lands afterwards must not write over them.
    var failed = false;

    // The turn as it stands. On an image model the picture slot is there
    // from the first delta: a null image is FlowImagePart's generating
    // state, a shimmering block that becomes the picture when the bytes
    // land — generation is data, so the host just re-renders with the
    // provider set. Text first, picture under it.
    List<FlowMessagePart> parts({required bool settled}) => [
      if (reply.isNotEmpty) FlowTextPart(reply),
      if (picture != null || (generatesImages && !settled))
        FlowImagePart(
          // The same Uint8List every rebuild, so MemoryImage stays equal
          // and the framework never decodes it twice.
          image: picture == null ? null : MemoryImage(picture!),
          aspectRatio: aspectRatio,
          semanticLabel: 'Generated image',
          // Carried so the next turn can send the picture back with the
          // history; GeminiApi inlines it the way it does an attachment.
          bytes: picture,
          mimeType: pictureType,
        ),
    ];

    _reply =
        GeminiApi(apiKey: apiKey, model: _model, imageOutput: generatesImages)
            .streamReply(history)
            .listen(
              (delta) {
                switch (delta) {
                  case GeminiTextDelta(:final text):
                    reply += text;
                  case GeminiImageDelta(:final bytes, :final mimeType):
                    picture = bytes;
                    pictureType = mimeType;
                    // The block holds its shape while the picture decodes;
                    // its real proportions come from the bytes, so measure
                    // them and re-render once known.
                    _measure(bytes).then((ratio) {
                      if (failed) return;
                      if (ratio == null || !identical(picture, bytes)) return;
                      aspectRatio = ratio;
                      if (_messages.any((m) => m.id == id)) {
                        _update(id, parts: parts(settled: _reply == null));
                      }
                    });
                }
                _update(
                  id,
                  parts: parts(settled: false),
                  status: FlowMessageStatus.streaming,
                );
              },
              onError: (Object error) {
                _reply = null;
                failed = true;
                _update(
                  id,
                  status: FlowMessageStatus.error,
                  parts: [
                    ...parts(settled: true),
                    FlowErrorPart(
                      message: error is GeminiApiException
                          ? error.message
                          : 'Something went wrong. Check your connection and '
                                'try again.',
                    ),
                  ],
                );
              },
              onDone: () {
                _reply = null;
                // A stream can close without ever emitting anything (an
                // empty or filtered response); completing then would leave
                // a blank assistant row — drop the turn instead, like _stop
                // does. Settling also drops the picture slot if the model
                // chose to answer in words alone.
                if (reply.isEmpty && picture == null) {
                  if (!mounted) return;
                  setState(() {
                    _messages = [
                      for (final m in _messages)
                        if (m.id != id) m,
                    ];
                  });
                } else {
                  _update(
                    id,
                    parts: parts(settled: true),
                    status: FlowMessageStatus.complete,
                  );
                }
              },
              cancelOnError: true,
            );
  }

  /// A generated picture's width over its height, from its bytes. Null
  /// when they cannot be decoded, which leaves the block square.
  static Future<double?> _measure(Uint8List bytes) async {
    try {
      final image = await decodeImageFromList(bytes);
      final ratio = image.width / image.height;
      image.dispose();
      return ratio;
    } catch (_) {
      return null;
    }
  }

  /// Stop keeps whatever streamed in and closes the turn — unless nothing
  /// arrived yet, where completing would leave an empty turn: the still
  /// pending reply is removed instead.
  void _stop() {
    _reply?.cancel();
    _reply = null;
    final last = _messages.last;
    // A picture that never landed is not content: its generating block
    // goes with the stop, and a turn left with nothing goes entirely.
    final kept = [
      for (final part in last.parts)
        if (part is! FlowImagePart || part.image != null) part,
    ];
    if (kept.isEmpty) {
      setState(() {
        _messages = [
          for (final m in _messages)
            if (m.id != last.id) m,
        ];
      });
    } else {
      _update(last.id, parts: kept, status: FlowMessageStatus.complete);
    }
  }

  void _copyCode(FlowCodePart part) {
    Clipboard.setData(ClipboardData(text: part.code));
    _copiedReset?.cancel();
    setState(() => _copiedCode = part);
    _copiedReset = Timer(const Duration(seconds: 2), () {
      _copiedReset = null;
      if (mounted) setState(() => _copiedCode = null);
    });
  }

  void _copy(FlowMessageData message) {
    final text = [
      for (final part in message.parts)
        if (part is FlowTextPart) part.text,
    ].join('\n');
    Clipboard.setData(ClipboardData(text: text));
    showFlowToast(
      context: context,
      icon: Icons.copy_outlined,
      message: 'Message copied to clipboard',
      dismissTooltip: 'Dismiss',
    );
  }

  /// The actions row under a settled assistant reply: copy, feedback, and
  /// — on the latest reply only, where re-running makes sense — regenerate.
  Widget? _actionsFor(FlowMessageData message) {
    if (message.role != FlowMessageRole.assistant ||
        message.status != FlowMessageStatus.complete) {
      return null;
    }
    final feedback = _feedback[message.id];
    final isLatest = message.id == _messages.last.id;
    return FlowMessageActions(
      actions: [
        FlowMessageAction.copy(
          tooltip: 'Copy',
          onPressed: () => _copy(message),
        ),
        FlowMessageAction.thumbUp(
          tooltip: 'Good response',
          selected: feedback == true,
          onPressed: () => setState(() {
            feedback == true
                ? _feedback.remove(message.id)
                : _feedback[message.id] = true;
          }),
        ),
        FlowMessageAction.thumbDown(
          tooltip: 'Bad response',
          selected: feedback == false,
          onPressed: () => setState(() {
            feedback == false
                ? _feedback.remove(message.id)
                : _feedback[message.id] = false;
          }),
        ),
        if (isLatest)
          FlowMessageAction.regenerate(
            tooltip: 'Regenerate',
            onPressed: _generating ? null : () => _retry(message),
          ),
      ],
    );
  }

  /// Retry from the thread's error card: drop the failed reply, re-run.
  void _retry(FlowMessageData message) {
    if (_generating) return;
    setState(() {
      _messages = [
        for (final m in _messages)
          if (m.id != message.id) m,
      ];
    });
    _generate();
  }

  void _update(
    String id, {
    List<FlowMessagePart>? parts,
    FlowMessageStatus? status,
  }) {
    if (!mounted) return;
    setState(() {
      _messages = [
        for (final m in _messages)
          if (m.id == id) m.copyWith(parts: parts, status: status) else m,
      ];
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: context.flowColors.surface,
      body: FlowChatView(
        // Drag-and-drop, handled by the package. Web only — the SDK
        // implements OS file drop nowhere else — and a no-op elsewhere,
        // which is why the attach button carries the same job.
        onAttachmentsDropped: _addAttachments,
        onAttachmentRejected: _rejectAttachment,
        attachmentOptions: _attachmentOptions,
        dropLabel: 'Drop files to add to chat',
        empty: _messages.isEmpty,
        greeting: const FlowGreeting(
          icon: Icons.wb_twilight,
          text: 'Good afternoon',
        ),
        thread: FlowThread(
          messages: _messages,
          controller: _scroll,
          thinkingLabel: 'Thinking…',
          errorTitle: 'Reply failed',
          retryLabel: 'Retry',
          onRetry: _retry,
          // Intent out: the host decides what opening a link means. Here,
          // a snackbar showing the href stands in for a browser launch.
          onLinkTap: (message, href) => ScaffoldMessenger.of(
            context,
          ).showSnackBar(SnackBar(content: Text(href))),
          onCodeCopy: _copyCode,
          copiedCodePart: _copiedCode,
          codeCopyTooltip: 'Copy code',
          // The footer slot keeps the default message and its wiring;
          // only the actions row is the host's.
          messageFooter: _actionsFor,
        ),
        threadController: _scroll,
        jumpToLatestTooltip: 'Jump to latest',
        composer: FlowComposer(
          isStreaming: _generating,
          onSend: _send,
          onStop: _stop,
          // Picking goes through the "+" menu below; paste and drop land
          // in the same place — three ways in, one handler.
          onAttachmentsPasted: _addAttachments,
          onAttachmentRejected: _rejectAttachment,
          attachmentOptions: _attachmentOptions,
          // The design's error banner above the card; the words are this
          // screen's, and its cross hands the dismissal back here.
          errorMessage: _rejection,
          onErrorDismiss: () => setState(() => _rejection = null),
          errorDismissTooltip: 'Dismiss',
          onContentInserted: _insertContent,
          attachments: List.of(_pending),
          onRemoveAttachment: (id) =>
              setState(() => _pending.removeWhere((a) => a.id == id)),
          leadingActions: [
            // The design's way in: 'Add Files or Photos' opens the
            // package's own dialog, from the host's menu.
            FlowMenu(
              icon: Icons.add,
              tooltip: 'Add to chat',
              sheetTitle: 'Add to chat',
              entries: const [
                FlowMenuOption(
                  id: 'files',
                  icon: Icons.upload_file_outlined,
                  label: 'Add Files or Photos',
                ),
              ],
              onSelected: (id) {
                if (id == 'files') unawaited(_pickFiles());
              },
            ),
          ],
          trailingActions: [
            FlowModelSelector(
              models: _models,
              selectedId: _model,
              onSelected: (id) => setState(() => _model = id),
            ),
          ],
        ),
      ),
    );
  }
}
18
likes
160
points
467
downloads

Documentation

Documentation
API reference

Publisher

verified publisherstac.dev

Weekly Downloads

Flow UI is an open-source Flutter UI library to build production-grade Chat & AI assistant interfaces.

Homepage
Repository (GitHub)
View/report issues

Topics

#ai #chat #chatbot #assistant #ui

License

MIT (license)

Dependencies

file_selector, flutter, google_fonts, material_ui

More

Packages that depend on flow_ui