Fliin
On this page

Components / data-display

Badge

Flags an unread count, a short word or a status dot on the corner of an icon, an avatar or a tab, capped at 99+ and read out with what it marks.

16 of 16 variants pass
Badge, basic state
Platform
Brightness
Text scale
Direction

Import

In a Flutter app where fliin init has run:

fliin add badgeAlso installs: core

Examples

Basic

Three badges on avatars: a count, a short text, and a dot that marks a status at the bottom end. Each badge sits over its child.

Badge, basic state
import 'package:your_app/ui/fliin/components/avatar/fl_avatar.dart';
import 'package:your_app/ui/fliin/components/badge/fl_badge.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    final space = FlScope.of(context).space;
    return Wrap(
      alignment: WrapAlignment.center,
      crossAxisAlignment: WrapCrossAlignment.center,
      spacing: space.sm,
      runSpacing: space.sm,
      children: [
        FlBadge.count(count: 5, color: FlBadgeColor.danger, size: FlBadgeSize.sm, child: _avatar('JD')),
        FlBadge.text(text: 'New', color: FlBadgeColor.accent, size: FlBadgeSize.sm, child: _avatar('AB')),
        FlBadge.dot(
          semanticLabel: 'online',
          color: FlBadgeColor.success,
          placement: FlBadgePlacement.bottomEnd,
          child: _avatar('CD'),
        ),
      ],
    );
  }
}

FlAvatar _avatar(String initials) =>
    FlAvatar(initials: initials, semanticLabel: initials, size: FlAvatarSize.sm, color: FlAvatarColor.neutral);

Colors

Five colors: neutral by default, accent for what is new, and success, warning and danger for a status.

Badge, colors state
import 'package:your_app/ui/fliin/components/avatar/fl_avatar.dart';
import 'package:your_app/ui/fliin/components/badge/fl_badge.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return Wrap(
      alignment: WrapAlignment.center,
      spacing: FlScope.of(context).space.lg,
      runSpacing: FlScope.of(context).space.lg,
      children: [
        for (final (color, name) in const [
          (FlBadgeColor.neutral, 'neutral'),
          (FlBadgeColor.accent, 'accent'),
          (FlBadgeColor.success, 'success'),
          (FlBadgeColor.warning, 'warning'),
          (FlBadgeColor.danger, 'danger'),
        ])
          FlBadge.dot(
            semanticLabel: name,
            color: color,
            child: const FlAvatar(
              image: AssetImage('images/people/1.jpg'),
              initials: 'KW',
              semanticLabel: 'Kate Wilson',
            ),
          ),
      ],
    );
  }
}

With content

What a badge can carry: a count, a short word, a capped count, or an icon named by semanticLabel.

Badge, with-content state
import 'package:your_app/ui/fliin/components/avatar/fl_avatar.dart';
import 'package:your_app/ui/fliin/components/badge/fl_badge.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:your_app/ui/fliin/icons/fl_icons.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return Wrap(
      alignment: WrapAlignment.center,
      spacing: FlScope.of(context).space.lg,
      runSpacing: FlScope.of(context).space.lg,
      children: [
        FlBadge.count(count: 5, color: FlBadgeColor.danger, size: FlBadgeSize.sm, child: _avatar()),
        FlBadge.text(text: 'New', color: FlBadgeColor.danger, size: FlBadgeSize.sm, child: _avatar()),
        FlBadge.count(count: 120, color: FlBadgeColor.danger, size: FlBadgeSize.sm, child: _avatar()),
        FlBadge.icon(
          icon: FlIcon(FlIcons.bell),
          semanticLabel: 'notifications on',
          color: FlBadgeColor.accent,
          size: FlBadgeSize.sm,
          child: _avatar(),
        ),
      ],
    );
  }
}

FlAvatar _avatar() =>
    const FlAvatar(image: AssetImage('images/people/1.jpg'), initials: 'KW', semanticLabel: 'Kate Wilson');

