E-commerce53 views

How to Build a Store Support Chat Screen in Flutter (Full Code + Preview)

Support chat is where an order problem either gets resolved or turns into a refund, and shoppers judge the whole store by how that thread feels. This tutorial walks through StyleCart's support-chat screen in Flutter: an agent header with a painted monogram avatar and live status, asymmetric agent and user bubbles with timestamps and read ticks, an inline order card, a three-dot typing row, tappable quick replies, and a composer that appends messages and auto-scrolls to the newest one. Everything is pure Flutter — one file, no chat SDK.

Support Chat — E-commerce Flutter UI screen
Live preview — Support Chat, built in pure Flutter.

What you'll build

  • ✓An agent header with a painted monogram avatar, a white-ringed green online dot, and a 'replies in ~2 min' promise
  • ✓Asymmetric chat bubbles — grey agent bubbles with a 4px tail corner on the left, coral #FF385C user bubbles mirrored on the right, each with a timestamp and green read ticks
  • ✓An inline order-attachment card showing #SC-20488 with its item count and total, sitting on the user's side of the thread
  • ✓A working send flow: quick-reply chips and the composer both append a real bubble and animate the thread to the bottom
  • ✓A three-dot typing indicator that borrows the agent bubble's exact shape

Step-by-step build

1

Create the file

Add a new file at lib/ecom_help_chat/ecom_help_chat_screen.dart in your Flutter project.

2

Register the bundled fonts

No external packages — this is pure Flutter. It does bundle its design font (Manrope), so drop the font file into fonts/ and declare it in pubspec.yaml:

pubspec.yaml
flutter:
  fonts:
    - family: Manrope
      fonts:
        - asset: fonts/Manrope-Regular.ttf
3

Build it, piece by piece

Here's how the screen goes together. Each block below is a real slice of the code with a plain-English explanation — paste them in order, or grab the whole file from the next section.

State, tokens, and a send that scrolls

ecom_help_chat_screen.dart
class EcomHelpChatScreen extends StatefulWidget {
  const EcomHelpChatScreen({super.key, this.onBack});

  final VoidCallback? onBack;

  @override
  State<EcomHelpChatScreen> createState() => _EcomHelpChatScreenState();
}

class _EcomHelpChatScreenState extends State<EcomHelpChatScreen> {
  static const String _font = 'Manrope';
  static const Color _canvas = Color(0xFFFFFFFF);
  static const Color _ink = Color(0xFF222222);
  static const Color _muted = Color(0xFF6A6A6A);
  static const Color _faint = Color(0xFFC1C1C1);
  static const Color _brand = Color(0xFFFF385C);
  static const Color _surface = Color(0xFFF2F2F2);
  static const Color _hairline = Color(0xFFEBEBEB);
  static const Color _success = Color(0xFF2E9E5B);

  static const List<String> _quickReplies = <String>[
    'Track my order',
    'Start a return',
    'Talk to a human',
  ];

  final List<_Msg> _messages = <_Msg>[
    const _Msg('Hi Maya 👋 I’m Asha from StyleCart support. How can I help '
        'you today?', false, '9:32 AM'),
    const _Msg('Hi! I have a question about order #SC-20488.', true, '9:33 AM'),
    const _Msg('Of course — I can see it right here. It’s out for delivery and '
        'should arrive by 6 PM today.', false, '9:33 AM'),
  ];

  final TextEditingController _controller = TextEditingController();
  final ScrollController _scroll = ScrollController();

  @override
  void dispose() {
    _controller.dispose();
    _scroll.dispose();
    super.dispose();
  }

  void _send(String text) {
    final String t = text.trim();
    if (t.isEmpty) return;
    setState(() {
      _messages.add(_Msg(t, true, 'Now'));
      _controller.clear();
    });
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_scroll.hasClients) {
        _scroll.animateTo(
          _scroll.position.maxScrollExtent,
          duration: const Duration(milliseconds: 280),
          curve: Curves.easeOut,
        );
      }
    });
  }

Unlike a static screen, chat has to mutate, so `EcomHelpChatScreen` is a `StatefulWidget` holding a `List<_Msg> _messages` seeded with three turns of a real conversation, a `TextEditingController` for the composer, and a `ScrollController` for the thread — both disposed properly. The palette is Airbnb-flavoured: `_brand` is the coral `#FF385C`, `_success` green `#2E9E5B` does double duty for the online dot and read ticks, and four greys (`_ink`, `_muted`, `_faint`, `_surface`) carry everything else. The interesting method is `_send`: it trims, bails on empty input, appends a `_Msg` stamped 'Now' inside `setState`, then scrolls to `maxScrollExtent` inside `addPostFrameCallback`. That post-frame hop matters — at the moment `setState` runs, the new bubble has not been laid out yet, so scrolling immediately would stop one message short. The 280ms `easeOut` animation makes the arrival feel like a message landing rather than a jump cut.

