ashlarui

Card

A panel with a header, content and a footer.

Card, as it looks in an app Card, as it looks in an app, dark
import 'package:flutter/widgets.dart';

import 'ui/badge.dart';
import 'ui/button.dart';
import 'ui/card.dart';
import 'ui/input.dart';

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

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 360,
    child: Card(
      children: [
        const CardHeader(
          title: Text('Send money'),
          description: Text('To a phone number or a bank account.'),
          action: Badge(variant: BadgeVariant.secondary, child: Text('KES')),
        ),
        const CardContent(child: Input(placeholder: 'Phone number')),
        CardFooter(
          alignment: MainAxisAlignment.end,
          children: [
            Button(variant: ButtonVariant.outline, onPressed: () {}, child: const Text('Cancel')),
            Button(onPressed: () {}, child: const Text('Continue')),
          ],
        ),
      ],
    ),
  );
}

Try its options

Each option its constructor takes, to change and see; the code below it is what you chose, to copy.

Card with controls, as it looks in an app Card with controls, as it looks in an app, dark

Add it

In your Flutter project:

$ ashlar add card

It writes theme.dart, palette.dart and card.dart into lib/ui, keeping any you already have.

Examples

Other ways to use it, each running live, with its code.

With a picture

A picture edge to edge, then the words.

With a picture, as it looks in an app With a picture, as it looks in an app, dark
// With a picture: A picture edge to edge, then the words.
import 'package:flutter/widgets.dart';

import 'ui/badge.dart';
import 'ui/button.dart';
import 'ui/card.dart';
import 'ui/theme.dart';

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

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 320,
    child: Card(
      children: [
        CardImage(
          height: 140,
          child: Builder(
            builder: (context) => ColoredBox(color: AshlarTheme.of(context).colors.muted),
          ),
        ),
        const CardHeader(
          title: Text('Lake Naivasha retreat'),
          description: Text('Two nights, the team together.'),
          action: Badge(variant: BadgeVariant.outline, child: Text('June')),
        ),
        CardFooter(
          children: [
            Expanded(
              child: Button(expand: true, onPressed: () {}, child: const Text('Book')),
            ),
          ],
        ),
      ],
    ),
  );
}

Small, with borders

Less room, lines under the header and over the footer.

Small, with borders, as it looks in an app Small, with borders, as it looks in an app, dark
// Small, with borders: Less room, lines under the header and over the footer.
import 'package:flutter/widgets.dart';

import 'ui/button.dart';
import 'ui/card.dart';

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

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 320,
    child: Card(
      size: CardSize.sm,
      children: [
        const CardHeader(border: true, title: Text('Storage'), description: Text('8.2 GB of 10 GB used.')),
        const CardContent(child: Text('Free up room by emptying the bin.')),
        CardFooter(
          border: true,
          children: [Button(size: ButtonSize.sm, onPressed: () {}, child: const Text('Empty bin'))],
        ),
      ],
    ),
  );
}

API

Read from the source above: each parameter with its type, its default, and what it is for.

CardSizeenum

How much room a card leaves round its parts.

ValueWhat it is for
md24 round the edges and between parts.
sm16: for a dense grid of cards.

Cardclass

A panel on the page, usually CardHeader, CardContent and CardFooter in order.

Parameter Type Default What it is for
childrenrequired List<Widget> Its parts, top to bottom: CardImage, CardHeader, CardContent, CardFooter.
size CardSize CardSize.md Small cards have less room inside.

CardImageclass

A picture across the card's full width: first, at the top edge; last, at the foot.

Parameter Type Default What it is for
childrequired Widget Usually an Image with BoxFit.cover.
height double 180 How tall the picture is.

CardHeaderclass

A card's title and description, with an optional action at the top right. With border, a line runs under it.

Parameter Type Default What it is for
titleWidget?The card's title.
description Widget? A line under the title.
action Widget? At the end of the header: a button or a menu.
border bool false A line under the header.

CardContentclass

A card's body.

Parameter Type Default What it is for
childrequired Widget The card's body.

CardFooterclass

A card's actions, in a row at its foot. With border, a line runs over it.

Parameter Type Default What it is for
childrenrequired List<Widget> Along the bottom: usually buttons.
alignment MainAxisAlignment MainAxisAlignment.start How the footer's children line up along it.
border bool false A line over the footer.

Source

What lands in your app. It is yours from then on: change it as you would your own code, and ashlar add will not write over it.

lib/ui/card.dart 226 lines
// Ashlar UI: Card. Copied into your app by `ashlar add card`; it is yours to change.

import 'package:flutter/widgets.dart';

import 'theme.dart';

/// How much room a card leaves round its parts.
enum CardSize {
  /// 24 round the edges and between parts.
  md,

  /// 16: for a dense grid of cards.
  sm,
}

/// A panel on the page, usually [CardHeader], [CardContent] and [CardFooter] in order.
///
/// ```dart
/// Card(children: [
///   CardHeader(title: Text('Payment'), description: Text('Pay by card or mobile money.')),
///   CardContent(child: ...),
///   CardFooter(children: [Button(onPressed: pay, child: Text('Pay'))]),
/// ])
/// ```
class Card extends StatelessWidget {
  const Card({super.key, required this.children, this.size = CardSize.md});