Sizes

Every FlBadgeSize, on a count past 99 that shows "99+".

Badge, sizes state
import 'package:your_app/ui/fliin/components/avatar/fl_avatar.dart';
import 'package:your_app/ui/fliin/components/badge/fl_badge.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    final space = FlScope.of(context).space;
    return Wrap(
      alignment: WrapAlignment.center,
      crossAxisAlignment: WrapCrossAlignment.center,
      spacing: space.sm,
      runSpacing: space.sm,
      children: [
        for (final size in FlBadgeSize.values)
          FlBadge.count(count: 120, color: FlBadgeColor.danger, size: size, child: _avatar('JD')),
      ],
    );
  }
}

FlAvatar _avatar(String initials) =>
    FlAvatar(initials: initials, semanticLabel: initials, size: FlAvatarSize.sm, color: FlAvatarColor.neutral);

Variants

Every FlBadgeVariant in every FlBadgeColor: a row per variant, from the most to the least emphasis.

Badge, variants state
import 'package:your_app/ui/fliin/components/avatar/fl_avatar.dart';
import 'package:your_app/ui/fliin/components/badge/fl_badge.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    final space = FlScope.of(context).space;
    return Column(
      mainAxisSize: MainAxisSize.min,
      spacing: space.md,
      children: [
        for (final variant in FlBadgeVariant.values)
          Wrap(
            alignment: WrapAlignment.center,
            crossAxisAlignment: WrapCrossAlignment.center,
            spacing: space.sm,
            runSpacing: space.sm,
            children: [
              for (final color in FlBadgeColor.values)
                FlBadge.count(count: 5, variant: variant, color: color, size: FlBadgeSize.sm, child: _avatar('JD')),
            ],
          ),
      ],
    );
  }
}

FlAvatar _avatar(String initials) =>
    FlAvatar(initials: initials, semanticLabel: initials, size: FlAvatarSize.sm, color: FlAvatarColor.neutral);

Count

A count of unread messages on an icon tile; semanticLabel says what it counts.

Badge, count state
import 'package:your_app/ui/fliin/components/badge/fl_badge.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return Wrap(alignment: WrapAlignment.center, children: [_inbox(context, 3)]);
  }
}

/// An icon tile named "Messages", marked with [count] unread.
Widget _inbox(BuildContext context, int count) {
  final theme = FlScope.of(context);
  return FlBadge.count(
    count: count,
    color: FlBadgeColor.danger,
    size: FlBadgeSize.sm,
    semanticLabel: '$count unread',
    child: Semantics(
      label: 'Messages',
      child: SizedBox.square(
        dimension: theme.space.xs * 10,
        child: DecoratedBox(
          decoration: ShapeDecoration(
            color: theme.colors.control,
            shape: FlContinuousBorder(radius: theme.shape.radiusXl),
          ),
        ),
      ),
    ),
  );
}

Overflow

Past 99, the count reads "99+" and keeps its width; the screen reader still hears the exact number.

Badge, overflow state
import 'package:your_app/ui/fliin/components/badge/fl_badge.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return Wrap(alignment: WrapAlignment.center, children: [_inbox(context, 128)]);
  }
}

/// An icon tile named "Messages", marked with [count] unread.
Widget _inbox(BuildContext context, int count) {
  final theme = FlScope.of(context);
  return FlBadge.count(
    count: count,
    color: FlBadgeColor.danger,
    size: FlBadgeSize.sm,
    semanticLabel: '$count unread',
    child: Semantics(
      label: 'Messages',
      child: SizedBox.square(
        dimension: theme.space.xs * 10,
        child: DecoratedBox(
          decoration: ShapeDecoration(
            color: theme.colors.control,
            shape: FlContinuousBorder(radius: theme.shape.radiusXl),
          ),
        ),
      ),
    ),
  );
}

Text

A short word instead of a number, such as "New", with FlBadge.text.

