E-commerce39 views

How to Build a Search History Screen in Flutter (Full Code + Preview)

Most shopping apps throw away what you typed the moment you leave search, so the next visit starts from zero. This tutorial builds StyleCart's search-history screen in Flutter: two recency groups labelled Today and Earlier, rows that carry the term and its result count and re-run on tap, a close button that removes one entry from its own list, a brand Clear all action in the header, and a painted clock-and-magnifier empty state when the log runs dry.

Search History — E-commerce Flutter UI screen
Live preview — Search History, built in pure Flutter.

What you'll build

  • A recency-grouped log that emits TODAY and EARLIER labels only when that group still holds entries
  • History rows that re-run the term on tap and carry a result count as a second line
  • A per-row close button that removes one record from whichever list owns it
  • A Clear all header action that empties both lists and hides itself afterwards
  • A hand-painted clock-and-magnifier empty state with a Start browsing button

Step-by-step build

1

Create the file

Add a new file at lib/ecom_search_recent/ecom_search_recent_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.

A stateful screen that owns its log

ecom_search_recent_screen.dart
import 'package:flutter/material.dart';

/// StyleCart — Search History.
///
/// The full recent-search log grouped by recency, each row re-runnable or
/// removable, with a clear-all action that wipes to a painted empty state.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. The empty mark is a
/// CustomPainter (no emoji glyph). Exposes callbacks only; the log is internal.
class EcomSearchRecentScreen extends StatefulWidget {
  const EcomSearchRecentScreen({
    super.key,
    this.onBack,
    this.onTerm,
    this.onCleared,
    this.onBrowse,
  });

  final VoidCallback? onBack;
  final ValueChanged<String>? onTerm;
  final VoidCallback? onCleared;
  final VoidCallback? onBrowse;

  @override
  State<EcomSearchRecentScreen> createState() =>
      _EcomSearchRecentScreenState();
}

class _EcomSearchRecentScreenState extends State<EcomSearchRecentScreen> {
  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 _hairline = Color(0xFFEBEBEB);

`EcomSearchRecentScreen` is a `StatefulWidget` because the history is genuinely mutable — rows disappear as you dismiss them and the whole log can be wiped. Four nullable callbacks keep the widget host-agnostic: `onBack`, `onTerm` (a `ValueChanged<String>` so the parent receives the actual query to re-run), `onCleared`, and `onBrowse`. The palette is declared as `static const` fields on the state class, which is where the tone gets set: `_ink` #222222 for terms, `_muted` #6A6A6A for counts and section labels, `_faint` #C1C1C1 for both icons, and `_brand` coral #FF385C reserved for the two destructive-or-primary moments only.

Two lists, an emptiness getter, and clear-all

ecom_search_recent_screen.dart
  final List<_H> _today = <_H>[
    const _H('Linen blazer', '2,140 results'),
    const _H('White sneakers', '980 results'),
    const _H('Slip dress', '640 results'),
  ];
  final List<_H> _earlier = <_H>[
    const _H('Tote bag', '1,210 results'),
    const _H('Wide-leg denim', '540 results'),
    const _H('Wool overcoat', '310 results'),
    const _H('Gold hoops', '420 results'),
  ];

  bool get _empty => _today.isEmpty && _earlier.isEmpty;

  void _clearAll() {
    setState(() {
      _today.clear();
      _earlier.clear();
    });
    widget.onCleared?.call();
  }

Recency grouping is modelled as two plain `List<_H>` fields, `_today` and `_earlier`, rather than one list with a timestamp that has to be bucketed at render time. That keeps grouping a data concern, and it is why removal later can be a one-liner. `bool get _empty => _today.isEmpty && _earlier.isEmpty` is a derived getter, so nothing has to be kept in sync — the header action, the body switch and the empty state all read the same expression. `_clearAll` empties both lists inside a single `setState` and then fires `widget.onCleared?.call()` after the rebuild, so the UI is already truthful by the time your backend hears about it.

The build: theme, safe area, and a two-way body

ecom_search_recent_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: _empty ? _emptyState() : _list()),
            ],
          ),
        ),
      ),
    );
  }

