Social55 views

How to Build a Link Preview Feed with Painted URL Unfurls in Flutter (Full Code + Preview)

Shared links are the least readable thing in a social feed until they unfurl into a card, and most tutorials reach for a network image the moment they need a thumbnail. This screen builds Pulse's link-post feed without one: `_BannerPainter` paints a gradient banner with diagonal streaks and a centred Material glyph, `_LargeUnfurl` wraps it in a 16:8.4 hero card, and `_CompactUnfurl` shrinks the same painter to a 78px square. You finish with three unfurl variants, a `_DomainRow` favicon chip, an optional `_MetaChip` read-time pill, and `onLink` / `onPost` callbacks ready for a real backend.

Pulse · Link Card — Social Flutter UI screen
Live preview — Pulse · Link Card, built in pure Flutter.

What you'll build

  • A `_BannerPainter` that draws a diagonal gradient, six translucent streaks and a Material icon via `TextPainter`, reused at both banner and thumbnail sizes
  • A `_LargeUnfurl` hero card using `AspectRatio(16 / 8.4)` with ellipsis-clamped title and description and an optional `_MetaChip`
  • A `_CompactUnfurl` row that puts a 78px painted square beside a two-line title and an `open_in_new` affordance
  • A `_PostShell` whose `_initials` getter derives monogram letters from the author name and forwards taps to `onPost(id)`
  • A `_DomainRow` with a 15px gradient favicon block and an uppercased, letter-spaced domain label

Step-by-step build

1

Create the file

Add a new file at lib/social_feed_link_preview/social_feed_link_preview_screen.dart in your Flutter project.

2

Register the bundled fonts

No external packages — this is pure Flutter. It does bundle its design font (Inter), so drop the font file into fonts/ and declare it in pubspec.yaml:

pubspec.yaml
flutter:
  fonts:
    - family: Inter
      fonts:
        - asset: fonts/Inter-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.

Callbacks, palette and a forced dark theme

social_feed_link_preview_screen.dart
import 'package:flutter/material.dart';

/// Link Card — feed posts that unfurl a shared URL into a rich preview. Shows
/// three variants: a large hero-banner unfurl, a compact side-thumbnail row, and
/// an article unfurl with a read-time meta chip. All thumbnails and favicons are
/// painted (no network images). Self-contained per CONVENTIONS.md: pure Flutter,
/// bundled Inter font, own dark theme, SafeArea, overflow-proof.
class SocialFeedLinkPreviewScreen extends StatelessWidget {
  const SocialFeedLinkPreviewScreen({
    super.key,
    this.onBack,
    this.onLink,
    this.onPost,
  });

  final VoidCallback? onBack;
  final ValueChanged<String>? onLink;
  final ValueChanged<String>? onPost;

