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.

Watch the Flutter UI walkthrough

A short screen recording of Search History running, if you'd rather see it before you read the code. The written tutorial below covers everything in it.

Can't see the video? Watch it on YouTube.

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