The screen wraps itself in `Theme(data: ThemeData.light(useMaterial3: true))` so it renders identically whether or not the surrounding app is Material 3 or dark — a drop-in requirement rather than a style choice. Inside, a `Scaffold` on `_canvas` white holds a `SafeArea` `Column` of exactly three things: the header, a one-pixel `Divider(height: 1, color: _hairline)` that pins the title to the top and lets the list scroll under it, and `Expanded(child: _empty ? _emptyState() : _list())`. Putting the ternary there means both states inherit the same header and hairline, so clearing the log never causes the top of the screen to jump.

A header whose action disappears with the data

ecom_search_recent_screen.dart
  Widget _header() {
    return Padding(
      padding: const EdgeInsets.fromLTRB(8, 4, 20, 4),
      child: Row(
        children: <Widget>[
          IconButton(
            onPressed: widget.onBack,
            icon: const Icon(Icons.arrow_back_ios_new_rounded,
                size: 20, color: _ink),
          ),
          const Expanded(
            child: Text(
              'Search history',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 19,
                fontWeight: FontWeight.w800,
                letterSpacing: -0.3,
                color: _ink,
              ),
            ),
          ),
          if (!_empty)
            GestureDetector(
              onTap: _clearAll,
              child: const Text(
                'Clear all',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 13.5,
                  fontWeight: FontWeight.w700,
                  color: _brand,
                ),
              ),
            ),
        ],
      ),
    );
  }

The header is a `Row` inside asymmetric padding — `fromLTRB(8, 4, 20, 4)` — because the leading `IconButton` brings its own 48px touch target and padding, while the trailing text needs a real 20px margin from the edge. The title sits in `Expanded` at 19px `w800` with `letterSpacing: -0.3`, so it absorbs all leftover width and pushes the action right without a `Spacer`. The key line is `if (!_empty)`: Clear all is only built while there is something to clear, so the coral never sits there as a dead control after the log is emptied. It is a `GestureDetector` on bare text rather than a `TextButton`, keeping the action visually light despite being destructive.

Grouping with spread operators

ecom_search_recent_screen.dart
  Widget _list() {
    return ListView(
      padding: const EdgeInsets.only(bottom: 24),
      children: <Widget>[
        if (_today.isNotEmpty) ...<Widget>[
          _label('Today'),
          ..._today.map((_H h) => _row(h, _today)),
        ],
        if (_earlier.isNotEmpty) ...<Widget>[
          _label('Earlier'),
          ..._earlier.map((_H h) => _row(h, _earlier)),
        ],
      ],
    );
  }

  Widget _label(String t) {
    return Padding(
      padding: const EdgeInsets.fromLTRB(20, 18, 20, 8),
      child: Text(
        t.toUpperCase(),
        style: const TextStyle(
          fontFamily: _font,
          fontSize: 11,
          fontWeight: FontWeight.w800,
          letterSpacing: 0.8,
          color: _muted,
        ),
      ),
    );
  }

`_list` is a plain `ListView` with `padding: EdgeInsets.only(bottom: 24)` so the last row clears the home indicator. Each group is a collection-if guarding a spread — `if (_today.isNotEmpty) ...<Widget>[_label('Today'), ..._today.map(...)]` — which is what makes the section headers self-managing: dismiss the final row in Today and the TODAY label vanishes with it, no separate bookkeeping. `_row(h, _today)` passes the owning list along with the record, and that argument is what lets a row delete itself later. `_label` uppercases in Dart with `t.toUpperCase()` at 11px `w800` with `letterSpacing: 0.8`, positive tracking being the fix that stops small all-caps text from looking cramped.

A row that re-runs on tap and deletes on the close icon

ecom_search_recent_screen.dart
  Widget _row(_H h, List<_H> group) {
    return InkWell(
      onTap: () => widget.onTerm?.call(h.term),
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 13),
        child: Row(
          children: <Widget>[
            const Icon(Icons.history_rounded, size: 22, color: _faint),
            const SizedBox(width: 14),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    h.term,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 15.5,
                      fontWeight: FontWeight.w700,
                      color: _ink,
                    ),
                  ),
                  const SizedBox(height: 2),
                  Text(
                    h.count,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 12.5,
                      fontWeight: FontWeight.w500,
                      color: _muted,
                    ),
                  ),
                ],
              ),
            ),
            GestureDetector(
              onTap: () => setState(() => group.remove(h)),
              child: const Padding(
                padding: EdgeInsets.all(4),
                child: Icon(Icons.close_rounded, size: 20, color: _faint),
              ),
            ),
          ],
        ),
      ),
    );
  }