  static const String _font = 'Inter';
  static const Color _bg = Color(0xFF0B0B0F);
  static const Color _surface = Color(0xFF15151B);
  static const Color _brand = Color(0xFF6E56F7);
  static const Color _hairline = Color(0xFF26262F);
  static const Color _textHi = Color(0xFFF4F4F7);
  static const Color _muted = Color(0xFF8A8A99);

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.dark(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _bg,
        body: SafeArea(

`SocialFeedLinkPreviewScreen` is a `StatelessWidget` with three optional callbacks: `onBack`, `onLink` (a `ValueChanged<String>` that receives a domain) and `onPost` (a `ValueChanged<String>` that receives a post id). Nothing in the feed mutates, so no state class is needed — the host app decides what a tap means. The palette is six `static const` colours: `_bg #0B0B0F` and `_surface #15151B` are two near-black greys separated by just enough contrast for a card to read as raised, `_hairline #26262F` draws every divider, `_brand #6E56F7` is Pulse indigo, and `_textHi #F4F4F7` / `_muted #8A8A99` are the two text tiers. `build` wraps the `Scaffold` in `Theme(data: ThemeData.dark(useMaterial3: true))` so the screen stays dark even inside a light host app, then a `SafeArea` and a `Column` hold the top bar and the scrolling list.

Three posts, three unfurl variants

social_feed_link_preview_screen.dart
          child: Column(
            children: <Widget>[
              _TopBar(onBack: onBack),
              Expanded(
                child: ListView(
                  padding: EdgeInsets.zero,
                  children: <Widget>[
                    _PostShell(
                      id: 'l1',
                      name: 'Maya Chen',
                      handle: 'mayabuilds',
                      time: '2h',
                      colorA: 0xFF6E56F7,
                      colorB: 0xFF9B8CFF,
                      body: 'Great read on calm software — bookmarking this one.',
                      onPost: onPost,
                      preview: _LargeUnfurl(
                        domain: 'linear.app',
                        title: 'The quiet power of a fast, focused tool',
                        desc:
                            'How constraint and speed compound into software '
                            'people actually enjoy opening every day.',
                        colorA: 0xFF6E56F7,
                        colorB: 0xFF9B8CFF,
                        icon: Icons.bolt,
                        onTap: () => onLink?.call('linear.app'),
                      ),
                    ),
                    _PostShell(
                      id: 'l2',
                      name: 'Dev Kapoor',
                      handle: 'devk',
                      time: '5h',
                      colorA: 0xFF34D399,
                      colorB: 0xFF6E56F7,
                      body: 'Handy spec on payment flows if you\'re building one.',
                      onPost: onPost,
                      preview: _CompactUnfurl(
                        domain: 'stripe.com',
                        title:
                            'Designing checkout that converts without cutting '
                            'corners',
                        colorA: 0xFF34D399,
                        colorB: 0xFF6E56F7,
                        icon: Icons.credit_card,
                        onTap: () => onLink?.call('stripe.com'),
                      ),
                    ),
                    _PostShell(
                      id: 'l3',
                      name: 'Lena Ortiz',
                      handle: 'lenaux',
                      time: '1d',
                      colorA: 0xFFF4476B,
                      colorB: 0xFFFBBF24,
                      body: 'Loved this piece on design tokens at scale.',
                      onPost: onPost,
                      preview: _LargeUnfurl(
                        domain: 'figma.com',
                        title: 'Tokens, themes, and the end of the redline',
                        desc:
                            'A practical look at keeping design and code in sync '
                            'when both move fast.',
                        colorA: 0xFFF4476B,
                        colorB: 0xFFFBBF24,
                        icon: Icons.hexagon_outlined,
                        meta: 'Article · 6 min read',
                        onTap: () => onLink?.call('figma.com'),
                      ),
                    ),
                    const SizedBox(height: 24),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

The feed is a plain `ListView` with `padding: EdgeInsets.zero` (the `_PostShell` rows carry their own padding and hairline) holding three hand-written posts. Each `_PostShell` receives a pair of ARGB ints, `colorA` and `colorB`, that flow into both the author's `_Monogram` and the link's painted banner, so the avatar and the preview share one gradient — the post reads as a single visual unit. Post `l1` uses `_LargeUnfurl` with an indigo-to-lavender gradient and `Icons.bolt`; post `l2` switches to `_CompactUnfurl` in green-to-indigo with `Icons.credit_card`; post `l3` is another `_LargeUnfurl` in coral-to-amber, and is the only one to pass `meta: 'Article · 6 min read'`, which switches on the `_MetaChip`. Every `onTap` calls `onLink?.call(domain)` with the bare domain string, and a trailing `SizedBox(height: 24)` stops the last action bar sitting flush against the bottom edge.

A 56px top bar with a single hairline

social_feed_link_preview_screen.dart
class _TopBar extends StatelessWidget {
  const _TopBar({this.onBack});
  final VoidCallback? onBack;

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 56,
      padding: const EdgeInsets.only(left: 4, right: 20),
      decoration: const BoxDecoration(
        border: Border(
          bottom: BorderSide(color: SocialFeedLinkPreviewScreen._hairline),
        ),
      ),
      child: Row(
        children: <Widget>[
          IconButton(
            onPressed: onBack,
            icon: const Icon(Icons.arrow_back_ios_new,
                size: 18, color: SocialFeedLinkPreviewScreen._textHi),
          ),
          const Text(
            'Link posts',
            style: TextStyle(
              fontFamily: SocialFeedLinkPreviewScreen._font,
              fontSize: 18,
              fontWeight: FontWeight.w700,
              letterSpacing: -0.4,
              color: SocialFeedLinkPreviewScreen._textHi,
            ),
          ),
        ],
      ),
    );
  }
}

`_TopBar` is a fixed-height 56px `Container` whose `BoxDecoration` draws only a bottom `BorderSide` in `_hairline` — no elevation, no `AppBar`, so it costs nothing to lay out and never picks up Material's scroll-tint behaviour. The padding is asymmetric, `left: 4, right: 20`, because the `IconButton` already carries its own 48px hit target and would look indented if the bar added a full 16px on that side. The back glyph is `Icons.arrow_back_ios_new` at 18px in `_textHi`, and the title 'Link posts' is 18px `w700` with `letterSpacing: -0.4`, the same tight tracking used on headline text elsewhere in the Pulse screens. The row has no trailing actions, which keeps this a focused sub-screen rather than a home tab.

The post shell and its initials getter

social_feed_link_preview_screen.dart
class _PostShell extends StatelessWidget {
  const _PostShell({
    required this.id,
    required this.name,
    required this.handle,
    required this.time,
    required this.colorA,
    required this.colorB,
    required this.body,
    required this.preview,
    this.onPost,
  });
  final String id;
  final String name;
  final String handle;
  final String time;
  final int colorA;
  final int colorB;
  final String body;
  final Widget preview;
  final ValueChanged<String>? onPost;

  String get _initials {
    final List<String> p = name.trim().split(RegExp(r'\s+'));
    if (p.length == 1) return p.first.characters.first.toUpperCase();
    return (p.first[0] + p.last[0]).toUpperCase();
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => onPost?.call(id),
      behavior: HitTestBehavior.opaque,
      child: Container(
        padding: const EdgeInsets.fromLTRB(16, 16, 12, 12),
        decoration: const BoxDecoration(
          border: Border(
            bottom: BorderSide(color: SocialFeedLinkPreviewScreen._hairline),
          ),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Row(
              children: <Widget>[
                _Monogram(
                  initials: _initials,
                  colorA: Color(colorA),
                  colorB: Color(colorB),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Row(
                        children: <Widget>[
                          Flexible(
                            child: Text(
                              name,
                              overflow: TextOverflow.ellipsis,
                              style: const TextStyle(
                                fontFamily: SocialFeedLinkPreviewScreen._font,
                                fontSize: 15,
                                fontWeight: FontWeight.w700,
                                color: SocialFeedLinkPreviewScreen._textHi,
                              ),
                            ),
                          ),
                          const SizedBox(width: 6),
                          const Icon(Icons.verified,
                              size: 15,
                              color: SocialFeedLinkPreviewScreen._brand),
                        ],
                      ),
                      Text(
                        '@$handle · $time',
                        style: const TextStyle(
                          fontFamily: SocialFeedLinkPreviewScreen._font,
                          fontSize: 12.5,
                          color: SocialFeedLinkPreviewScreen._muted,
                        ),
                      ),
                    ],
                  ),
                ),
                const Icon(Icons.more_horiz,
                    color: SocialFeedLinkPreviewScreen._muted, size: 22),
                const SizedBox(width: 6),
              ],
            ),
            const SizedBox(height: 10),
            Text(
              body,
              style: const TextStyle(
                fontFamily: SocialFeedLinkPreviewScreen._font,
                fontSize: 14.5,
                height: 1.5,
                color: Color(0xFFDDDDE6),
              ),
            ),
            const SizedBox(height: 12),
            preview,
            const SizedBox(height: 8),
            const _ActionBar(),
          ],
        ),
      ),
    );
  }
}

`_PostShell` is the frame every post shares, taking the `preview` as an injected `Widget` so the shell never knows which unfurl variant it holds. The `_initials` getter splits `name` on `RegExp(r'\s+')`, returns the first character for a single-word name, and otherwise concatenates the first letter of the first and last words and upper-cases them — so 'Maya Chen' becomes 'MC' with no hard-coded initials in the data. The whole card sits in a `GestureDetector` with `HitTestBehavior.opaque`, which makes the blank padding tappable too, firing `onPost?.call(id)`. Inside, a header `Row` places the 42px `_Monogram`, then an `Expanded` column with the name in `Flexible` + `TextOverflow.ellipsis` beside a 15px `Icons.verified` in `_brand`, and the `'@$handle · $time'` line at 12.5px muted. The caption is 14.5px with `height: 1.5` in a slightly dimmed `#DDDDE6`, then the preview and a `const _ActionBar()`.

The large hero unfurl and its optional meta chip

social_feed_link_preview_screen.dart
class _LargeUnfurl extends StatelessWidget {
  const _LargeUnfurl({
    required this.domain,
    required this.title,
    required this.desc,
    required this.colorA,
    required this.colorB,
    required this.icon,
    this.meta,
    this.onTap,
  });
  final String domain;
  final String title;
  final String desc;
  final int colorA;
  final int colorB;
  final IconData icon;
  final String? meta;
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        clipBehavior: Clip.antiAlias,
        decoration: BoxDecoration(
          color: SocialFeedLinkPreviewScreen._surface,
          borderRadius: BorderRadius.circular(14),
          border: Border.all(color: SocialFeedLinkPreviewScreen._hairline),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            AspectRatio(
              aspectRatio: 16 / 8.4,
              child: CustomPaint(
                painter: _BannerPainter(Color(colorA), Color(colorB), icon),
                child: const SizedBox.expand(),
              ),
            ),
            Padding(
              padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  _DomainRow(domain: domain, colorA: colorA, colorB: colorB),
                  const SizedBox(height: 8),
                  Text(
                    title,
                    maxLines: 2,
                    overflow: TextOverflow.ellipsis,
                    style: const TextStyle(
                      fontFamily: SocialFeedLinkPreviewScreen._font,
                      fontSize: 15.5,
                      fontWeight: FontWeight.w700,
                      height: 1.3,
                      letterSpacing: -0.2,
                      color: SocialFeedLinkPreviewScreen._textHi,
                    ),
                  ),
                  const SizedBox(height: 5),
                  Text(
                    desc,
                    maxLines: 2,
                    overflow: TextOverflow.ellipsis,
                    style: const TextStyle(
                      fontFamily: SocialFeedLinkPreviewScreen._font,
                      fontSize: 13,
                      height: 1.45,
                      color: Color(0xFFB5B5C2),
                    ),
                  ),
                  if (meta != null) ...<Widget>[
                    const SizedBox(height: 10),
                    _MetaChip(label: meta!),
                  ],
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

`_LargeUnfurl` is the classic Twitter-style card: a `Container` with `clipBehavior: Clip.antiAlias`, a 14px radius and a `_hairline` border, so the painted banner is clipped to the rounded corners without a separate `ClipRRect`. The banner is an `AspectRatio(aspectRatio: 16 / 8.4)` — a touch taller than 16:9 — wrapping a `CustomPaint` whose child is `SizedBox.expand()`, which forces the painter to fill whatever width the feed gives it. Below, `EdgeInsets.fromLTRB(14, 12, 14, 14)` frames a `_DomainRow`, a 15.5px `w700` title and a 13px `#B5B5C2` description, both hard-clamped to `maxLines: 2` with ellipsis so a long Open Graph title can never grow the card. The `if (meta != null) ...[]` spread appends a 10px gap and a `_MetaChip` only when the caller supplies a label, which is why just the figma.com post shows 'Article · 6 min read'.

The compact side-thumbnail row

social_feed_link_preview_screen.dart
class _CompactUnfurl extends StatelessWidget {
  const _CompactUnfurl({
    required this.domain,
    required this.title,
    required this.colorA,
    required this.colorB,
    required this.icon,
    this.onTap,
  });
  final String domain;
  final String title;
  final int colorA;
  final int colorB;
  final IconData icon;
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        clipBehavior: Clip.antiAlias,
        decoration: BoxDecoration(
          color: SocialFeedLinkPreviewScreen._surface,
          borderRadius: BorderRadius.circular(12),
          border: Border.all(color: SocialFeedLinkPreviewScreen._hairline),
        ),
        child: Row(
          children: <Widget>[
            SizedBox(
              width: 78,
              height: 78,
              child: CustomPaint(
                painter: _BannerPainter(Color(colorA), Color(colorB), icon),
              ),
            ),
            Expanded(
              child: Padding(
                padding: const EdgeInsets.fromLTRB(12, 10, 10, 10),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: <Widget>[
                    _DomainRow(domain: domain, colorA: colorA, colorB: colorB),
                    const SizedBox(height: 6),
                    Text(
                      title,
                      maxLines: 2,
                      overflow: TextOverflow.ellipsis,
                      style: const TextStyle(
                        fontFamily: SocialFeedLinkPreviewScreen._font,
                        fontSize: 14,
                        fontWeight: FontWeight.w600,
                        height: 1.3,
                        color: SocialFeedLinkPreviewScreen._textHi,
                      ),
                    ),
                  ],
                ),
              ),
            ),
            const Padding(
              padding: EdgeInsets.only(right: 12),
              child: Icon(Icons.open_in_new,
                  size: 16, color: SocialFeedLinkPreviewScreen._muted),
            ),
          ],
        ),
      ),
    );
  }
}

`_CompactUnfurl` is the same idea turned sideways for links that do not deserve a hero. The outer `Container` matches the large card's surface and hairline but drops to a 12px radius, and its child is a `Row` rather than a `Column`. The thumbnail is a fixed 78×78 `SizedBox` around a `CustomPaint` using the very same `_BannerPainter` — because the painter sizes its glyph from `size.shortestSide`, the icon scales down automatically. The text column is `Expanded` with `mainAxisAlignment: MainAxisAlignment.center` so a one-line title sits vertically centred against the square, and the title drops to 14px `w600` since there is no description to balance it. A trailing `Icons.open_in_new` at 16px in `_muted` with 12px right padding tells the reader this leaves the app, an affordance the large card does not need because its banner already reads as a link.

Domain row, meta chip and the action bar

social_feed_link_preview_screen.dart
class _DomainRow extends StatelessWidget {
  const _DomainRow({
    required this.domain,
    required this.colorA,
    required this.colorB,
  });
  final String domain;
  final int colorA;
  final int colorB;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: <Widget>[
        Container(
          width: 15,
          height: 15,
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(4),
            gradient: LinearGradient(
              begin: Alignment.topLeft,
              end: Alignment.bottomRight,
              colors: <Color>[Color(colorA), Color(colorB)],
            ),
          ),
        ),
        const SizedBox(width: 7),
        Flexible(
          child: Text(
            domain.toUpperCase(),
            overflow: TextOverflow.ellipsis,
            style: const TextStyle(
              fontFamily: SocialFeedLinkPreviewScreen._font,
              fontSize: 11,
              fontWeight: FontWeight.w600,
              letterSpacing: 0.6,
              color: SocialFeedLinkPreviewScreen._muted,
            ),
          ),
        ),
      ],
    );
  }
}