Badge, text state
import 'package:your_app/ui/fliin/components/avatar/fl_avatar.dart';
import 'package:your_app/ui/fliin/components/badge/fl_badge.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return const Wrap(
      alignment: WrapAlignment.center,
      children: [
        FlBadge.text(
          text: 'New',
          color: FlBadgeColor.accent,
          size: FlBadgeSize.sm,
          child: FlAvatar(initials: 'AB', semanticLabel: 'AB', size: FlAvatarSize.sm, color: FlAvatarColor.neutral),
        ),
      ],
    );
  }
}

Dot

A dot without a number marks a status, such as online. placement puts it on any corner of its child, in the reading direction.

Badge, dot state
import 'package:your_app/ui/fliin/components/avatar/fl_avatar.dart';
import 'package:your_app/ui/fliin/components/badge/fl_badge.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    final space = FlScope.of(context).space;
    return Wrap(
      alignment: WrapAlignment.center,
      crossAxisAlignment: WrapCrossAlignment.center,
      spacing: space.sm,
      runSpacing: space.sm,
      children: [
        for (final placement in FlBadgePlacement.values)
          FlBadge.dot(semanticLabel: 'online', color: FlBadgeColor.success, placement: placement, child: _avatar('JD')),
      ],
    );
  }
}

FlAvatar _avatar(String initials) =>
    FlAvatar(initials: initials, semanticLabel: initials, size: FlAvatarSize.sm, color: FlAvatarColor.neutral);

Usage

The code the reference images render:

final example = ColoredBox(
  color: FlScope.of(context).colors.surface,
  child: Padding(
    padding: EdgeInsetsDirectional.all(FlScope.of(context).space.lg),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      spacing: FlScope.of(context).space.sm,
      children: [
        for (final v in FlBadgeVariant.values)
          Padding(
            padding: EdgeInsetsDirectional.symmetric(
              vertical: FlScope.of(context).space.sm,
            ),
            child: Wrap(
              spacing: FlScope.of(context).space.lg,
              runSpacing: FlScope.of(context).space.lg,
              children: [
                for (final c in FlBadgeColor.values)
                  FlBadge.count(
                    count: 5,
                    semanticLabel: '5 unread',
                    variant: v,
                    color: c,
                    size: FlBadgeSize.sm,
                    child: Semantics(
                      label: 'Messages',
                      child: SizedBox.square(
                        dimension: FlScope.of(context).space.xs * 10,
                        child: DecoratedBox(
                          decoration: ShapeDecoration(
                            color: FlScope.of(context).colors.control,
                            shape: FlContinuousBorder(
                              radius: FlScope.of(context).shape.radiusXl,
                            ),
                          ),
                        ),
                      ),
                    ),
                  ),
              ],
            ),
          ),
        Padding(
          padding: EdgeInsetsDirectional.symmetric(
            vertical: FlScope.of(context).space.sm,
          ),
          child: Wrap(
            spacing: FlScope.of(context).space.lg,
            runSpacing: FlScope.of(context).space.lg,
            children: [
              for (final s in FlBadgeSize.values)
                FlBadge.count(
                  count: 120,
                  color: FlBadgeColor.danger,
                  size: s,
                  child: Semantics(
                    label: 'Messages',
                    child: SizedBox.square(
                      dimension: FlScope.of(context).space.xs * 10,
                      child: DecoratedBox(
                        decoration: ShapeDecoration(
                          color: FlScope.of(context).colors.control,
                          shape: FlContinuousBorder(
                            radius: FlScope.of(context).shape.radiusXl,
                          ),
                        ),
                      ),
                    ),
                  ),
                ),
            ],
          ),
        ),
        Padding(
          padding: EdgeInsetsDirectional.symmetric(
            vertical: FlScope.of(context).space.sm,
          ),
          child: Wrap(
            spacing: FlScope.of(context).space.lg,
            runSpacing: FlScope.of(context).space.lg,
            children: [
              FlBadge.text(
                text: 'New',
                color: FlBadgeColor.accent,
                size: FlBadgeSize.sm,
                child: Semantics(
                  label: 'What’s new',
                  child: SizedBox.square(
                    dimension: FlScope.of(context).space.xs * 10,
                    child: DecoratedBox(
                      decoration: ShapeDecoration(
                        color: FlScope.of(context).colors.control,
                        shape: FlContinuousBorder(
                          radius: FlScope.of(context).shape.radiusXl,
                        ),
                      ),
                    ),
                  ),
                ),
              ),
              for (final p in FlBadgePlacement.values)
                FlBadge.dot(
                  semanticLabel: 'online',
                  color: FlBadgeColor.success,
                  placement: p,
                  child: Semantics(
                    label: 'Jane',
                    child: SizedBox.square(
                      dimension: FlScope.of(context).space.xs * 10,
                      child: DecoratedBox(
                        decoration: ShapeDecoration(
                          color: FlScope.of(context).colors.control,
                          shape: FlContinuousBorder(
                            radius: FlScope.of(context).shape.radiusXl,
                          ),
                        ),
                      ),
                    ),
                  ),
                ),
            ],
          ),
        ),
      ],
    ),
  ),
);