The build: a Column where only the thread flexes

ecom_help_chat_screen.dart
  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              _header(),
              const Divider(height: 1, color: _hairline),
              Expanded(
                child: ListView(
                  controller: _scroll,
                  padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
                  children: <Widget>[
                    _dayDivider('Today'),
                    _agentRow(_messages[0]),
                    _userBubble(_messages[1]),
                    _orderAttachment(),
                    for (int i = 2; i < _messages.length; i++)
                      _messages[i].fromUser
                          ? _userBubble(_messages[i])
                          : _agentRow(_messages[i]),
                    _typingRow(),
                  ],
                ),
              ),
              _quickReplyRow(),
              _composer(),
            ],
          ),
        ),
      ),
    );
  }

The scaffold is a `Column` with exactly one `Expanded` child — the message `ListView` — so the header, quick-reply row, and composer keep their natural heights while the thread absorbs whatever is left. The list's children mix hardcoded openers (`_dayDivider('Today')`, the first agent message, the first user message, then `_orderAttachment()`) with a collection-for that renders `_messages[2]` onward, branching on `fromUser` to pick `_userBubble` or `_agentRow`. That split is why the order card can sit mid-conversation at a fixed spot while every newly sent message still appears dynamically below it. `_typingRow()` is always the last child, so the agent perpetually appears mid-reply. The `ListView` owns `_scroll`, which is what lets `_send` drive it to the bottom.

A header that earns trust before the first message

ecom_help_chat_screen.dart
  Widget _header() {
    return Padding(
      padding: const EdgeInsets.fromLTRB(6, 4, 16, 8),
      child: Row(
        children: <Widget>[
          IconButton(
            onPressed: widget.onBack,
            icon: const Icon(Icons.arrow_back_rounded, size: 22, color: _ink),
          ),
          Stack(
            children: <Widget>[
              CustomPaint(
                size: const Size(40, 40),
                painter: _MonogramPainter('A', Color(0xFF6A4C93)),
              ),
              Positioned(
                right: 0,
                bottom: 0,
                child: Container(
                  width: 12,
                  height: 12,
                  decoration: BoxDecoration(
                    color: _success,
                    shape: BoxShape.circle,
                    border: Border.all(color: _canvas, width: 2),
                  ),
                ),
              ),
            ],
          ),
          const SizedBox(width: 12),
          const Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Text(
                  'Asha · StyleCart',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 15.5,
                    fontWeight: FontWeight.w800,
                    color: _ink,
                  ),
                ),
                SizedBox(height: 1),
                Text(
                  'Online · replies in ~2 min',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 11.5,
                    fontWeight: FontWeight.w600,
                    color: _success,
                  ),
                ),
              ],
            ),
          ),
          const Icon(Icons.more_vert_rounded, size: 22, color: _muted),
        ],
      ),
    );
  }

The header answers 'is anyone actually there?' three ways at once. A `Stack` layers a 40px `CustomPaint` monogram avatar with a `Positioned` 12px `_success` circle at bottom-right, ringed by a 2px `_canvas`-white border so the dot reads as sitting on top of the avatar rather than merging into it. The name line 'Asha · StyleCart' is 15.5px `w800`, and directly under it 'Online · replies in ~2 min' renders at 11.5px in the same green as the dot — a concrete expectation, not just a status. Naming a wait time is what stops a shopper from firing off three duplicate messages. The row's padding starts at 6 on the left because the `IconButton`'s own touch target supplies the rest, and a `more_vert` icon in `_muted` closes the row without pretending to be a primary action.

The day divider and the agent's side of the thread