class _MetaChip extends StatelessWidget {
  const _MetaChip({required this.label});
  final String label;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
      decoration: BoxDecoration(
        color: SocialFeedLinkPreviewScreen._brand.withValues(alpha: 0.14),
        borderRadius: BorderRadius.circular(20),
      ),
      child: Text(
        label,
        style: const TextStyle(
          fontFamily: SocialFeedLinkPreviewScreen._font,
          fontSize: 11.5,
          fontWeight: FontWeight.w700,
          color: SocialFeedLinkPreviewScreen._brand,
        ),
      ),
    );
  }
}

class _ActionBar extends StatelessWidget {
  const _ActionBar();

  @override
  Widget build(BuildContext context) {
    return const Row(
      children: <Widget>[
        _Action(icon: Icons.favorite_border, label: '146'),
        SizedBox(width: 18),
        _Action(icon: Icons.chat_bubble_outline, label: '22'),
        SizedBox(width: 18),
        _Action(icon: Icons.share_outlined, label: 'Share'),
        Spacer(),
        Icon(Icons.bookmark_border,
            size: 21, color: SocialFeedLinkPreviewScreen._muted),
        SizedBox(width: 8),
      ],
    );
  }
}

class _Action extends StatelessWidget {
  const _Action({required this.icon, required this.label});
  final IconData icon;
  final String label;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: <Widget>[
        Icon(icon, size: 20, color: SocialFeedLinkPreviewScreen._muted),
        const SizedBox(width: 6),
        Text(
          label,
          style: const TextStyle(
            fontFamily: SocialFeedLinkPreviewScreen._font,
            fontSize: 13,
            fontWeight: FontWeight.w500,
            color: SocialFeedLinkPreviewScreen._muted,
          ),
        ),
      ],
    );
  }
}

