ashlarui

Attachment

A file or picture in a conversation: what it is and how its upload is going.

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

import 'ui/attachment.dart';

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

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 520,
    child: Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        AttachmentGroup(
          children: [
            Attachment(
              title: 'quarterly-report.pdf',
              description: '2.4 MB',
              icon: AshlarIconsRoundedStroke.fileText,
              onPressed: () {},
              actions: [
                AttachmentAction(
                  icon: AshlarIconsRoundedStroke.download,
                  label: 'Download',
                  onPressed: () {},
                ),
              ],
            ),
            const Attachment(
              title: 'site-photos.zip',
              description: 'Uploading, 64%',
              state: AttachmentState.uploading,
            ),
          ],
        ),
        const SizedBox(height: 16),
        AttachmentGroup(
          children: [
            Attachment(
              title: 'budget.xlsx',
              description: 'Too large to send.',
              state: AttachmentState.error,
              actions: [
                AttachmentAction(
                  icon: AshlarIconsRoundedStroke.rotateCw,
                  label: 'Try again',
                  onPressed: () {},
                ),
              ],
            ),
            Attachment(
              title: 'notes.md',
              description: 'Not sent yet',
              state: AttachmentState.idle,
              size: AttachmentSize.sm,
              actions: [
                AttachmentAction(
                  icon: AshlarIconsRoundedStroke.x,
                  label: 'Remove',
                  onPressed: () {},
                ),
              ],
            ),
          ],
        ),
        const SizedBox(height: 16),
        const AttachmentGroup(
          children: [
            Attachment(
              title: 'design.fig',
              description: 'Reading it',
              axis: Axis.vertical,
              icon: AshlarIconsRoundedStroke.frame,
              state: AttachmentState.processing,
            ),
            Attachment(
              title: 'logo.svg',
              description: '12 KB',
              axis: Axis.vertical,
              icon: AshlarIconsRoundedStroke.image,
            ),
          ],
        ),
      ],
    ),
  );
}

Try its options

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

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

Add it

In your Flutter project:

$ ashlar add attachment

It writes theme.dart, palette.dart, group.dart, pressable.dart, button.dart, spinner.dart and attachment.dart into lib/ui, keeping any you already have, and adds ashlar_icons to pubspec.yaml. It needs button, pressable, spinner and ashlar_icons, which come with it.

Examples

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

Sizes

Medium, small and extra small.

Sizes, as it looks in an app Sizes, as it looks in an app, dark
// Sizes: Medium, small and extra small.
import 'package:flutter/widgets.dart';

import 'ui/attachment.dart';

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

  @override
  Widget build(BuildContext context) => const Column(
    mainAxisSize: MainAxisSize.min,
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Attachment(title: 'contract.pdf', description: '320 KB'),
      SizedBox(height: 8),
      Attachment(title: 'contract.pdf', description: '320 KB', size: AttachmentSize.sm),
      SizedBox(height: 8),
      Attachment(title: 'contract.pdf', size: AttachmentSize.xs),
    ],
  );
}

API

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

AttachmentStateenum

Where an Attachment is on its way.

ValueWhat it is for
idleChosen but not sent: a dashed border.
uploadingOn its way up: a spinner, and the name shimmers.
processingUp, and being read: a spinner, and the name shimmers.
error It failed: the destructive colour, and the reason under the name.
done

AttachmentSizeenum

ValueWhat it is for
md
sm
xs

Attachmentclass

A file or a picture in a conversation or a message being written: what it is, how it is getting on, and what can be done with it.

Parameter Type Default What it is for
titlerequired String Its name.
description String? Its size or type, or, in AttachmentState.error, what went wrong.
icon IconData AshlarIconsRoundedStroke.file Shown when there is no image.
image ImageProvider? A picture of it: the image itself, or a preview.
state AttachmentState AttachmentState.done Where it is on its way: idle, uploading, processing, failed or done.
size AttachmentSize AttachmentSize.md Medium, small or extra small.
axis Axis Axis.horizontal Across: the picture beside the name. Upright: a tile, the picture above.
actions List<Widget> const [] Small buttons at the end, or over the corner of a tile: remove, retry, download.
onPressedVoidCallback?Opens it.