Each row carries two independent gestures. The outer `InkWell` calls `widget.onTerm?.call(h.term)` — the primary action, since re-running a past search is why history exists — and gives the whole 20/13 padded strip a ripple. Nested inside is a `GestureDetector` whose `onTap` is `setState(() => group.remove(h))`; because the row was handed its owning list, removal needs no index, no id and no search. That inner `Padding(EdgeInsets.all(4))` around a 20px close icon exists purely to enlarge its hit area so the delete tap does not bleed into the re-run tap. The term at 15.5px `w700` sits above the count at 12.5px `_muted` in an `Expanded` Column, so a long query truncates instead of shoving the close button off-screen.

The empty state and the record type

ecom_search_recent_screen.dart
  Widget _emptyState() {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(40),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            SizedBox(
              width: 96,
              height: 96,
              child: CustomPaint(painter: _HistoryEmptyPainter()),
            ),
            const SizedBox(height: 22),
            const Text(
              'No search history',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 19,
                fontWeight: FontWeight.w800,
                color: _ink,
              ),
            ),
            const SizedBox(height: 8),
            const Text(
              'Searches you make will show up here so you can\nre-run them in a tap.',
              textAlign: TextAlign.center,
              style: TextStyle(
                fontFamily: _font,
                fontSize: 14,
                fontWeight: FontWeight.w500,
                height: 1.4,
                color: _muted,
              ),
            ),
            const SizedBox(height: 26),
            SizedBox(
              height: 52,
              child: FilledButton(
                onPressed: widget.onBrowse,
                style: FilledButton.styleFrom(
                  backgroundColor: _brand,
                  foregroundColor: _canvas,
                  shape: const StadiumBorder(),
                  padding: const EdgeInsets.symmetric(horizontal: 32),
                  textStyle: const TextStyle(
                    fontFamily: _font,
                    fontSize: 15,
                    fontWeight: FontWeight.w800,
                  ),
                ),
                child: const Text('Start browsing'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _H {
  const _H(this.term, this.count);
  final String term;
  final String count;
}

`_emptyState` centres a `Column` with `mainAxisSize: MainAxisSize.min` inside 40px padding, so the block stays optically centred rather than stretching. The 96x96 `CustomPaint` leads, then 'No search history' at 19px `w800`, then a subtitle with a hard `\n` before 're-run them in a tap' — a manual break chosen over natural wrapping to guarantee two balanced lines. The copy explains the mechanic instead of apologising, and the coral `FilledButton` with a `StadiumBorder` sends the reader to `onBrowse`, because with no history there is nothing to search from here. `_H` at the bottom is a two-field immutable record holding `term` and `count` — small enough that a full model class would be overhead.

Painting the clock-and-magnifier mark

ecom_search_recent_screen.dart
/// Paints a clock with a magnifier — the empty-history mark.
class _HistoryEmptyPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    const Color brand = Color(0xFFFF385C);
    final Offset c = Offset(size.width * 0.44, size.height * 0.44);
    final double r = size.width * 0.34;

    canvas.drawCircle(Offset(size.width / 2, size.height / 2),
        size.width / 2, Paint()..color = brand.withValues(alpha: 0.08));

    final Paint stroke = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = 3
      ..strokeCap = StrokeCap.round
      ..color = brand;
    canvas.drawCircle(c, r, stroke);
    // Clock hands.
    canvas.drawLine(c, c + Offset(0, -r * 0.55), stroke);
    canvas.drawLine(c, c + Offset(r * 0.42, r * 0.1), stroke);

    // Small magnifier lower-right.
    final Offset m = Offset(size.width * 0.74, size.height * 0.74);
    final double mr = size.width * 0.12;
    final Paint thin = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2.6
      ..strokeCap = StrokeCap.round
      ..color = brand.withValues(alpha: 0.6);
    canvas.drawCircle(m, mr, thin);
    canvas.drawLine(
      m + Offset(mr * 0.7, mr * 0.7),
      m + Offset(mr * 1.5, mr * 1.5),
      thin,
    );
  }

  @override
  bool shouldRepaint(_HistoryEmptyPainter oldDelegate) => false;
}

`_HistoryEmptyPainter` draws the mark from primitives, so there is no asset and no `flutter_svg` dependency. A full-bleed disc at `brand.withValues(alpha: 0.08)` gives the soft coral ground; on top, a stroked clock centred slightly up-left at `(0.44, 0.44)` with radius `0.34 * width`, its hands drawn as two `drawLine` calls offset from the same centre — one straight up at `-r * 0.55`, one to the lower right — reading as a time near two o'clock. The magnifier at `(0.74, 0.74)` uses a thinner 2.6px stroke at 60% alpha so it recedes behind the clock rather than competing with it. Every coordinate is a fraction of `size`, so the badge is resolution-independent, and `shouldRepaint` returns `false` because nothing here depends on state.

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 — Search History.
///
/// The full recent-search log grouped by recency, each row re-runnable or
/// removable, with a clear-all action that wipes to a painted empty state.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. The empty mark is a
/// CustomPainter (no emoji glyph). Exposes callbacks only; the log is internal.
class EcomSearchRecentScreen extends StatefulWidget {
  const EcomSearchRecentScreen({
    super.key,
    this.onBack,
    this.onTerm,
    this.onCleared,
    this.onBrowse,
  });

  final VoidCallback? onBack;
  final ValueChanged<String>? onTerm;
  final VoidCallback? onCleared;
  final VoidCallback? onBrowse;

  @override
  State<EcomSearchRecentScreen> createState() =>
      _EcomSearchRecentScreenState();
}

class _EcomSearchRecentScreenState extends State<EcomSearchRecentScreen> {
  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 _hairline = Color(0xFFEBEBEB);

  final List<_H> _today = <_H>[
    const _H('Linen blazer', '2,140 results'),
    const _H('White sneakers', '980 results'),
    const _H('Slip dress', '640 results'),
  ];
  final List<_H> _earlier = <_H>[
    const _H('Tote bag', '1,210 results'),
    const _H('Wide-leg denim', '540 results'),
    const _H('Wool overcoat', '310 results'),
    const _H('Gold hoops', '420 results'),
  ];

  bool get _empty => _today.isEmpty && _earlier.isEmpty;

  void _clearAll() {
    setState(() {
      _today.clear();
      _earlier.clear();
    });
    widget.onCleared?.call();
  }

  @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: _empty ? _emptyState() : _list()),
            ],
          ),
        ),
      ),
    );
  }

  Widget _header() {
    return Padding(
      padding: const EdgeInsets.fromLTRB(8, 4, 20, 4),
      child: Row(
        children: <Widget>[
          IconButton(
            onPressed: widget.onBack,
            icon: const Icon(Icons.arrow_back_ios_new_rounded,
                size: 20, color: _ink),
          ),
          const Expanded(
            child: Text(
              'Search history',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 19,
                fontWeight: FontWeight.w800,
                letterSpacing: -0.3,
                color: _ink,
              ),
            ),
          ),
          if (!_empty)
            GestureDetector(
              onTap: _clearAll,
              child: const Text(
                'Clear all',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 13.5,
                  fontWeight: FontWeight.w700,
                  color: _brand,
                ),
              ),
            ),
        ],
      ),
    );
  }

  Widget _list() {
    return ListView(
      padding: const EdgeInsets.only(bottom: 24),
      children: <Widget>[
        if (_today.isNotEmpty) ...<Widget>[
          _label('Today'),
          ..._today.map((_H h) => _row(h, _today)),
        ],
        if (_earlier.isNotEmpty) ...<Widget>[
          _label('Earlier'),
          ..._earlier.map((_H h) => _row(h, _earlier)),
        ],
      ],
    );
  }

  Widget _label(String t) {
    return Padding(
      padding: const EdgeInsets.fromLTRB(20, 18, 20, 8),
      child: Text(
        t.toUpperCase(),
        style: const TextStyle(
          fontFamily: _font,
          fontSize: 11,
          fontWeight: FontWeight.w800,
          letterSpacing: 0.8,
          color: _muted,
        ),
      ),
    );
  }

  Widget _row(_H h, List<_H> group) {
    return InkWell(
      onTap: () => widget.onTerm?.call(h.term),
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 13),
        child: Row(
          children: <Widget>[
            const Icon(Icons.history_rounded, size: 22, color: _faint),
            const SizedBox(width: 14),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    h.term,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 15.5,
                      fontWeight: FontWeight.w700,
                      color: _ink,
                    ),
                  ),
                  const SizedBox(height: 2),
                  Text(
                    h.count,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 12.5,
                      fontWeight: FontWeight.w500,
                      color: _muted,
                    ),
                  ),
                ],
              ),
            ),
            GestureDetector(
              onTap: () => setState(() => group.remove(h)),
              child: const Padding(
                padding: EdgeInsets.all(4),
                child: Icon(Icons.close_rounded, size: 20, color: _faint),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _emptyState() {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(40),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            SizedBox(
              width: 96,
              height: 96,
              child: CustomPaint(painter: _HistoryEmptyPainter()),
            ),
            const SizedBox(height: 22),
            const Text(
              'No search history',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 19,
                fontWeight: FontWeight.w800,
                color: _ink,
              ),
            ),
            const SizedBox(height: 8),
            const Text(
              'Searches you make will show up here so you can\nre-run them in a tap.',
              textAlign: TextAlign.center,
              style: TextStyle(
                fontFamily: _font,
                fontSize: 14,
                fontWeight: FontWeight.w500,
                height: 1.4,
                color: _muted,
              ),
            ),
            const SizedBox(height: 26),
            SizedBox(
              height: 52,
              child: FilledButton(
                onPressed: widget.onBrowse,
                style: FilledButton.styleFrom(
                  backgroundColor: _brand,
                  foregroundColor: _canvas,
                  shape: const StadiumBorder(),
                  padding: const EdgeInsets.symmetric(horizontal: 32),
                  textStyle: const TextStyle(
                    fontFamily: _font,
                    fontSize: 15,
                    fontWeight: FontWeight.w800,
                  ),
                ),
                child: const Text('Start browsing'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _H {
  const _H(this.term, this.count);
  final String term;
  final String count;
}

/// Paints a clock with a magnifier — the empty-history mark.
class _HistoryEmptyPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    const Color brand = Color(0xFFFF385C);
    final Offset c = Offset(size.width * 0.44, size.height * 0.44);
    final double r = size.width * 0.34;

    canvas.drawCircle(Offset(size.width / 2, size.height / 2),
        size.width / 2, Paint()..color = brand.withValues(alpha: 0.08));

    final Paint stroke = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = 3
      ..strokeCap = StrokeCap.round
      ..color = brand;
    canvas.drawCircle(c, r, stroke);
    // Clock hands.
    canvas.drawLine(c, c + Offset(0, -r * 0.55), stroke);
    canvas.drawLine(c, c + Offset(r * 0.42, r * 0.1), stroke);

    // Small magnifier lower-right.
    final Offset m = Offset(size.width * 0.74, size.height * 0.74);
    final double mr = size.width * 0.12;
    final Paint thin = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2.6
      ..strokeCap = StrokeCap.round
      ..color = brand.withValues(alpha: 0.6);
    canvas.drawCircle(m, mr, thin);
    canvas.drawLine(
      m + Offset(mr * 0.7, mr * 0.7),
      m + Offset(mr * 1.5, mr * 1.5),
      thin,
    );
  }

  @override
  bool shouldRepaint(_HistoryEmptyPainter oldDelegate) => false;
}

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-search-recent

2. AI agent (MCP)

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

FAQ

Is this search history screen free to use in a commercial app?

Yes. FlutterKit is free with no paid tier, no licence key and no sign-up. Copy the Dart from this page, install it with the CLI, or pull it through MCP, and ship it in a store app. Attribution is not required.

How do I make the history survive an app restart?

Add `shared_preferences`, encode the two lists as JSON in `_clearAll` and in the row's remove handler, and load them in `initState` before the first build. Because `_today` and `_earlier` are ordinary lists on the state class, persistence is a read at startup and a write after each mutation — no change to any widget.

Do I need extra packages or a font file?

No packages — it is `package:flutter/material.dart` only, and the empty mark is a `CustomPainter` rather than an image. It does reference `fontFamily: 'Manrope'`, so bundle Manrope in `pubspec.yaml` or delete the `_font` constant to fall back to your app's default typeface.

How do I bucket real timestamps into Today and Earlier?

Keep one list of records with a `DateTime`, then compute the two groups in `build` by comparing each date against today's midnight. The rest of the screen is unchanged — `_row` only needs the term, the count and whichever list it should remove itself from, so the same spread-and-collection-if pattern still hides an empty section.

Which Flutter version does this need?

Flutter 3.22 or newer, because the painter calls `Color.withValues(alpha: 0.08)` and the constructor uses the `super.key` parameter. On an older SDK swap those for `withOpacity(0.08)` and the `{Key? key, ...}) : super(key: key)` form; nothing else in the file is version-sensitive.

Related screens