`_DomainRow` fakes a favicon with a 15px `Container` whose `BoxDecoration` carries a 4px radius and a `LinearGradient` from `colorA` to `colorB` — the same pair as the banner, so the chip looks like it belongs to the site. The domain text is rendered with `domain.toUpperCase()` at 11px `w600` and `letterSpacing: 0.6`, the small-caps treatment that makes 'LINEAR.APP' read as a label rather than a sentence, and it sits in `Flexible` so a long hostname ellipsises instead of overflowing. `_MetaChip` is a pill at `_brand.withValues(alpha: 0.14)` with 20px radius and 11.5px `w700` text in full `_brand`, the same tinted-background-plus-solid-text pattern used for badges across the Pulse set. `_ActionBar` is entirely `const`: three `_Action` icon-plus-label pairs (146 likes, 22 comments, Share) separated by 18px gaps, a `Spacer`, and a lone 21px bookmark icon pushed to the right.

The monogram avatar and the banner painter

social_feed_link_preview_screen.dart
class _Monogram extends StatelessWidget {
  const _Monogram({
    required this.initials,
    required this.colorA,
    required this.colorB,
  });
  final String initials;
  final Color colorA;
  final Color colorB;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 42,
      height: 42,
      decoration: BoxDecoration(
        shape: BoxShape.circle,
        gradient: LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: <Color>[colorA, colorB],
        ),
      ),
      child: Center(
        child: Text(
          initials,
          style: const TextStyle(
            fontFamily: SocialFeedLinkPreviewScreen._font,
            fontSize: 15,
            fontWeight: FontWeight.w700,
            color: Colors.white,
          ),
        ),
      ),
    );
  }
}