API

FlBadgeColor: enum {accent, neutral, success, warning, danger}
FlBadgeColor.accent: static final FlBadgeColor
FlBadgeColor.neutral: static final FlBadgeColor
FlBadgeColor.success: static final FlBadgeColor
FlBadgeColor.warning: static final FlBadgeColor
FlBadgeColor.danger: static final FlBadgeColor
FlBadgeVariant: enum {primary, secondary, soft}
FlBadgeVariant.primary: static final FlBadgeVariant
FlBadgeVariant.secondary: static final FlBadgeVariant
FlBadgeVariant.soft: static final FlBadgeVariant
FlBadgeSize: enum {sm, md, lg}
FlBadgeSize.sm: static final FlBadgeSize
FlBadgeSize.md: static final FlBadgeSize
FlBadgeSize.lg: static final FlBadgeSize
FlBadgePlacement: enum {topEnd, topStart, bottomEnd, bottomStart}
FlBadgePlacement.topEnd: static final FlBadgePlacement
FlBadgePlacement.topStart: static final FlBadgePlacement
FlBadgePlacement.bottomEnd: static final FlBadgePlacement
FlBadgePlacement.bottomStart: static final FlBadgePlacement
FlBadge: class extends StatelessWidget
FlBadge.count: const new({super.key, Widget? child, String? semanticLabel, FlBadgeColor color, FlBadgeVariant variant, FlBadgeSize size, FlBadgePlacement placement, Color? ringColor})
FlBadge.text: const new({super.key, Widget? child, String? semanticLabel, FlBadgeColor color, FlBadgeVariant variant, FlBadgeSize size, FlBadgePlacement placement, Color? ringColor})
FlBadge.icon: const new({super.key, Widget? child, required String semanticLabel, FlBadgeColor color, FlBadgeVariant variant, FlBadgeSize size, FlBadgePlacement placement, Color? ringColor})
FlBadge.dot: const new({super.key, Widget? child, required String semanticLabel, FlBadgeColor color, FlBadgeVariant variant, FlBadgeSize size, FlBadgePlacement placement, Color? ringColor})
FlBadge.child: get Widget?
FlBadge.semanticLabel: get String?
FlBadge.color: get FlBadgeColor
FlBadge.variant: get FlBadgeVariant
FlBadge.size: get FlBadgeSize
FlBadge.placement: get FlBadgePlacement
FlBadge.ringColor: get Color?
FlBadge.build: Widget(BuildContext context)
extension FlBadgePlacement.top: get bool
extension FlBadgePlacement.start: get bool

Reference renders

