Ashlar UI Ashlar UI ashlarui

Sources

What an answer rests on, numbered, and the citation that points to each.

Sources, as it looks in an app Sources, as it looks in an app, dark
// The sources demo: the page's picture and what runs live.
import 'package:flutter/widgets.dart';

import 'ui/sources.dart';
import 'ui/theme.dart';

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

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    return SizedBox(
      width: 520,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Text.rich(
            TextSpan(
              style: t.text.sm.copyWith(color: t.colors.foreground),
              children: [
                const TextSpan(text: 'Returns are taken within 14 days of delivery'),
                WidgetSpan(
                  alignment: PlaceholderAlignment.middle,
                  child: Citation(1, title: 'Returns policy', onPressed: () {}),
                ),
                const TextSpan(text: ', and order 1042 was delivered on 12 October'),
                WidgetSpan(
                  alignment: PlaceholderAlignment.middle,
                  child: Citation(2, title: 'Order 1042', onPressed: () {}),
                ),
                const TextSpan(text: '.'),
              ],
            ),
          ),
          const SizedBox(height: 12),
          Sources(
            initiallyOpen: true,
            onOpen: (_) {},
            sources: const [
              Source(title: 'Returns policy', url: 'https://www.shop.example.com/returns'),
              Source(title: 'Order 1042', detail: 'Orders, 12 October'),
            ],
          ),
        ],
      ),
    );
  }
}

Add it

In your Flutter project:

$ ashlar add sources

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

API

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

Sourceclass

One thing an answer rests on: a page, a document, a row in your data.

Parameter Type Default What it is for
titlerequired String What it is called: a page's title, a file's name.
detail String? Where it is from, under the title: a site, a folder, a date. Taken from url when not given.
url String? Its address, given back to Sources.onOpen.

Sourcesclass

What an answer rests on, folded under a line that counts them ("Used 4 sources"), each numbered as the answer cites it. Citation draws that number in the answer's text.

Parameter Type Default What it is for
sourcesrequired List<Source>
onOpen ValueChanged<Source>? Called with the source tapped; they are not tappable without it.
initiallyOpenboolfalse

Citationclass

A source's number in an answer's text, as 2 is written: tap it to open the source.

Parameter Type Default What it is for
numberpositional int
onPressedVoidCallback?
title String? What it cites, for a screen reader: "Source 1, Returns policy".

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

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

import 'collapsible.dart';
import 'pressable.dart';
import 'theme.dart';

/// One thing an answer rests on: a page, a document, a row in your data.
class Source {
  const Source({required this.title, this.detail, this.url});

  /// What it is called: a page's title, a file's name.
  final String title;

  /// Where it is from, under the title: a site, a folder, a date. Taken from [url] when
  /// not given.
  final String? detail;

  /// Its address, given back to [Sources.onOpen].
  final String? url;

  String? get _from =>
      detail ??
      (url == null ? null : Uri.tryParse(url!)?.host.replaceFirst('www.', ''));
}

/// What an answer rests on, folded under a line that counts them ("Used 4 sources"), each
/// numbered as the answer cites it. [Citation] draws that number in the answer's text.
///
/// ```dart
/// Sources(
///   sources: [Source(title: 'Returns policy', url: 'https://shop.example.com/returns')],
///   onOpen: (source) => launch(source.url!),
/// )
/// ```
class Sources extends StatelessWidget {
  const Sources({
    super.key,
    required this.sources,
    this.onOpen,
    this.initiallyOpen = false,
  });

  final List<Source> sources;