class _BannerPainter extends CustomPainter {
  _BannerPainter(this.colorA, this.colorB, this.icon);
  final Color colorA;
  final Color colorB;
  final IconData icon;

  @override
  void paint(Canvas canvas, Size size) {
    final Rect rect = Offset.zero & size;
    canvas.drawRect(
      rect,
      Paint()
        ..shader = LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: <Color>[
            colorA.withValues(alpha: 0.9),
            colorB.withValues(alpha: 0.7),
          ],
        ).createShader(rect),
    );
    final Paint streak = Paint()
      ..color = Colors.white.withValues(alpha: 0.06)
      ..style = PaintingStyle.stroke
      ..strokeWidth = size.width * 0.10;
    for (int i = -1; i < 5; i++) {
      final double x = size.width * (0.2 * i);
      canvas.drawLine(Offset(x, size.height), Offset(x + size.height, 0), streak);
    }
    // Painted glyph badge centered.
    final TextPainter tp = TextPainter(
      text: TextSpan(
        text: String.fromCharCode(icon.codePoint),
        style: TextStyle(
          fontSize: size.shortestSide * 0.42,
          fontFamily: icon.fontFamily,
          package: icon.fontPackage,
          color: Colors.white.withValues(alpha: 0.9),
        ),
      ),
      textDirection: TextDirection.ltr,
    )..layout();
    tp.paint(
      canvas,
      Offset(
        (size.width - tp.width) / 2,
        (size.height - tp.height) / 2,
      ),
    );
  }

  @override
  bool shouldRepaint(covariant _BannerPainter old) =>
      old.colorA != colorA || old.colorB != colorB || old.icon != icon;
}

`_Monogram` is a 42px circle with a top-left-to-bottom-right gradient and the initials centred in 15px `w700` white. `_BannerPainter` does the real work. It first fills the whole `rect` with a `LinearGradient` shader from `colorA` at 90% alpha to `colorB` at 70%, so the banner is slightly softer than the avatar. A `streak` paint at `Colors.white.withValues(alpha: 0.06)`, stroke width `size.width * 0.10`, draws six diagonal lines in a `for (int i = -1; i < 5; i++)` loop: each starts at `x = width * 0.2 * i` on the bottom edge and ends at `x + height` on the top edge, giving 45-degree bands a fifth of the width apart; `i = -1` covers the bottom-left corner. Finally the icon is drawn as text: `String.fromCharCode(icon.codePoint)` in a `TextPainter` with `fontFamily: icon.fontFamily` and `package: icon.fontPackage`, sized at `shortestSide * 0.42` and painted at the exact centre. `shouldRepaint` compares both colours and the icon, so a rebuild with identical data skips the paint.

Full code

The complete, ready-to-paste source. Free to use in your projects — one click copies it all.

import 'package:flutter/material.dart';

/// Link Card — feed posts that unfurl a shared URL into a rich preview. Shows
/// three variants: a large hero-banner unfurl, a compact side-thumbnail row, and
/// an article unfurl with a read-time meta chip. All thumbnails and favicons are
/// painted (no network images). Self-contained per CONVENTIONS.md: pure Flutter,
/// bundled Inter font, own dark theme, SafeArea, overflow-proof.
class SocialFeedLinkPreviewScreen extends StatelessWidget {
  const SocialFeedLinkPreviewScreen({
    super.key,
    this.onBack,
    this.onLink,
    this.onPost,
  });

  final VoidCallback? onBack;
  final ValueChanged<String>? onLink;
  final ValueChanged<String>? onPost;