AttachmentActionclass

A small icon button on an Attachment: remove, retry, download.

Parameter Type Default What it is for
iconrequired IconData Its icon.
labelrequired String What it does, for screen readers and the tooltip.
onPressedrequired VoidCallback? Called when it is pressed. Null disables it.

AttachmentGroupclass

Attachments in a row that scrolls sideways when they do not fit.

Parameter Type Default What it is for
childrenrequired List<Widget> The attachments, in order.

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/attachment.dart 420 lines
// Ashlar UI: Attachment. Copied into your app by `ashlar add attachment`; it is yours to
// change.

import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';

import 'button.dart';
import 'pressable.dart';
import 'spinner.dart';
import 'theme.dart';

/// Where an [Attachment] is on its way.
enum AttachmentState {
  /// Chosen but not sent: a dashed border.
  idle,

  /// On its way up: a spinner, and the name shimmers.
  uploading,

  /// Up, and being read: a spinner, and the name shimmers.
  processing,

  /// It failed: the destructive colour, and the reason under the name.
  error,
  done,
}

enum AttachmentSize { md, sm, xs }

/// A file or a picture in a conversation or a message being written: what it is, how it
/// is getting on, and what can be done with it.
///
/// ```dart
/// Attachment(
///   title: 'quarterly-report.pdf',
///   description: '2.4 MB',
///   state: AttachmentState.uploading,
///   actions: [AttachmentAction(icon: AshlarIconsRoundedStroke.x, label: 'Remove', onPressed: remove)],
/// )
/// ```
class Attachment extends StatelessWidget {
  const Attachment({
    super.key,
    required this.title,
    this.description,
    this.icon = AshlarIconsRoundedStroke.file,
    this.image,
    this.state = AttachmentState.done,
    this.size = AttachmentSize.md,
    this.axis = Axis.horizontal,
    this.actions = const [],
    this.onPressed,
  });

  /// Its name.
  final String title;

  /// Its size or type, or, in [AttachmentState.error], what went wrong.
  final String? description;

  /// Shown when there is no [image].
  final IconData icon;

  /// A picture of it: the image itself, or a preview.
  final ImageProvider? image;

  /// Where it is on its way: idle, uploading, processing, failed or done.
  final AttachmentState state;

  /// Medium, small or extra small.
  final AttachmentSize size;

  /// Across: the picture beside the name. Upright: a tile, the picture above.
  final Axis axis;

  /// Small buttons at the end, or over the corner of a tile: remove, retry, download.
  final List<Widget> actions;