The 16 images published with version 0.2.4, rendered by the shared harness on macOS with Flutter 3.47.7. fliin verify badge compares your copy to them.

iOS · Light
Badge, iOS, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Badge, iOS, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Badge, iOS, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Badge, iOS, Light, text 2.0×, Right to left
Text 2.0× · Right to left
iOS · Dark
Badge, iOS, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Badge, iOS, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Badge, iOS, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Badge, iOS, Dark, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Light
Badge, Android, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Badge, Android, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Badge, Android, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Badge, Android, Light, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Dark
Badge, Android, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Badge, Android, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Badge, Android, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Badge, Android, Dark, text 2.0×, Right to left
Text 2.0× · Right to left

Use it to

  • show the unread message count of the inbox
  • show pending notifications on a tab
  • show that a contact is online with a dot on their avatar
  • add a short new label to a menu entry
  • show 99+ once a counter passes a hundred
  • add a count over the cart icon
Not for
  • a status or category in text only: use a chip (chip)
  • short help shown on hover or long press: use a tooltip (tooltip)
  • the progress of a task: use a progress indicator (progress)
States
  • Count
  • Overflow
  • Text
  • Dot

Source

The files fliin add badge copies into your app, before your prefix and imports are applied.

badge/fl_badge.dart

// Modified from the Badge of HeroUI React v3, Copyright 2020 Next UI Inc.,
// licensed under the MIT license below and under Apache 2.0:
// https://www.apache.org/licenses/LICENSE-2.0
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.
//
// Also modified from the theme colors of HeroUI Native 1.0.10, Copyright 2025
// NextUI Inc., licensed under Apache 2.0.
import 'package:flutter/widgets.dart';
import 'package:fliin_ui/core/fliin.dart';

/// The color of a [FlBadge].
enum FlBadgeColor {
  /// The highlighted color: solid, `selected` with
  /// `onSelected` text (the accent is reserved for the primary action);
  /// otherwise `onAccentSoft`, on `control` or `accentSoft`.
  accent,

  /// The neutral.
  neutral,

  /// Green.
  success,

  /// Orange.
  warning,

  /// Red.
  danger,
}

/// The fill of a [FlBadge].
enum FlBadgeVariant {
  /// The solid background of the color, its foreground text.
  primary,

  /// The `control` background, the color's soft text.
  secondary,

  /// The color's soft tint, its soft text.
  soft,
}

/// The size of a [FlBadge]: its minimum side and its text.
enum FlBadgeSize {
  /// At least 16 per side, 10 text.
  sm,

  /// At least 20 per side, `textXs` text.
  md,

  /// At least 32 per side, `textSm` text.
  lg,
}

/// The corner of the child on which a [FlBadge] sits, overflowing by a
/// quarter of its size. Start and end follow the reading direction.
enum FlBadgePlacement {
  /// Top, at the end (top right in LTR).
  topEnd,

  /// Top, at the start.
  topStart,

  /// Bottom, at the end.
  bottomEnd,

  /// Bottom, at the start.
  bottomStart,
}

/// `text-[10px]` of `.badge--sm`: no role of the scale has this size.
const double _smText = 10;

/// SF's tracking at 10 points, read from its `trak` table (24/2048 em): the
/// Core Text system font applies it, Skia does not.
const double _smTracking = 24 / 2048 * _smText;

/// `leading-[1.34]` (sm, md) and `leading-[1.43]` (lg) of `.badge`.
const double _leading = 1.34;
const double _leadingLg = 1.43;

/// `translate(±25%, ±25%)` of `.badge--top-right` and the other corners; a
/// dot sits on the corner itself (the canvas's `.badge-dot`).
const double _overhang = 0.25;
const double _dotOverhang = 0.5;