  static const String _font = 'Inter';
  static const Color _bg = Color(0xFF0B0B0F);
  static const Color _surface = Color(0xFF15151B);
  static const Color _brand = Color(0xFF6E56F7);
  static const Color _hairline = Color(0xFF26262F);
  static const Color _textHi = Color(0xFFF4F4F7);
  static const Color _muted = Color(0xFF8A8A99);

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.dark(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _bg,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              _TopBar(onBack: onBack),
              Expanded(
                child: ListView(
                  padding: EdgeInsets.zero,
                  children: <Widget>[
                    _PostShell(
                      id: 'l1',
                      name: 'Maya Chen',
                      handle: 'mayabuilds',
                      time: '2h',
                      colorA: 0xFF6E56F7,
                      colorB: 0xFF9B8CFF,
                      body: 'Great read on calm software — bookmarking this one.',
                      onPost: onPost,
                      preview: _LargeUnfurl(
                        domain: 'linear.app',
                        title: 'The quiet power of a fast, focused tool',
                        desc:
                            'How constraint and speed compound into software '
                            'people actually enjoy opening every day.',
                        colorA: 0xFF6E56F7,
                        colorB: 0xFF9B8CFF,
                        icon: Icons.bolt,
                        onTap: () => onLink?.call('linear.app'),
                      ),
                    ),
                    _PostShell(
                      id: 'l2',
                      name: 'Dev Kapoor',
                      handle: 'devk',
                      time: '5h',
                      colorA: 0xFF34D399,
                      colorB: 0xFF6E56F7,
                      body: 'Handy spec on payment flows if you\'re building one.',
                      onPost: onPost,
                      preview: _CompactUnfurl(
                        domain: 'stripe.com',
                        title:
                            'Designing checkout that converts without cutting '
                            'corners',
                        colorA: 0xFF34D399,
                        colorB: 0xFF6E56F7,
                        icon: Icons.credit_card,
                        onTap: () => onLink?.call('stripe.com'),
                      ),
                    ),
                    _PostShell(
                      id: 'l3',
                      name: 'Lena Ortiz',
                      handle: 'lenaux',
                      time: '1d',
                      colorA: 0xFFF4476B,
                      colorB: 0xFFFBBF24,
                      body: 'Loved this piece on design tokens at scale.',
                      onPost: onPost,
                      preview: _LargeUnfurl(
                        domain: 'figma.com',
                        title: 'Tokens, themes, and the end of the redline',
                        desc:
                            'A practical look at keeping design and code in sync '
                            'when both move fast.',
                        colorA: 0xFFF4476B,
                        colorB: 0xFFFBBF24,
                        icon: Icons.hexagon_outlined,
                        meta: 'Article · 6 min read',
                        onTap: () => onLink?.call('figma.com'),
                      ),
                    ),
                    const SizedBox(height: 24),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _TopBar extends StatelessWidget {
  const _TopBar({this.onBack});
  final VoidCallback? onBack;

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 56,
      padding: const EdgeInsets.only(left: 4, right: 20),
      decoration: const BoxDecoration(
        border: Border(
          bottom: BorderSide(color: SocialFeedLinkPreviewScreen._hairline),
        ),
      ),
      child: Row(
        children: <Widget>[
          IconButton(
            onPressed: onBack,
            icon: const Icon(Icons.arrow_back_ios_new,
                size: 18, color: SocialFeedLinkPreviewScreen._textHi),
          ),
          const Text(
            'Link posts',
            style: TextStyle(
              fontFamily: SocialFeedLinkPreviewScreen._font,
              fontSize: 18,
              fontWeight: FontWeight.w700,
              letterSpacing: -0.4,
              color: SocialFeedLinkPreviewScreen._textHi,
            ),
          ),
        ],
      ),
    );
  }
}

class _PostShell extends StatelessWidget {
  const _PostShell({
    required this.id,
    required this.name,
    required this.handle,
    required this.time,
    required this.colorA,
    required this.colorB,
    required this.body,
    required this.preview,
    this.onPost,
  });
  final String id;
  final String name;
  final String handle;
  final String time;
  final int colorA;
  final int colorB;
  final String body;
  final Widget preview;
  final ValueChanged<String>? onPost;

  String get _initials {
    final List<String> p = name.trim().split(RegExp(r'\s+'));
    if (p.length == 1) return p.first.characters.first.toUpperCase();
    return (p.first[0] + p.last[0]).toUpperCase();
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => onPost?.call(id),
      behavior: HitTestBehavior.opaque,
      child: Container(
        padding: const EdgeInsets.fromLTRB(16, 16, 12, 12),
        decoration: const BoxDecoration(
          border: Border(
            bottom: BorderSide(color: SocialFeedLinkPreviewScreen._hairline),
          ),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Row(
              children: <Widget>[
                _Monogram(
                  initials: _initials,
                  colorA: Color(colorA),
                  colorB: Color(colorB),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Row(
                        children: <Widget>[
                          Flexible(
                            child: Text(
                              name,
                              overflow: TextOverflow.ellipsis,
                              style: const TextStyle(
                                fontFamily: SocialFeedLinkPreviewScreen._font,
                                fontSize: 15,
                                fontWeight: FontWeight.w700,
                                color: SocialFeedLinkPreviewScreen._textHi,
                              ),
                            ),
                          ),
                          const SizedBox(width: 6),
                          const Icon(Icons.verified,
                              size: 15,
                              color: SocialFeedLinkPreviewScreen._brand),
                        ],
                      ),
                      Text(
                        '@$handle · $time',
                        style: const TextStyle(
                          fontFamily: SocialFeedLinkPreviewScreen._font,
                          fontSize: 12.5,
                          color: SocialFeedLinkPreviewScreen._muted,
                        ),
                      ),
                    ],
                  ),
                ),
                const Icon(Icons.more_horiz,
                    color: SocialFeedLinkPreviewScreen._muted, size: 22),
                const SizedBox(width: 6),
              ],
            ),
            const SizedBox(height: 10),
            Text(
              body,
              style: const TextStyle(
                fontFamily: SocialFeedLinkPreviewScreen._font,
                fontSize: 14.5,
                height: 1.5,
                color: Color(0xFFDDDDE6),
              ),
            ),
            const SizedBox(height: 12),
            preview,
            const SizedBox(height: 8),
            const _ActionBar(),
          ],
        ),
      ),
    );
  }
}