  /// Called with the source tapped; they are not tappable without it.
  final ValueChanged<Source>? onOpen;
  final bool initiallyOpen;

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    final count = sources.length == 1
        ? 'Used 1 source'
        : 'Used ${sources.length} sources';
    return Collapsible(
      initiallyOpen: initiallyOpen,
      builder: (context, open, toggle) => Align(
        alignment: AlignmentDirectional.centerStart,
        child: Pressable(
          onPressed: toggle,
          expanded: open,
          semanticLabel: count,
          builder: (context, state) {
            final ink = state.hovered || state.focused
                ? c.foreground
                : c.mutedForeground;
            return AnimatedContainer(
              duration: t.duration,
              padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 2),
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(t.radiusSm),
                border: Border.all(
                  color: state.focused ? c.ring : c.ring.withValues(alpha: 0),
                ),
              ),
              child: Row(
                mainAxisSize: MainAxisSize.min,
                children: [
                  AshlarIcon(
                    AshlarIconsRoundedStroke.bookOpen,
                    size: 16,
                    color: ink,
                  ),
                  const SizedBox(width: 6),
                  Text(count, style: t.text.sm.copyWith(color: ink)),
                  const SizedBox(width: 2),
                  AnimatedRotation(
                    turns: open ? 0 : -.25,
                    duration: t.duration,
                    child: AshlarIcon(
                      AshlarIconsRoundedStroke.chevronDown,
                      size: 16,
                      color: ink,
                    ),
                  ),
                ],
              ),
            );
          },
        ),
      ),
      child: Padding(
        padding: const EdgeInsets.only(top: 6),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            for (final (i, s) in sources.indexed)
              Pressable(
                onPressed: onOpen == null ? null : () => onOpen!(s),
                link: s.url != null,
                semanticLabel:
                    '${i + 1}. ${s.title}${s._from == null ? '' : ', ${s._from}'}',
                builder: (context, state) => AnimatedContainer(
                  duration: t.duration,
                  padding: const EdgeInsets.symmetric(
                    horizontal: 8,
                    vertical: 6,
                  ),
                  decoration: BoxDecoration(
                    color: state.hovered && onOpen != null
                        ? c.accent
                        : c.accent.withValues(alpha: 0),
                    borderRadius: BorderRadius.circular(t.radiusMd),
                    border: Border.all(
                      color: state.focused
                          ? c.ring
                          : c.ring.withValues(alpha: 0),
                    ),
                  ),
                  child: Row(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Padding(
                        padding: const EdgeInsets.only(top: 1),
                        child: _Number(i + 1),
                      ),
                      const SizedBox(width: 10),
                      Expanded(
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Text(
                              s.title,
                              maxLines: 1,
                              overflow: TextOverflow.ellipsis,
                              style: t.text.sm.copyWith(
                                color: c.foreground,
                                fontWeight: FontWeight.w500,
                              ),
                            ),
                            if (s._from case final from?)
                              Text(
                                from,
                                maxLines: 1,
                                overflow: TextOverflow.ellipsis,
                                style: t.text.xs.copyWith(
                                  color: c.mutedForeground,
                                ),
                              ),
                          ],
                        ),
                      ),
                    ],
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }
}

/// The number of a source, in a small rounded square.
class _Number extends StatelessWidget {
  const _Number(this.n, {this.hovered = false});

  final int n;
  final bool hovered;

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    return Container(
      // At least a small square, and taller when the text is made larger.
      constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
      padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
      decoration: BoxDecoration(
        color: hovered ? c.primary : c.muted,
        borderRadius: BorderRadius.circular(t.radiusSm),
      ),
      // As wide as its number, so it sits in a line of text.
      child: Center(
        widthFactor: 1,
        child: Text(
          '$n',
          style: t.text.xs.copyWith(
            color: hovered ? c.primaryForeground : c.mutedForeground,
            fontWeight: FontWeight.w600,
            fontSize: 11,
            height: 1,
          ),
        ),
      ),
    );
  }
}

/// A source's number in an answer's text, as `[2]` is written: tap it to open the source.
///
/// ```dart
/// Text.rich(TextSpan(children: [TextSpan(text: 'Returns take 14 days '), WidgetSpan(child: Citation(1, onPressed: open))]))
/// ```
class Citation extends StatelessWidget {
  const Citation(this.number, {super.key, this.onPressed, this.title});

  final int number;
  final VoidCallback? onPressed;

  /// What it cites, for a screen reader: "Source 1, Returns policy".
  final String? title;

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    // In a sentence, a finger-sized press area would push the lines apart; a link inside
    // text is exempt from that minimum, as on the web.
    return AshlarTheme(
      data: t.copyWith(tapTarget: 0),
      child: Pressable(
        onPressed: onPressed,
        link: true,
        semanticLabel: 'Source $number${title == null ? '' : ', $title'}',
        builder: (context, state) => Container(
          margin: const EdgeInsets.symmetric(horizontal: 2),
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(4),
            border: Border.all(
              color: state.focused ? c.ring : c.ring.withValues(alpha: 0),
            ),
          ),
          child: _Number(number, hovered: state.hovered),
        ),
      ),
    );
  }
}