/// A badge placed on the corner of [child]: a
/// count, a short word, an icon or a plain dot, circled by a ring of
/// [ringColor] that separates it from what it overlaps. It does not change
/// the layout of [child], and its text does not grow beyond
/// `size.iconMaxScale`.
///
/// The screen reader announces it with [child], as a single node
/// ("Messages", then "3 unread"): [child] and its badge are merged, so
/// [child] must be a single element.
///
/// A count that changes rolls its digits to the new value; with `hideZero`,
/// the badge pops in when the count leaves zero and pops out when it returns
/// to it (`FlPop`). Under reduced motion, it changes at once or fades.
///
/// Without [child], the badge stands alone, in its place in the row (the
/// unread count at the end of a row, next to the time): no corner, no
/// overflow, and its label joins the node that contains it.
class FlBadge extends StatelessWidget {
  /// A count: beyond `max`, `<max>+`. At zero, the badge stays, unless
  /// `hideZero` is set. When [semanticLabel] is null: the displayed text.
  const FlBadge.count({
    super.key,
    this.child,
    required int this._count,
    this._max = 99,
    this._hideZero = false,
    this.semanticLabel,
    this.color = FlBadgeColor.neutral,
    this.variant = FlBadgeVariant.primary,
    this.size = FlBadgeSize.md,
    this.placement = FlBadgePlacement.topEnd,
    this.ringColor,
  }) : assert(_count >= 0, 'a count is not negative'),
       assert(_max > 0, 'the cap is positive'),
       _text = null,
       _icon = null;

  /// A short word ("New"). When [semanticLabel] is null: that word.
  const FlBadge.text({
    super.key,
    this.child,
    required String this._text,
    this.semanticLabel,
    this.color = FlBadgeColor.neutral,
    this.variant = FlBadgeVariant.primary,
    this.size = FlBadgeSize.md,
    this.placement = FlBadgePlacement.topEnd,
    this.ringColor,
  }) : _count = null,
       _max = 0,
       _hideZero = false,
       _icon = null;

  /// An icon at the size and color of the text, named by [semanticLabel].
  const FlBadge.icon({
    super.key,
    this.child,
    required Widget this._icon,
    required String this.semanticLabel,
    this.color = FlBadgeColor.neutral,
    this.variant = FlBadgeVariant.primary,
    this.size = FlBadgeSize.md,
    this.placement = FlBadgePlacement.topEnd,
    this.ringColor,
  }) : _count = null,
       _max = 0,
       _hideZero = false,
       _text = null;

  /// A dot with no content (online, new), named by [semanticLabel]; `sm` by
  /// default.
  const FlBadge.dot({
    super.key,
    this.child,
    required String this.semanticLabel,
    this.color = FlBadgeColor.neutral,
    this.variant = FlBadgeVariant.primary,
    this.size = FlBadgeSize.sm,
    this.placement = FlBadgePlacement.topEnd,
    this.ringColor,
  }) : _count = null,
       _max = 0,
       _hideZero = false,
       _text = null,
       _icon = null;

  /// What the badge marks: an icon, an avatar, a tab; when null, the badge
  /// alone.
  final Widget? child;

  /// What the screen reader reads after [child] ("3 unread").
  final String? semanticLabel;

  /// The color; the neutral by default.
  final FlBadgeColor color;

  /// The fill.
  final FlBadgeVariant variant;

  /// The size; `md` by default.
  final FlBadgeSize size;

  /// The corner; top end by default.
  final FlBadgePlacement placement;

  /// The ring's color: that of the background under [child]. When null:
  /// `surface`, the page background.
  final Color? ringColor;

  final int? _count;
  final int _max;
  final bool _hideZero;
  final String? _text;
  final Widget? _icon;

  /// The displayed text: the capped count or the word; null for an icon or
  /// a dot.
  String? get _shown => switch (_count) {
    final count? => count > _max ? '$_max+' : '$count',
    null => _text,
  };

  bool get _hidden => _hideZero && _count == 0;