class _LargeUnfurl extends StatelessWidget {
  const _LargeUnfurl({
    required this.domain,
    required this.title,
    required this.desc,
    required this.colorA,
    required this.colorB,
    required this.icon,
    this.meta,
    this.onTap,
  });
  final String domain;
  final String title;
  final String desc;
  final int colorA;
  final int colorB;
  final IconData icon;
  final String? meta;
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        clipBehavior: Clip.antiAlias,
        decoration: BoxDecoration(
          color: SocialFeedLinkPreviewScreen._surface,
          borderRadius: BorderRadius.circular(14),
          border: Border.all(color: SocialFeedLinkPreviewScreen._hairline),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            AspectRatio(
              aspectRatio: 16 / 8.4,
              child: CustomPaint(
                painter: _BannerPainter(Color(colorA), Color(colorB), icon),
                child: const SizedBox.expand(),
              ),
            ),
            Padding(
              padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  _DomainRow(domain: domain, colorA: colorA, colorB: colorB),
                  const SizedBox(height: 8),
                  Text(
                    title,
                    maxLines: 2,
                    overflow: TextOverflow.ellipsis,
                    style: const TextStyle(
                      fontFamily: SocialFeedLinkPreviewScreen._font,
                      fontSize: 15.5,
                      fontWeight: FontWeight.w700,
                      height: 1.3,
                      letterSpacing: -0.2,
                      color: SocialFeedLinkPreviewScreen._textHi,
                    ),
                  ),
                  const SizedBox(height: 5),
                  Text(
                    desc,
                    maxLines: 2,
                    overflow: TextOverflow.ellipsis,
                    style: const TextStyle(
                      fontFamily: SocialFeedLinkPreviewScreen._font,
                      fontSize: 13,
                      height: 1.45,
                      color: Color(0xFFB5B5C2),
                    ),
                  ),
                  if (meta != null) ...<Widget>[
                    const SizedBox(height: 10),
                    _MetaChip(label: meta!),
                  ],
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _CompactUnfurl extends StatelessWidget {
  const _CompactUnfurl({
    required this.domain,
    required this.title,
    required this.colorA,
    required this.colorB,
    required this.icon,
    this.onTap,
  });
  final String domain;
  final String title;
  final int colorA;
  final int colorB;
  final IconData icon;
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        clipBehavior: Clip.antiAlias,
        decoration: BoxDecoration(
          color: SocialFeedLinkPreviewScreen._surface,
          borderRadius: BorderRadius.circular(12),
          border: Border.all(color: SocialFeedLinkPreviewScreen._hairline),
        ),
        child: Row(
          children: <Widget>[
            SizedBox(
              width: 78,
              height: 78,
              child: CustomPaint(
                painter: _BannerPainter(Color(colorA), Color(colorB), icon),
              ),
            ),
            Expanded(
              child: Padding(
                padding: const EdgeInsets.fromLTRB(12, 10, 10, 10),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: <Widget>[
                    _DomainRow(domain: domain, colorA: colorA, colorB: colorB),
                    const SizedBox(height: 6),
                    Text(
                      title,
                      maxLines: 2,
                      overflow: TextOverflow.ellipsis,
                      style: const TextStyle(
                        fontFamily: SocialFeedLinkPreviewScreen._font,
                        fontSize: 14,
                        fontWeight: FontWeight.w600,
                        height: 1.3,
                        color: SocialFeedLinkPreviewScreen._textHi,
                      ),
                    ),
                  ],
                ),
              ),
            ),
            const Padding(
              padding: EdgeInsets.only(right: 12),
              child: Icon(Icons.open_in_new,
                  size: 16, color: SocialFeedLinkPreviewScreen._muted),
            ),
          ],
        ),
      ),
    );
  }
}

class _DomainRow extends StatelessWidget {
  const _DomainRow({
    required this.domain,
    required this.colorA,
    required this.colorB,
  });
  final String domain;
  final int colorA;
  final int colorB;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: <Widget>[
        Container(
          width: 15,
          height: 15,
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(4),
            gradient: LinearGradient(
              begin: Alignment.topLeft,
              end: Alignment.bottomRight,
              colors: <Color>[Color(colorA), Color(colorB)],
            ),
          ),
        ),
        const SizedBox(width: 7),
        Flexible(
          child: Text(
            domain.toUpperCase(),
            overflow: TextOverflow.ellipsis,
            style: const TextStyle(
              fontFamily: SocialFeedLinkPreviewScreen._font,
              fontSize: 11,
              fontWeight: FontWeight.w600,
              letterSpacing: 0.6,
              color: SocialFeedLinkPreviewScreen._muted,
            ),
          ),
        ),
      ],
    );
  }
}

class _MetaChip extends StatelessWidget {
  const _MetaChip({required this.label});
  final String label;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
      decoration: BoxDecoration(
        color: SocialFeedLinkPreviewScreen._brand.withValues(alpha: 0.14),
        borderRadius: BorderRadius.circular(20),
      ),
      child: Text(
        label,
        style: const TextStyle(
          fontFamily: SocialFeedLinkPreviewScreen._font,
          fontSize: 11.5,
          fontWeight: FontWeight.w700,
          color: SocialFeedLinkPreviewScreen._brand,
        ),
      ),
    );
  }
}

class _ActionBar extends StatelessWidget {
  const _ActionBar();