ecom_help_chat_screen.dart
  Widget _dayDivider(String label) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 12),
      child: Center(
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
          decoration: BoxDecoration(
            color: _surface,
            borderRadius: BorderRadius.circular(9999),
          ),
          child: Text(
            label,
            style: const TextStyle(
              fontFamily: _font,
              fontSize: 11,
              fontWeight: FontWeight.w700,
              color: _muted,
            ),
          ),
        ),
      ),
    );
  }

  Widget _agentRow(_Msg m) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 10),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.end,
        children: <Widget>[
          CustomPaint(
            size: const Size(28, 28),
            painter: _MonogramPainter('A', Color(0xFF6A4C93)),
          ),
          const SizedBox(width: 8),
          Flexible(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Container(
                  padding: const EdgeInsets.symmetric(
                      horizontal: 14, vertical: 10),
                  decoration: const BoxDecoration(
                    color: _surface,
                    borderRadius: BorderRadius.only(
                      topLeft: Radius.circular(4),
                      topRight: Radius.circular(16),
                      bottomLeft: Radius.circular(16),
                      bottomRight: Radius.circular(16),
                    ),
                  ),
                  child: Text(
                    m.text,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 13.5,
                      fontWeight: FontWeight.w500,
                      height: 1.4,
                      color: _ink,
                    ),
                  ),
                ),
                Padding(
                  padding: const EdgeInsets.only(left: 6, top: 3),
                  child: Text(
                    m.time,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 10,
                      fontWeight: FontWeight.w600,
                      color: _faint,
                    ),
                  ),
                ),
              ],
            ),
          ),
          const SizedBox(width: 40),
        ],
      ),
    );
  }

`_dayDivider` is a centred `_surface` pill with `BorderRadius.circular(9999)` and 11px `w700` `_muted` text — the classic messenger 'Today' marker, parameterised so a longer thread can insert 'Yesterday' the same way. `_agentRow` sets the pattern for the left side: a 28px monogram avatar, then the bubble in a `Flexible` so long text wraps instead of overflowing, then a trailing `const SizedBox(width: 40)` that stops agent bubbles from ever spanning the full width — the empty 40px is what visually reserves the right edge for the user. The bubble itself is `_surface` grey with `BorderRadius.only`: 4px at topLeft, 16px everywhere else. That one squared-off corner points back toward the avatar and is the entire 'speech tail' — no painter, no clip path. `crossAxisAlignment: CrossAxisAlignment.end` on the row bottom-aligns the avatar with the bubble, and the 10px `_faint` timestamp hangs below with a 6px left inset so it lines up with the bubble's text, not its edge.

User bubbles: the same shape, mirrored

ecom_help_chat_screen.dart
  Widget _userBubble(_Msg m) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 10, left: 40),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.end,
        children: <Widget>[
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
            decoration: const BoxDecoration(
              color: _brand,
              borderRadius: BorderRadius.only(
                topLeft: Radius.circular(16),
                topRight: Radius.circular(4),
                bottomLeft: Radius.circular(16),
                bottomRight: Radius.circular(16),
              ),
            ),
            child: Text(
              m.text,
              style: const TextStyle(
                fontFamily: _font,
                fontSize: 13.5,
                fontWeight: FontWeight.w500,
                height: 1.4,
                color: Colors.white,
              ),
            ),
          ),
          Padding(
            padding: const EdgeInsets.only(right: 6, top: 3),
            child: Row(
              mainAxisSize: MainAxisSize.min,
              children: <Widget>[
                Text(
                  m.time,
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 10,
                    fontWeight: FontWeight.w600,
                    color: _faint,
                  ),
                ),
                const SizedBox(width: 3),
                const Icon(Icons.done_all_rounded, size: 13, color: _success),
              ],
            ),
          ),
        ],
      ),
    );
  }

`_userBubble` is `_agentRow` reflected. The 40px reservation swaps sides — here it is `padding: EdgeInsets.only(left: 40)` on the whole column — and the squared 4px corner moves to topRight, pointing at the sender. The fill flips from `_surface` grey to full `_brand` coral with white text, which is the strongest cue in the thread: you never need to read a name to know who said what, because colour and alignment both say it. There is no avatar on this side — you know who you are — so the bubble hangs directly from `CrossAxisAlignment.end`. Below it, a `mainAxisSize: MainAxisSize.min` Row pairs the 10px timestamp with a 13px `done_all_rounded` icon in `_success`, the WhatsApp-style double tick that tells the shopper support has actually seen the message — which is half the reason people open a chat instead of sending an email.

Attaching the order to the conversation