  @override
  Widget build(BuildContext context) {
    final hidden = _hidden;
    final label = hidden ? null : semanticLabel ?? _shown;
    final child = this.child;
    // A count that leaves zero pops in, and pops out when it returns to it.
    if (child == null) {
      return Semantics(
        label: label,
        child: ExcludeSemantics(
          child: FlPop(shown: !hidden, child: _pastille(context)),
        ),
      );
    }
    return MergeSemantics(
      child: Stack(
        // The child keeps the parent's constraints: the badge does not change
        // its layout.
        fit: StackFit.passthrough,
        clipBehavior: Clip.none,
        children: [
          child,
          Positioned.directional(
            textDirection: Directionality.of(context),
            top: placement.top ? 0 : null,
            bottom: placement.top ? null : 0,
            start: placement.start ? 0 : null,
            end: placement.start ? null : 0,
            child: _overhanging(
              context,
              ExcludeSemantics(
                child: FlPop(shown: !hidden, child: _pastille(context)),
              ),
            ),
          ),
          if (label != null) Semantics(label: label),
        ],
      ),
    );
  }

  bool get _dot => _count == null && _text == null && _icon == null;

  /// The quarter of the badge that overflows its corner, on the corner's
  /// side; half of a dot.
  Widget _overhanging(BuildContext context, Widget child) {
    final rtl = Directionality.of(context) == TextDirection.rtl;
    final overhang = _dot ? _dotOverhang : _overhang;
    final outward = placement.start != rtl ? -overhang : overhang;
    return FractionalTranslation(translation: Offset(outward, placement.top ? -overhang : overhang), child: child);
  }