  @override
  Widget build(BuildContext context) {
    return const Row(
      children: <Widget>[
        _Action(icon: Icons.favorite_border, label: '146'),
        SizedBox(width: 18),
        _Action(icon: Icons.chat_bubble_outline, label: '22'),
        SizedBox(width: 18),
        _Action(icon: Icons.share_outlined, label: 'Share'),
        Spacer(),
        Icon(Icons.bookmark_border,
            size: 21, color: SocialFeedLinkPreviewScreen._muted),
        SizedBox(width: 8),
      ],
    );
  }
}

class _Action extends StatelessWidget {
  const _Action({required this.icon, required this.label});
  final IconData icon;
  final String label;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: <Widget>[
        Icon(icon, size: 20, color: SocialFeedLinkPreviewScreen._muted),
        const SizedBox(width: 6),
        Text(
          label,
          style: const TextStyle(
            fontFamily: SocialFeedLinkPreviewScreen._font,
            fontSize: 13,
            fontWeight: FontWeight.w500,
            color: SocialFeedLinkPreviewScreen._muted,
          ),
        ),
      ],
    );
  }
}

class _Monogram extends StatelessWidget {
  const _Monogram({
    required this.initials,
    required this.colorA,
    required this.colorB,
  });
  final String initials;
  final Color colorA;
  final Color colorB;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 42,
      height: 42,
      decoration: BoxDecoration(
        shape: BoxShape.circle,
        gradient: LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: <Color>[colorA, colorB],
        ),
      ),
      child: Center(
        child: Text(
          initials,
          style: const TextStyle(
            fontFamily: SocialFeedLinkPreviewScreen._font,
            fontSize: 15,
            fontWeight: FontWeight.w700,
            color: Colors.white,
          ),
        ),
      ),
    );
  }
}

class _BannerPainter extends CustomPainter {
  _BannerPainter(this.colorA, this.colorB, this.icon);
  final Color colorA;
  final Color colorB;
  final IconData icon;

  @override
  void paint(Canvas canvas, Size size) {
    final Rect rect = Offset.zero & size;
    canvas.drawRect(
      rect,
      Paint()
        ..shader = LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: <Color>[
            colorA.withValues(alpha: 0.9),
            colorB.withValues(alpha: 0.7),
          ],
        ).createShader(rect),
    );
    final Paint streak = Paint()
      ..color = Colors.white.withValues(alpha: 0.06)
      ..style = PaintingStyle.stroke
      ..strokeWidth = size.width * 0.10;
    for (int i = -1; i < 5; i++) {
      final double x = size.width * (0.2 * i);
      canvas.drawLine(Offset(x, size.height), Offset(x + size.height, 0), streak);
    }
    // Painted glyph badge centered.
    final TextPainter tp = TextPainter(
      text: TextSpan(
        text: String.fromCharCode(icon.codePoint),
        style: TextStyle(
          fontSize: size.shortestSide * 0.42,
          fontFamily: icon.fontFamily,
          package: icon.fontPackage,
          color: Colors.white.withValues(alpha: 0.9),
        ),
      ),
      textDirection: TextDirection.ltr,
    )..layout();
    tp.paint(
      canvas,
      Offset(
        (size.width - tp.width) / 2,
        (size.height - tp.height) / 2,
      ),
    );
  }

  @override
  bool shouldRepaint(covariant _BannerPainter old) =>
      old.colorA != colorA || old.colorB != colorB || old.icon != icon;
}

Plus bundled 1 binary asset (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 social-feed-link-preview

2. AI agent (MCP)

Connect FlutterKit's MCP server in Claude or Cursor and just ask your agent to install social-feed-link-preview — it fetches and writes the files for you.

FAQ

Can I use this link preview feed in a commercial app for free?

Yes. FlutterKit screens are free for personal and commercial projects under an MIT-style licence. Copy the code from this page or run `flutterkit add social-feed-link-preview`, ship it, and keep the code — no key, no attribution.

Does it depend on any pub packages or fonts?

No packages — the file imports only `package:flutter/material.dart`, and every thumbnail and favicon is painted with `CustomPainter` or a gradient `BoxDecoration`. The one asset is the Inter font, which `flutterkit add social-feed-link-preview` bundles and registers in your pubspec for you.

How do I feed real Open Graph data into the cards?

Fetch the page's `og:title`, `og:description` and `og:site_name` on your server and pass them straight into `_LargeUnfurl` or `_CompactUnfurl`. To show the real `og:image`, replace the `CustomPaint` in the banner slot with an `Image.network` inside the same `AspectRatio` or 78px `SizedBox`, and keep `_BannerPainter` as the fallback when a page has no image.

Why is the icon drawn with a TextPainter instead of an Icon widget?

Because `_BannerPainter` runs inside `paint()`, where there is no widget tree. Material icons are glyphs in the `MaterialIcons` font, so `String.fromCharCode(icon.codePoint)` with `icon.fontFamily` and `icon.fontPackage` renders the same glyph on the canvas, and sizing it from `size.shortestSide * 0.42` keeps it proportional in both the wide banner and the 78px square.

Which Flutter version does this need?

Flutter 3.22 or newer — the constructor uses the `super.key` super-parameter and the painter and meta chip call `Color.withValues(alpha: ...)`. On an older SDK, replace each `withValues(alpha: x)` with `withOpacity(x)` and expand the constructor to `{Key? key, ...}) : super(key: key)`.

Related screens