  /// Its parts, top to bottom: CardImage, CardHeader, CardContent, CardFooter.
  final List<Widget> children;

  /// Small cards have less room inside.
  final CardSize size;

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final pad = AshlarTheme.of(context).space(size == CardSize.sm ? 16 : 24);
    // An image at the top or the foot runs to the card's edges.
    final topImage = children.isNotEmpty && children.first is CardImage;
    final footImage = children.isNotEmpty && children.last is CardImage;
    return _CardScope(
      pad: pad,
      child: Container(
        clipBehavior: Clip.antiAlias,
        padding: EdgeInsets.only(
          top: topImage ? 0 : pad,
          bottom: footImage ? 0 : pad,
        ),
        decoration: BoxDecoration(
          color: t.colors.card,
          borderRadius: BorderRadius.circular(t.radiusXl),
          border: Border.all(color: t.colors.border),
          boxShadow: t.shadowSm,
        ),
        child: DefaultTextStyle.merge(
          style: TextStyle(color: t.colors.cardForeground),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              for (final (i, child) in children.indexed) ...[
                if (i > 0) SizedBox(height: pad),
                child,
              ],
            ],
          ),
        ),
      ),
    );
  }
}

/// The spacing of the card around a part.
class _CardScope extends InheritedWidget {
  const _CardScope({required this.pad, required super.child});

  final double pad;

  static double of(BuildContext context) =>
      context.dependOnInheritedWidgetOfExactType<_CardScope>()?.pad ?? 24;

  @override
  bool updateShouldNotify(_CardScope old) => old.pad != pad;
}

/// A picture across the card's full width: first, at the top edge; last, at the foot.
class CardImage extends StatelessWidget {
  const CardImage({super.key, required this.child, this.height = 180});

  /// Usually an Image with BoxFit.cover.
  final Widget child;

  /// How tall the picture is.
  final double height;

  @override
  Widget build(BuildContext context) => SizedBox(height: height, child: child);
}

/// A card's title and description, with an optional [action] at the top right. With
/// [border], a line runs under it.
class CardHeader extends StatelessWidget {
  const CardHeader({
    super.key,
    this.title,
    this.description,
    this.action,
    this.border = false,
  });

  /// The card's title.
  final Widget? title;

  /// A line under the title.
  final Widget? description;

  /// At the end of the header: a button or a menu.
  final Widget? action;

  /// A line under the header.
  final bool border;

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final pad = _CardScope.of(context);
    return Container(
      padding: EdgeInsets.only(left: pad, right: pad, bottom: border ? pad : 0),
      decoration: border
          ? BoxDecoration(
              border: Border(bottom: BorderSide(color: t.colors.border)),
            )
          : null,
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                if (title != null)
                  Semantics(
                    header: true,
                    child: DefaultTextStyle.merge(
                      style: t.text.base.copyWith(
                        fontWeight: FontWeight.w600,
                        height: 1,
                        // The font's spare leading split above and below, so the text sits centred.
                        leadingDistribution: TextLeadingDistribution.even,
                      ),
                      child: title!,
                    ),
                  ),
                if (title != null && description != null)
                  const SizedBox(height: 6),
                if (description != null)
                  DefaultTextStyle.merge(
                    style: t.text.sm.copyWith(color: t.colors.mutedForeground),
                    child: description!,
                  ),
              ],
            ),
          ),
          if (action != null) ...[const SizedBox(width: 16), action!],
        ],
      ),
    );
  }
}

/// A card's body.
class CardContent extends StatelessWidget {
  const CardContent({super.key, required this.child});

  /// The card's body.
  final Widget child;

  @override
  Widget build(BuildContext context) => Padding(
    padding: EdgeInsets.symmetric(horizontal: _CardScope.of(context)),
    child: child,
  );
}

/// A card's actions, in a row at its foot. With [border], a line runs over it.
class CardFooter extends StatelessWidget {
  const CardFooter({
    super.key,
    required this.children,
    this.alignment = MainAxisAlignment.start,
    this.border = false,
  });

  /// Along the bottom: usually buttons.
  final List<Widget> children;

  /// How the footer's children line up along it.
  final MainAxisAlignment alignment;

  /// A line over the footer.
  final bool border;

  @override
  Widget build(BuildContext context) {
    final pad = _CardScope.of(context);
    return Container(
      padding: EdgeInsets.only(left: pad, right: pad, top: border ? pad : 0),
      decoration: border
          ? BoxDecoration(
              border: Border(
                top: BorderSide(color: AshlarTheme.of(context).colors.border),
              ),
            )
          : null,
      child: Row(
        mainAxisAlignment: alignment,
        children: [
          for (final (i, child) in children.indexed) ...[
            if (i > 0) const SizedBox(width: 8),
            child,
          ],
        ],
      ),
    );
  }
}