  Widget _pastille(BuildContext context) {
    final theme = FlScope.of(context);
    final colors = theme.colors;
    final scale = theme.typography;
    final (fill, ink) = switch ((variant, color)) {
      (FlBadgeVariant.primary, FlBadgeColor.accent) => (colors.selected, colors.onSelected),
      (FlBadgeVariant.primary, FlBadgeColor.neutral) => (colors.control, colors.onControl),
      (FlBadgeVariant.primary, FlBadgeColor.success) => (colors.success, colors.onSuccess),
      (FlBadgeVariant.primary, FlBadgeColor.warning) => (colors.warning, colors.onWarning),
      (FlBadgeVariant.primary, FlBadgeColor.danger) => (colors.dangerFill, colors.onDanger),
      (FlBadgeVariant.secondary, FlBadgeColor.accent) => (colors.control, colors.onAccentSoft),
      (FlBadgeVariant.secondary, FlBadgeColor.neutral) => (colors.control, colors.onControl),
      (FlBadgeVariant.secondary, FlBadgeColor.success) => (colors.control, colors.onSuccessSoft),
      (FlBadgeVariant.secondary, FlBadgeColor.warning) => (colors.control, colors.onWarningSoft),
      // `danger` holds 4.5:1 on `control`; `onDangerSoft` is darker only
      // for the soft tint.
      (FlBadgeVariant.secondary, FlBadgeColor.danger) => (colors.control, colors.danger),
      (FlBadgeVariant.soft, FlBadgeColor.accent) => (colors.accentSoft, colors.onAccentSoft),
      (FlBadgeVariant.soft, FlBadgeColor.neutral) => (colors.controlPressed, colors.onControl),
      (FlBadgeVariant.soft, FlBadgeColor.success) => (colors.successSoft, colors.onSuccessSoft),
      (FlBadgeVariant.soft, FlBadgeColor.warning) => (colors.warningSoft, colors.onWarningSoft),
      (FlBadgeVariant.soft, FlBadgeColor.danger) => (colors.dangerSoft, colors.onDangerSoft),
    };
    // The canvas's `.badge`: 16 high with a padding of 4 and 10 text, 20
    // with 5 and `textXs`, 32 with 8 and `textSm`; a dot of 12, 16 or 24.
    final xs = theme.space.xs;
    final (side, padding, radius, role) = switch (size) {
      FlBadgeSize.sm => (xs * 4, xs, theme.shape.radiusXl, FlTextRole.textXs),
      FlBadgeSize.md => (xs * 5, xs * 1.25, theme.shape.radiusCard, FlTextRole.textXs),
      FlBadgeSize.lg => (xs * 8, xs * 2, theme.shape.radiusXxl, FlTextRole.textSm),
    };
    final extent = _dot ? side * 3 / 4 : side;
    final base = scale.style(role);
    final systemFont = scale.familyOf(role) == null;
    final fontSize = size == FlBadgeSize.sm ? _smText : base.size;
    final style = scale
        .resolve(role)
        .copyWith(
          color: ink,
          fontSize: fontSize,
          fontWeight: theme.tokens.typography.weight.medium,
          letterSpacing: size == FlBadgeSize.sm && systemFont && theme.platform == TargetPlatform.iOS
              ? _smTracking
              : null,
          fontVariations: systemFont ? [FontVariation.opticalSize(fontSize)] : null,
        );
    final strut = StrutStyle(
      fontFamily: scale.familyOf(role) ?? DefaultTextStyle.of(context).style.fontFamily,
      fontSize: fontSize,
      height: size == FlBadgeSize.lg ? _leadingLg : _leading,
      leadingDistribution: TextLeadingDistribution.even,
      fontWeight: theme.tokens.typography.weight.medium,
    );
    final text = _shown;
    final icon = _icon;
    final content = icon != null
        ? IconTheme.merge(
            data: IconThemeData(color: ink, size: fontSize),
            child: icon,
          )
        : text != null
        ? Padding(
            padding: EdgeInsetsDirectional.symmetric(horizontal: padding),
            // A count rolls its digits to the new value.
            child: _count == null
                ? Text(text, maxLines: 1, softWrap: false, style: style, strutStyle: strut)
                : FlRollingText(text, maxLines: 1, softWrap: false, style: style, strutStyle: strut),
          )
        : null;
    // box-shadow: 0 0 0 2px var(--surface): outside the badge.
    final ring = theme.size.border * 2;
    return MediaQuery.withClampedTextScaling(
      maxScaleFactor: theme.size.iconMaxScale,
      child: FlPixelSnap(
        child: ConstrainedBox(
          constraints: BoxConstraints(minWidth: extent, minHeight: extent),
          child: CustomPaint(
            painter: _Pastille(
              fill: fill,
              // Alone, it overlaps nothing: no ring, which would leave a
              // fringe on a pressed row or a card.
              ring: child == null ? null : ringColor ?? colors.surface,
              ringWidth: ring,
              radius: radius,
            ),
            child: Center(widthFactor: 1, heightFactor: 1, child: content),
          ),
        ),
      ),
    );
  }
}

extension on FlBadgePlacement {
  bool get top => this == FlBadgePlacement.topEnd || this == FlBadgePlacement.topStart;

  bool get start => this == FlBadgePlacement.topStart || this == FlBadgePlacement.bottomStart;
}

/// The ring, outside the badge like a `box-shadow` spread, then the
/// background over it; both are drawn as paths.
class _Pastille extends CustomPainter {
  _Pastille({required this.fill, required this.ring, required this.ringWidth, required this.radius});

  final Color fill;
  final Color? ring;
  final double ringWidth;
  final double radius;

  @override
  void paint(Canvas canvas, Size size) {
    final body = RRect.fromRectAndRadius(Offset.zero & size, Radius.circular(radius)).scaleRadii();
    if (ring case final ring?) {
      canvas.drawPath(Path()..addRRect(body.inflate(ringWidth)), Paint()..color = ring);
    }
    canvas.drawPath(Path()..addRRect(body), Paint()..color = fill);
  }

  @override
  bool shouldRepaint(_Pastille old) =>
      old.fill != fill || old.ring != ring || old.ringWidth != ringWidth || old.radius != radius;
}

Arrows to move, Enter to open, Escape to close.