  /// Opens it.
  final VoidCallback? onPressed;

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    final upright = axis == Axis.vertical;
    final busy =
        state == AttachmentState.uploading ||
        state == AttachmentState.processing;
    final error = state == AttachmentState.error;
    final media = switch (size) {
      AttachmentSize.md => 40.0,
      AttachmentSize.sm => 32.0,
      AttachmentSize.xs => 28.0,
    };
    final pad = switch (size) {
      AttachmentSize.md => 8.0,
      AttachmentSize.sm => 6.0,
      AttachmentSize.xs => 4.0,
    };
    final radius = size == AttachmentSize.xs ? t.radiusLg : t.radiusXl;
    final picture = Container(
      width: upright ? null : media,
      height: upright ? null : media,
      clipBehavior: Clip.antiAlias,
      decoration: BoxDecoration(
        color: error ? c.destructive.withValues(alpha: .1) : c.muted,
        borderRadius: BorderRadius.circular(
          size == AttachmentSize.xs ? t.radiusMd : t.radiusLg,
        ),
      ),
      child: AspectRatio(
        aspectRatio: 1,
        child: Stack(
          fit: StackFit.expand,
          children: [
            if (image != null)
              Opacity(
                opacity: busy || error ? .6 : 1,
                child: Image(image: image!, fit: BoxFit.cover),
              )
            else if (!busy)
              Center(
                child: AshlarIcon(
                  error ? AshlarIconsRoundedStroke.circleAlert : icon,
                  size: upright ? 24 : (size == AttachmentSize.xs ? 14 : 16),
                  color: error ? c.destructive : c.foreground,
                ),
              ),
            if (busy)
              Center(
                child: Spinner(size: upright ? 24 : 16, semanticLabel: ''),
              ),
          ],
        ),
      ),
    );
    final text = t.text.xs;
    final words = Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      mainAxisSize: MainAxisSize.min,
      children: [
        _Shimmer(
          on: busy,
          child: Text(
            title,
            maxLines: 1,
            overflow: TextOverflow.ellipsis,
            style: (size == AttachmentSize.md ? t.text.sm : text).copyWith(
              fontWeight: FontWeight.w500,
              height: 1.25,
            ),
          ),
        ),
        if (description != null) ...[
          const SizedBox(height: 2),
          Text(
            description!,
            maxLines: 1,
            overflow: TextOverflow.ellipsis,
            style: text.copyWith(
              color: error
                  ? c.destructive.withValues(alpha: .8)
                  : c.mutedForeground,
            ),
          ),
        ],
      ],
    );
    final status = switch (state) {
      AttachmentState.idle => 'not sent',
      AttachmentState.uploading => 'uploading',
      AttachmentState.processing => 'processing',
      AttachmentState.error => 'failed',
      AttachmentState.done => null,
    };
    final label = [title, ?description, ?status].join(', ');
    // The card's fill and border: under everything, and the button when it opens.
    Widget surface(bool hovered, bool focused) => AnimatedContainer(
      duration: t.duration,
      decoration: BoxDecoration(
        color: hovered ? Color.lerp(c.card, c.muted, .5) : c.card,
        borderRadius: BorderRadius.circular(radius),
        border: state == AttachmentState.idle
            ? null
            : Border.all(
                color: focused
                    ? c.ring
                    : (error ? c.destructive.withValues(alpha: .3) : c.border),
              ),
      ),
    );
    // The name and picture are said by the label and let presses through to the card;
    // the actions are buttons of their own, on top.
    Widget quiet(Widget w) => IgnorePointer(child: ExcludeSemantics(child: w));
    final content = Container(
      width: upright ? (size == AttachmentSize.md ? 120 : 96) : null,
      constraints: upright ? null : const BoxConstraints(minWidth: 160),
      padding: EdgeInsets.all(pad),
      child: upright
          ? Stack(
              children: [
                quiet(
                  Column(
                    crossAxisAlignment: CrossAxisAlignment.stretch,
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      picture,
                      Padding(
                        padding: const EdgeInsets.fromLTRB(4, 8, 4, 2),
                        child: words,
                      ),
                    ],
                  ),
                ),
                if (actions.isNotEmpty)
                  PositionedDirectional(
                    top: 4,
                    end: 4,
                    child: Row(
                      mainAxisSize: MainAxisSize.min,
                      children: actions,
                    ),
                  ),
              ],
            )
          : Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                quiet(picture),
                SizedBox(width: size == AttachmentSize.xs ? 6 : 10),
                Flexible(child: quiet(words)),
                if (actions.isNotEmpty) ...[
                  const SizedBox(width: 4),
                  ...actions,
                ],
              ],
            ),
    );
    return Semantics(
      container: true,
      explicitChildNodes: true,
      child: CustomPaint(
        // Dashed while it has not been sent.
        foregroundPainter: state == AttachmentState.idle
            ? _Dashes(color: c.border, radius: radius)
            : null,
        child: Stack(
          children: [
            Positioned.fill(
              child: onPressed == null
                  ? Semantics(label: label, child: surface(false, false))
                  : Pressable(
                      onPressed: onPressed,
                      semanticLabel: label,
                      builder: (context, states) =>
                          surface(states.hovered, states.focused),
                    ),
            ),
            content,
          ],
        ),
      ),
    );
  }
}