ecom_help_chat_screen.dart
  Widget _orderAttachment() {
    return Padding(
      padding: const EdgeInsets.only(bottom: 10, left: 40),
      child: Align(
        alignment: Alignment.centerRight,
        child: Container(
          width: 230,
          padding: const EdgeInsets.all(12),
          decoration: BoxDecoration(
            color: _canvas,
            borderRadius: BorderRadius.circular(14),
            border: Border.all(color: _hairline),
          ),
          child: Row(
            children: <Widget>[
              Container(
                width: 44,
                height: 44,
                decoration: BoxDecoration(
                  color: _surface,
                  borderRadius: BorderRadius.circular(10),
                ),
                child: const Icon(Icons.inventory_2_outlined,
                    size: 21, color: _muted),
              ),
              const SizedBox(width: 10),
              const Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Text(
                      '#SC-20488',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 13,
                        fontWeight: FontWeight.w800,
                        color: _ink,
                      ),
                    ),
                    SizedBox(height: 2),
                    Text(
                      '3 items · \$312',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 11.5,
                        fontWeight: FontWeight.w600,
                        color: _muted,
                      ),
                    ),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

`_orderAttachment` puts the order being discussed into the thread as a card instead of making the agent retype details. It reuses the user side's `left: 40` inset plus `Align(alignment: Alignment.centerRight)`, so it reads as something the shopper shared. Where the bubbles are filled, the card is deliberately quiet: `_canvas` white with a `_hairline` border and a softer 14px radius, because it is a document, not speech. Inside, a 44px `_surface` square holds an `inventory_2_outlined` icon, and the text column pairs the order number `#SC-20488` at 13px `w800` with '3 items · $312' at 11.5px `_muted`. The fixed `width: 230` keeps the card bubble-sized rather than letting the Row stretch it wall-to-wall, and the order number matching the one typed in the seeded message ties the card to the conversation.

Typing dots and quick replies

ecom_help_chat_screen.dart
  Widget _typingRow() {
    return Padding(
      padding: const EdgeInsets.only(bottom: 6),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.end,
        children: <Widget>[
          CustomPaint(
            size: const Size(28, 28),
            painter: _MonogramPainter('A', Color(0xFF6A4C93)),
          ),
          const SizedBox(width: 8),
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
            decoration: const BoxDecoration(
              color: _surface,
              borderRadius: BorderRadius.only(
                topLeft: Radius.circular(4),
                topRight: Radius.circular(16),
                bottomLeft: Radius.circular(16),
                bottomRight: Radius.circular(16),
              ),
            ),
            child: Row(
              mainAxisSize: MainAxisSize.min,
              children: <Widget>[
                _dot(),
                const SizedBox(width: 4),
                _dot(),
                const SizedBox(width: 4),
                _dot(),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _dot() => Container(
        width: 6,
        height: 6,
        decoration: const BoxDecoration(color: _faint, shape: BoxShape.circle),
      );

  Widget _quickReplyRow() {
    return SizedBox(
      height: 44,
      child: ListView.separated(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
        itemCount: _quickReplies.length,
        separatorBuilder: (BuildContext _, int i) => const SizedBox(width: 8),
        itemBuilder: (BuildContext _, int i) {
          return GestureDetector(
            onTap: () => _send(_quickReplies[i]),
            child: Container(
              padding: const EdgeInsets.symmetric(horizontal: 14),
              alignment: Alignment.center,
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(9999),
                border: Border.all(color: _brand.withValues(alpha: 0.4)),
              ),
              child: Text(
                _quickReplies[i],
                style: const TextStyle(
                  fontFamily: _font,
                  fontSize: 12.5,
                  fontWeight: FontWeight.w800,
                  color: _brand,
                ),
              ),
            ),
          );
        },
      ),
    );
  }

`_typingRow` reuses the agent bubble's exact `BorderRadius.only` shape and 28px avatar, but swaps the text for three 6px `_faint` circles from the `_dot()` helper, each separated by 4px. Keeping the identical bubble geometry is the trick: the indicator reads as 'a message forming' rather than a separate UI element. Below the thread, `_quickReplyRow` is a 44px-tall horizontal `ListView.separated` of the three `_quickReplies` — 'Track my order', 'Start a return', 'Talk to a human' — the three things e-commerce support actually gets asked. Each chip is an outlined pill: `_brand.withValues(alpha: 0.4)` border, full-coral 12.5px `w800` text, no fill, so they present as suggestions rather than committed messages. Because each `GestureDetector` calls `_send(_quickReplies[i])`, tapping one goes through the exact same append-and-scroll path as typing — one code path for every way a message enters the thread.

The composer, and painting the avatar

ecom_help_chat_screen.dart
  Widget _composer() {
    return Container(
      decoration: const BoxDecoration(
        color: _canvas,
        border: Border(top: BorderSide(color: _hairline)),
      ),
      padding: const EdgeInsets.fromLTRB(12, 10, 12, 12),
      child: Row(
        children: <Widget>[
          const Icon(Icons.add_circle_outline_rounded,
              size: 26, color: _muted),
          const SizedBox(width: 10),
          Expanded(
            child: Container(
              padding: const EdgeInsets.symmetric(horizontal: 14),
              decoration: BoxDecoration(
                color: _surface,
                borderRadius: BorderRadius.circular(9999),
              ),
              child: TextField(
                controller: _controller,
                textInputAction: TextInputAction.send,
                onSubmitted: _send,
                style: const TextStyle(
                  fontFamily: _font,
                  fontSize: 14,
                  fontWeight: FontWeight.w600,
                  color: _ink,
                ),
                decoration: const InputDecoration(
                  isDense: true,
                  border: InputBorder.none,
                  hintText: 'Message…',
                  hintStyle: TextStyle(
                    fontFamily: _font,
                    fontSize: 14,
                    fontWeight: FontWeight.w600,
                    color: _faint,
                  ),
                  contentPadding: EdgeInsets.symmetric(vertical: 12),
                ),
              ),
            ),
          ),
          const SizedBox(width: 10),
          GestureDetector(
            onTap: () => _send(_controller.text),
            child: Container(
              width: 44,
              height: 44,
              decoration: const BoxDecoration(
                color: _brand,
                shape: BoxShape.circle,
              ),
              child: const Icon(Icons.arrow_upward_rounded,
                  size: 22, color: Colors.white),
            ),
          ),
        ],
      ),
    );
  }
}

class _Msg {
  const _Msg(this.text, this.fromUser, this.time);
  final String text;
  final bool fromUser;
  final String time;
}

/// A painted circular monogram avatar (tinted disc + white initial).
class _MonogramPainter extends CustomPainter {
  _MonogramPainter(this.initial, this.tint);
  final String initial;
  final Color tint;

  @override
  void paint(Canvas canvas, Size size) {
    final Offset c = size.center(Offset.zero);
    canvas.drawCircle(c, size.width / 2, Paint()..color = tint);
    final TextPainter tp = TextPainter(
      text: TextSpan(
        text: initial,
        style: TextStyle(
          fontFamily: 'Manrope',
          fontSize: size.width * 0.46,
          fontWeight: FontWeight.w800,
          color: Colors.white,
        ),
      ),
      textDirection: TextDirection.ltr,
    )..layout();
    tp.paint(canvas,
        Offset((size.width - tp.width) / 2, (size.height - tp.height) / 2));
  }

  @override
  bool shouldRepaint(_MonogramPainter old) =>
      old.initial != initial || old.tint != tint;
}

`_composer` sits in a `_canvas` Container with a `_hairline` top border, holding an attach icon, a pill-shaped `_surface` field, and a 44px coral send circle. The `TextField` strips all Material chrome — `isDense`, `InputBorder.none` — because the pill Container is the visual boundary; `textInputAction: TextInputAction.send` turns the keyboard's return key into a send button, and `onSubmitted: _send` means keyboard-send and the coral button (`_send(_controller.text)`) converge on the same method. Then two supporting classes: `_Msg` is a three-field const value type — `text`, `fromUser`, `time` — which is all a demo thread needs. `_MonogramPainter` draws the avatar with no image asset: a `drawCircle` disc in the agent's tint (`#6A4C93` purple here), then a `TextPainter` laying out the initial in white Manrope `w800` at `size.width * 0.46`, centred by subtracting the painter's measured width and height. Because it takes `initial` and `tint` as parameters, `shouldRepaint` compares both — a different agent gets a different avatar for free.

Full code

The complete, ready-to-paste source. Free to use in your projects — one click copies it all.

import 'package:flutter/material.dart';

/// StyleCart — Support chat.
///
/// A live support conversation: an agent header (painted monogram avatar +
/// online status), a scrollable thread of agent/user bubbles with a day
/// divider and an attached-order card, a "typing…" indicator, quick-reply
/// chips, and a composer row with attach + send. Tapping a quick reply or send
/// appends a user bubble locally.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. Painter-only graphics
/// (monogram avatar). Exposes callbacks only.
class EcomHelpChatScreen extends StatefulWidget {
  const EcomHelpChatScreen({super.key, this.onBack});

  final VoidCallback? onBack;

  @override
  State<EcomHelpChatScreen> createState() => _EcomHelpChatScreenState();
}

class _EcomHelpChatScreenState extends State<EcomHelpChatScreen> {
  static const String _font = 'Manrope';
  static const Color _canvas = Color(0xFFFFFFFF);
  static const Color _ink = Color(0xFF222222);
  static const Color _muted = Color(0xFF6A6A6A);
  static const Color _faint = Color(0xFFC1C1C1);
  static const Color _brand = Color(0xFFFF385C);
  static const Color _surface = Color(0xFFF2F2F2);
  static const Color _hairline = Color(0xFFEBEBEB);
  static const Color _success = Color(0xFF2E9E5B);

  static const List<String> _quickReplies = <String>[
    'Track my order',
    'Start a return',
    'Talk to a human',
  ];

  final List<_Msg> _messages = <_Msg>[
    const _Msg('Hi Maya 👋 I’m Asha from StyleCart support. How can I help '
        'you today?', false, '9:32 AM'),
    const _Msg('Hi! I have a question about order #SC-20488.', true, '9:33 AM'),
    const _Msg('Of course — I can see it right here. It’s out for delivery and '
        'should arrive by 6 PM today.', false, '9:33 AM'),
  ];

  final TextEditingController _controller = TextEditingController();
  final ScrollController _scroll = ScrollController();

  @override
  void dispose() {
    _controller.dispose();
    _scroll.dispose();
    super.dispose();
  }

  void _send(String text) {
    final String t = text.trim();
    if (t.isEmpty) return;
    setState(() {
      _messages.add(_Msg(t, true, 'Now'));
      _controller.clear();
    });
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_scroll.hasClients) {
        _scroll.animateTo(
          _scroll.position.maxScrollExtent,
          duration: const Duration(milliseconds: 280),
          curve: Curves.easeOut,
        );
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              _header(),
              const Divider(height: 1, color: _hairline),
              Expanded(
                child: ListView(
                  controller: _scroll,
                  padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
                  children: <Widget>[
                    _dayDivider('Today'),
                    _agentRow(_messages[0]),
                    _userBubble(_messages[1]),
                    _orderAttachment(),
                    for (int i = 2; i < _messages.length; i++)
                      _messages[i].fromUser
                          ? _userBubble(_messages[i])
                          : _agentRow(_messages[i]),
                    _typingRow(),
                  ],
                ),
              ),
              _quickReplyRow(),
              _composer(),
            ],
          ),
        ),
      ),
    );
  }

  Widget _header() {
    return Padding(
      padding: const EdgeInsets.fromLTRB(6, 4, 16, 8),
      child: Row(
        children: <Widget>[
          IconButton(
            onPressed: widget.onBack,
            icon: const Icon(Icons.arrow_back_rounded, size: 22, color: _ink),
          ),
          Stack(
            children: <Widget>[
              CustomPaint(
                size: const Size(40, 40),
                painter: _MonogramPainter('A', Color(0xFF6A4C93)),
              ),
              Positioned(
                right: 0,
                bottom: 0,
                child: Container(
                  width: 12,
                  height: 12,
                  decoration: BoxDecoration(
                    color: _success,
                    shape: BoxShape.circle,
                    border: Border.all(color: _canvas, width: 2),
                  ),
                ),
              ),
            ],
          ),
          const SizedBox(width: 12),
          const Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Text(
                  'Asha · StyleCart',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 15.5,
                    fontWeight: FontWeight.w800,
                    color: _ink,
                  ),
                ),
                SizedBox(height: 1),
                Text(
                  'Online · replies in ~2 min',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 11.5,
                    fontWeight: FontWeight.w600,
                    color: _success,
                  ),
                ),
              ],
            ),
          ),
          const Icon(Icons.more_vert_rounded, size: 22, color: _muted),
        ],
      ),
    );
  }

  Widget _dayDivider(String label) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 12),
      child: Center(
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
          decoration: BoxDecoration(
            color: _surface,
            borderRadius: BorderRadius.circular(9999),
          ),
          child: Text(
            label,
            style: const TextStyle(
              fontFamily: _font,
              fontSize: 11,
              fontWeight: FontWeight.w700,
              color: _muted,
            ),
          ),
        ),
      ),
    );
  }

  Widget _agentRow(_Msg m) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 10),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.end,
        children: <Widget>[
          CustomPaint(
            size: const Size(28, 28),
            painter: _MonogramPainter('A', Color(0xFF6A4C93)),
          ),
          const SizedBox(width: 8),
          Flexible(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Container(
                  padding: const EdgeInsets.symmetric(
                      horizontal: 14, vertical: 10),
                  decoration: const BoxDecoration(
                    color: _surface,
                    borderRadius: BorderRadius.only(
                      topLeft: Radius.circular(4),
                      topRight: Radius.circular(16),
                      bottomLeft: Radius.circular(16),
                      bottomRight: Radius.circular(16),
                    ),
                  ),
                  child: Text(
                    m.text,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 13.5,
                      fontWeight: FontWeight.w500,
                      height: 1.4,
                      color: _ink,
                    ),
                  ),
                ),
                Padding(
                  padding: const EdgeInsets.only(left: 6, top: 3),
                  child: Text(
                    m.time,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 10,
                      fontWeight: FontWeight.w600,
                      color: _faint,
                    ),
                  ),
                ),
              ],
            ),
          ),
          const SizedBox(width: 40),
        ],
      ),
    );
  }

  Widget _userBubble(_Msg m) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 10, left: 40),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.end,
        children: <Widget>[
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
            decoration: const BoxDecoration(
              color: _brand,
              borderRadius: BorderRadius.only(
                topLeft: Radius.circular(16),
                topRight: Radius.circular(4),
                bottomLeft: Radius.circular(16),
                bottomRight: Radius.circular(16),
              ),
            ),
            child: Text(
              m.text,
              style: const TextStyle(
                fontFamily: _font,
                fontSize: 13.5,
                fontWeight: FontWeight.w500,
                height: 1.4,
                color: Colors.white,
              ),
            ),
          ),
          Padding(
            padding: const EdgeInsets.only(right: 6, top: 3),
            child: Row(
              mainAxisSize: MainAxisSize.min,
              children: <Widget>[
                Text(
                  m.time,
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 10,
                    fontWeight: FontWeight.w600,
                    color: _faint,
                  ),
                ),
                const SizedBox(width: 3),
                const Icon(Icons.done_all_rounded, size: 13, color: _success),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _orderAttachment() {
    return Padding(
      padding: const EdgeInsets.only(bottom: 10, left: 40),
      child: Align(
        alignment: Alignment.centerRight,
        child: Container(
          width: 230,
          padding: const EdgeInsets.all(12),
          decoration: BoxDecoration(
            color: _canvas,
            borderRadius: BorderRadius.circular(14),
            border: Border.all(color: _hairline),
          ),
          child: Row(
            children: <Widget>[
              Container(
                width: 44,
                height: 44,
                decoration: BoxDecoration(
                  color: _surface,
                  borderRadius: BorderRadius.circular(10),
                ),
                child: const Icon(Icons.inventory_2_outlined,
                    size: 21, color: _muted),
              ),
              const SizedBox(width: 10),
              const Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Text(
                      '#SC-20488',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 13,
                        fontWeight: FontWeight.w800,
                        color: _ink,
                      ),
                    ),
                    SizedBox(height: 2),
                    Text(
                      '3 items · \$312',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 11.5,
                        fontWeight: FontWeight.w600,
                        color: _muted,
                      ),
                    ),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _typingRow() {
    return Padding(
      padding: const EdgeInsets.only(bottom: 6),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.end,
        children: <Widget>[
          CustomPaint(
            size: const Size(28, 28),
            painter: _MonogramPainter('A', Color(0xFF6A4C93)),
          ),
          const SizedBox(width: 8),
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
            decoration: const BoxDecoration(
              color: _surface,
              borderRadius: BorderRadius.only(
                topLeft: Radius.circular(4),
                topRight: Radius.circular(16),
                bottomLeft: Radius.circular(16),
                bottomRight: Radius.circular(16),
              ),
            ),
            child: Row(
              mainAxisSize: MainAxisSize.min,
              children: <Widget>[
                _dot(),
                const SizedBox(width: 4),
                _dot(),
                const SizedBox(width: 4),
                _dot(),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _dot() => Container(
        width: 6,
        height: 6,
        decoration: const BoxDecoration(color: _faint, shape: BoxShape.circle),
      );

  Widget _quickReplyRow() {
    return SizedBox(
      height: 44,
      child: ListView.separated(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
        itemCount: _quickReplies.length,
        separatorBuilder: (BuildContext _, int i) => const SizedBox(width: 8),
        itemBuilder: (BuildContext _, int i) {
          return GestureDetector(
            onTap: () => _send(_quickReplies[i]),
            child: Container(
              padding: const EdgeInsets.symmetric(horizontal: 14),
              alignment: Alignment.center,
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(9999),
                border: Border.all(color: _brand.withValues(alpha: 0.4)),
              ),
              child: Text(
                _quickReplies[i],
                style: const TextStyle(
                  fontFamily: _font,
                  fontSize: 12.5,
                  fontWeight: FontWeight.w800,
                  color: _brand,
                ),
              ),
            ),
          );
        },
      ),
    );
  }

  Widget _composer() {
    return Container(
      decoration: const BoxDecoration(
        color: _canvas,
        border: Border(top: BorderSide(color: _hairline)),
      ),
      padding: const EdgeInsets.fromLTRB(12, 10, 12, 12),
      child: Row(
        children: <Widget>[
          const Icon(Icons.add_circle_outline_rounded,
              size: 26, color: _muted),
          const SizedBox(width: 10),
          Expanded(
            child: Container(
              padding: const EdgeInsets.symmetric(horizontal: 14),
              decoration: BoxDecoration(
                color: _surface,
                borderRadius: BorderRadius.circular(9999),
              ),
              child: TextField(
                controller: _controller,
                textInputAction: TextInputAction.send,
                onSubmitted: _send,
                style: const TextStyle(
                  fontFamily: _font,
                  fontSize: 14,
                  fontWeight: FontWeight.w600,
                  color: _ink,
                ),
                decoration: const InputDecoration(
                  isDense: true,
                  border: InputBorder.none,
                  hintText: 'Message…',
                  hintStyle: TextStyle(
                    fontFamily: _font,
                    fontSize: 14,
                    fontWeight: FontWeight.w600,
                    color: _faint,
                  ),
                  contentPadding: EdgeInsets.symmetric(vertical: 12),
                ),
              ),
            ),
          ),
          const SizedBox(width: 10),
          GestureDetector(
            onTap: () => _send(_controller.text),
            child: Container(
              width: 44,
              height: 44,
              decoration: const BoxDecoration(
                color: _brand,
                shape: BoxShape.circle,
              ),
              child: const Icon(Icons.arrow_upward_rounded,
                  size: 22, color: Colors.white),
            ),
          ),
        ],
      ),
    );
  }
}

class _Msg {
  const _Msg(this.text, this.fromUser, this.time);
  final String text;
  final bool fromUser;
  final String time;
}

/// A painted circular monogram avatar (tinted disc + white initial).
class _MonogramPainter extends CustomPainter {
  _MonogramPainter(this.initial, this.tint);
  final String initial;
  final Color tint;

  @override
  void paint(Canvas canvas, Size size) {
    final Offset c = size.center(Offset.zero);
    canvas.drawCircle(c, size.width / 2, Paint()..color = tint);
    final TextPainter tp = TextPainter(
      text: TextSpan(
        text: initial,
        style: TextStyle(
          fontFamily: 'Manrope',
          fontSize: size.width * 0.46,
          fontWeight: FontWeight.w800,
          color: Colors.white,
        ),
      ),
      textDirection: TextDirection.ltr,
    )..layout();
    tp.paint(canvas,
        Offset((size.width - tp.width) / 2, (size.height - tp.height) / 2));
  }

  @override
  bool shouldRepaint(_MonogramPainter old) =>
      old.initial != initial || old.tint != tint;
}

Plus bundled 5 binary assets (fonts/images). The CLI and MCP install those for you automatically.

Two faster ways to add it

Copy-paste works, but you can skip it entirely.

1. FlutterKit CLI

One command drops this screen — and its fonts — straight into your project.

$ flutterkit add ecom-help-chat

2. AI agent (MCP)

Connect FlutterKit's MCP server in Claude or Cursor and just ask your agent to install ecom-help-chat — it fetches and writes the files for you.

FAQ

Can I use this support chat screen in a commercial app?

Yes. FlutterKit screens are free, including commercial use — copy the code from this page, install it with the CLI command, or pull it via MCP, and ship it. No licence key, no attribution, no sign-up.

Does this screen need a chat SDK or any packages?

No. It is pure Flutter — the thread, bubbles, typing dots, and composer are all built from Column, ListView, Container, and one small CustomPainter. The only asset is the bundled Manrope font family, referenced as `fontFamily: 'Manrope'` throughout, which ships with the screen's install.

Why does _send scroll inside addPostFrameCallback instead of right after setState?

Because when `setState` returns, the new bubble exists in the message list but has not been laid out, so `maxScrollExtent` still describes the old, shorter thread — scrolling immediately would land one message short. The post-frame callback runs after layout, when the extent includes the new bubble, and `hasClients` guards the edge case where the list is not attached yet.

The first two messages and the order card are placed by hand in the ListView — how do I make the thread fully dynamic?

The build hardcodes `_messages[0]`, `_messages[1]`, and `_orderAttachment()` so the demo card sits at a fixed point, then a collection-for renders index 2 onward. For a real backend, add a kind field to `_Msg` (text vs order attachment), render the entire list from one loop over `_messages`, and replace the seeded list with your stream's data — `_send` and the bubble builders need no changes.

Which Flutter version does this require?

Flutter 3.22 or newer: the quick-reply chip borders use `_brand.withValues(alpha: 0.4)`, and the constructor uses the `super.key` shorthand. On an older SDK, swap `withValues(alpha: 0.4)` for `withOpacity(0.4)` and expand the constructor to `{Key? key} : super(key: key)`.

Related screens