/// A small icon button on an [Attachment]: remove, retry, download.
class AttachmentAction extends StatelessWidget {
  const AttachmentAction({
    super.key,
    required this.icon,
    required this.label,
    required this.onPressed,
  });

  /// Its icon.
  final IconData icon;

  /// What it does, for screen readers and the tooltip.
  final String label;

  /// Called when it is pressed. Null disables it.
  final VoidCallback? onPressed;

  @override
  Widget build(BuildContext context) => Button(
    variant: ButtonVariant.ghost,
    size: ButtonSize.iconXs,
    semanticLabel: label,
    onPressed: onPressed,
    child: AshlarIcon(icon),
  );
}

/// Attachments in a row that scrolls sideways when they do not fit.
class AttachmentGroup extends StatelessWidget {
  const AttachmentGroup({super.key, required this.children});

  /// The attachments, in order.
  final List<Widget> children;

  @override
  Widget build(BuildContext context) => SingleChildScrollView(
    scrollDirection: Axis.horizontal,
    padding: const EdgeInsets.symmetric(vertical: 4),
    child: Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        for (final (i, a) in children.indexed) ...[
          if (i > 0) const SizedBox(width: 12),
          a,
        ],
      ],
    ),
  );
}

/// A light passing over its child while it works.
class _Shimmer extends StatefulWidget {
  const _Shimmer({required this.on, required this.child});

  final bool on;
  final Widget child;

  @override
  State<_Shimmer> createState() => _ShimmerState();
}

class _ShimmerState extends State<_Shimmer>
    with SingleTickerProviderStateMixin {
  late final _run = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 1600),
  );

  @override
  void initState() {
    super.initState();
    _sync();
  }

  @override
  void didUpdateWidget(_Shimmer old) {
    super.didUpdateWidget(old);
    _sync();
  }

  void _sync() {
    // Still for people who ask for less motion.
    final still = WidgetsBinding
        .instance
        .platformDispatcher
        .accessibilityFeatures
        .disableAnimations;
    if (widget.on && !still) {
      if (!_run.isAnimating) _run.repeat();
    } else {
      _run.stop();
    }
  }

  @override
  void dispose() {
    _run.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (!widget.on) return widget.child;
    final c = AshlarTheme.of(context).colors;
    return AnimatedBuilder(
      animation: _run,
      builder: (context, child) => ShaderMask(
        blendMode: BlendMode.srcIn,
        shaderCallback: (rect) {
          final x = -1 + _run.value * 3;
          return LinearGradient(
            begin: Alignment(x - 1, 0),
            end: Alignment(x + 1, 0),
            colors: [c.mutedForeground, c.foreground, c.mutedForeground],
          ).createShader(rect);
        },
        child: child,
      ),
      child: widget.child,
    );
  }
}

/// A dashed rounded border.
class _Dashes extends CustomPainter {
  const _Dashes({required this.color, required this.radius});

  final Color color;
  final double radius;

  @override
  void paint(Canvas canvas, Size size) {
    final outline = Path()
      ..addRRect(
        RRect.fromRectAndRadius(
          (Offset.zero & size).deflate(.5),
          Radius.circular(radius),
        ),
      );
    final paint = Paint()
      ..color = color
      ..style = PaintingStyle.stroke;
    for (final metric in outline.computeMetrics()) {
      for (var d = 0.0; d < metric.length; d += 8) {
        canvas.drawPath(metric.extractPath(d, d + 4), paint);
      }
    }
  }

  @override
  bool shouldRepaint(_Dashes old) => old.color != color || old.radius != radius;
}