Social90 views

How to Build a Pull-to-Refresh Feed State in Flutter (Full Code + Preview)

The moment a social feed reloads is easy to get wrong: the old posts vanish, a bare spinner sits on a blank screen, and the reader loses their place. This tutorial builds the Pulse home feed frozen mid-refresh in Flutter. A `_SpinnerPainter` draws a hairline track with a rounded indigo arc, spun by a repeating 900ms `AnimationController`; the existing posts stay visible under `Opacity(0.55)`; and a floating '12 new posts' pill hovers over the feed. Pure Flutter, no packages, with painted media instead of network images.

Pulse · Pull to Refresh — Social Flutter UI screen
Live preview — Pulse · Pull to Refresh, built in pure Flutter.

What you'll build

  • A `_SpinnerPainter` CustomPainter: a `#26262F` track circle plus a 1.55π indigo arc whose start angle is driven by `_spin.value`
  • A 66px refresh reveal row that pairs the 22px animated spinner with a muted 'Checking for new posts…' caption
  • A feed `ListView` dimmed to 55% opacity inside a `Stack`, with a `Positioned` `_NewPostsPill` floating above it
  • A `_PostCard` with a gradient `_Monogram` avatar, computed initials, a 16:10 `_MediaPainter` image and an `_ActionBar` with a `_fmt` count helper
  • Four nullable callbacks — `onShowNew`, `onSearch`, `onNotifications`, `onPost(id)` — so the screen stays backend-agnostic

Step-by-step build

1

Create the file

Add a new file at lib/social_feed_refresh/social_feed_refresh_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.

A stateful screen with a palette and four callbacks

social_feed_refresh_screen.dart
import 'dart:math' as math;

import 'package:flutter/material.dart';

/// Pull-to-Refresh State — the Pulse home mid-refresh: a painted spinner reveal
/// under the top bar, the current feed dimmed beneath it, and a floating
/// "new posts" pill over the feed. The spinner animates but is drawn as a long
/// arc so it still reads as loading in a single static frame. Self-contained per
/// CONVENTIONS.md: pure Flutter, bundled Inter font, own dark theme, SafeArea,
/// overflow-proof.
class SocialFeedRefreshScreen extends StatefulWidget {
  const SocialFeedRefreshScreen({
    super.key,
    this.onShowNew,
    this.onSearch,
    this.onNotifications,
    this.onPost,
  });

  final VoidCallback? onShowNew;
  final VoidCallback? onSearch;
  final VoidCallback? onNotifications;
  final ValueChanged<String>? onPost;

  static const String _font = 'Inter';
  static const Color _bg = Color(0xFF0B0B0F);
  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
  State<SocialFeedRefreshScreen> createState() =>
      _SocialFeedRefreshScreenState();
}

`SocialFeedRefreshScreen` is a `StatefulWidget` for exactly one reason: the spinner needs an `AnimationController`, and a controller needs a `TickerProvider`. Everything else on the screen is static. The constructor takes four nullable callbacks — `onShowNew` for the floating pill, `onSearch` and `onNotifications` for the top-bar icons, and `onPost` typed as `ValueChanged<String>` so a tap on a card hands back the post id rather than an index. The Pulse palette is declared as `static const` colours on the widget class so the private helper widgets below can reference them as `SocialFeedRefreshScreen._brand` without a theme lookup: `#0B0B0F` near-black background, `#6E56F7` indigo brand, `#26262F` hairline, `#F4F4F7` high text and `#8A8A99` muted. `dart:math` is imported as `math` purely for the arc geometry in the painter.

Three posts as const data, and a repeating 900ms controller

social_feed_refresh_screen.dart
class _SocialFeedRefreshScreenState extends State<SocialFeedRefreshScreen>
    with SingleTickerProviderStateMixin {
  late final AnimationController _spin;

  static const List<_Post> _posts = <_Post>[
    _Post(
      id: 'r1',
      name: 'Maya Chen',
      handle: 'mayabuilds',
      time: '1m',
      colorA: 0xFF6E56F7,
      colorB: 0xFF9B8CFF,
      body:
          'Pulled to refresh and watched the whole feed exhale. Little details '
          'like this are the difference between a tool and a habit.',
      likes: 132,
      comments: 14,
      hasMedia: true,
    ),
    _Post(
      id: 'r2',
      name: 'Dev Kapoor',
      handle: 'devk',
      time: '22m',
      colorA: 0xFF34D399,
      colorB: 0xFF6E56F7,
      body: 'New posts are loading. Give it a breath — good things buffer.',
      likes: 508,
      comments: 41,
      hasMedia: false,
    ),
    _Post(
      id: 'r3',
      name: 'Lena Ortiz',
      handle: 'lenaux',
      time: '1h',
      colorA: 0xFFF4476B,
      colorB: 0xFFFBBF24,
      body: 'Palette test from this morning’s shoot.',
      likes: 274,
      comments: 19,
      hasMedia: true,
    ),
  ];

  @override
  void initState() {
    super.initState();
    _spin = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 900),
    )..repeat();
  }

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

The state class mixes in `SingleTickerProviderStateMixin` because there is only one ticker to own. `_posts` is a `static const List<_Post>` of three entries; each carries two ARGB ints, `colorA` and `colorB`, rather than `Color` objects so the list can be fully const — the ints are wrapped in `Color(...)` later at the point of use. Those pairs drive both the avatar gradient and the painted media, so a post's picture and its author's monogram share a colour family. The `hasMedia` flag on the middle post is `false`, which gives the feed a text-only card between two photo cards. In `initState` the controller is created with a 900ms duration and immediately chained with `..repeat()`, so `_spin.value` cycles 0→1 forever; `dispose` tears it down so the ticker does not outlive the screen.

The refresh reveal row and the dimmed feed

social_feed_refresh_screen.dart
  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.dark(useMaterial3: true),
      child: Scaffold(
        backgroundColor: SocialFeedRefreshScreen._bg,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              _TopBar(
                onSearch: widget.onSearch,
                onNotifications: widget.onNotifications,
              ),
              // Refresh reveal.
              Container(
                height: 66,
                alignment: Alignment.center,
                child: Row(
                  mainAxisSize: MainAxisSize.min,
                  children: <Widget>[
                    SizedBox(
                      width: 22,
                      height: 22,
                      child: AnimatedBuilder(
                        animation: _spin,
                        builder: (BuildContext context, Widget? child) {
                          return CustomPaint(
                            painter: _SpinnerPainter(turns: _spin.value),
                          );
                        },
                      ),
                    ),
                    const SizedBox(width: 12),
                    const Text(
                      'Checking for new posts…',
                      style: TextStyle(
                        fontFamily: SocialFeedRefreshScreen._font,
                        fontSize: 13.5,
                        fontWeight: FontWeight.w500,
                        color: SocialFeedRefreshScreen._muted,
                      ),
                    ),
                  ],
                ),
              ),
              Expanded(
                child: Stack(
                  children: <Widget>[
                    Opacity(
                      opacity: 0.55,
                      child: ListView(
                        padding: EdgeInsets.zero,
                        children: <Widget>[
                          for (final _Post p in _posts)
                            _PostCard(
                              post: p,
                              onTap: () => widget.onPost?.call(p.id),
                            ),
                          const SizedBox(height: 24),
                        ],
                      ),
                    ),
                    Positioned(
                      top: 12,
                      left: 0,
                      right: 0,
                      child: Center(
                        child: _NewPostsPill(onTap: widget.onShowNew),
                      ),
                    ),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

`build` forces `ThemeData.dark(useMaterial3: true)` via a `Theme` wrapper so the screen looks identical whatever the host app's theme is. Under the `_TopBar` sits a 66px `Container` centred with `Alignment.center` holding a min-width `Row`: a 22×22 `SizedBox` around an `AnimatedBuilder` that rebuilds only the `CustomPaint` each tick, passing `turns: _spin.value` into `_SpinnerPainter`, then 12px of space and the 13.5px `w500` muted caption 'Checking for new posts…'. The important choice is what happens below: the feed goes inside an `Expanded` `Stack`, and the `ListView` of `_PostCard`s is wrapped in `Opacity(opacity: 0.55)`. The old posts stay readable but visibly recede, telling the user the content is stale without hiding it. A `Positioned` at `top: 12` spanning left-to-right centres the `_NewPostsPill` over the first card. Each card's `onTap` calls `widget.onPost?.call(p.id)` so the parent learns which post was tapped.

The floating 'new posts' pill

social_feed_refresh_screen.dart
class _NewPostsPill extends StatelessWidget {
  const _NewPostsPill({this.onTap});
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
        decoration: BoxDecoration(
          color: SocialFeedRefreshScreen._brand,
          borderRadius: BorderRadius.circular(24),
          boxShadow: <BoxShadow>[
            BoxShadow(
              color: SocialFeedRefreshScreen._brand.withValues(alpha: 0.35),
              blurRadius: 18,
              offset: const Offset(0, 6),
            ),
          ],
        ),
        child: const Row(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Icon(Icons.arrow_upward, size: 16, color: Colors.white),
            SizedBox(width: 8),
            Text(
              '12 new posts',
              style: TextStyle(
                fontFamily: SocialFeedRefreshScreen._font,
                fontSize: 13.5,
                fontWeight: FontWeight.w700,
                color: Colors.white,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

`_NewPostsPill` is a `GestureDetector` around a `Container` filled with the `_brand` indigo, padded 16×9 and rounded to 24px so it becomes a true capsule at its natural height. What makes it float rather than sit flat on the dimmed feed is the single `BoxShadow`: the brand colour itself at `withValues(alpha: 0.35)`, blurred 18px and offset 6px downward, which reads as a coloured glow instead of a grey drop shadow — a grey shadow would disappear against the `#0B0B0F` background. The content is a `const Row` with `mainAxisSize: MainAxisSize.min` — an `arrow_upward` icon at 16px, 8px gap, and '12 new posts' in 13.5px `w700` white. The count is hard-coded here; it is the obvious value to lift into a parameter once you connect a real feed.

A 56px top bar with two icon actions

social_feed_refresh_screen.dart
class _TopBar extends StatelessWidget {
  const _TopBar({this.onSearch, this.onNotifications});
  final VoidCallback? onSearch;
  final VoidCallback? onNotifications;

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 56,
      padding: const EdgeInsets.fromLTRB(20, 0, 8, 0),
      decoration: const BoxDecoration(
        border: Border(
          bottom: BorderSide(color: SocialFeedRefreshScreen._hairline),
        ),
      ),
      child: Row(
        children: <Widget>[
          const Text(
            'Home',
            style: TextStyle(
              fontFamily: SocialFeedRefreshScreen._font,
              fontSize: 22,
              fontWeight: FontWeight.w800,
              letterSpacing: -0.8,
              color: SocialFeedRefreshScreen._textHi,
            ),
          ),
          const Spacer(),
          IconButton(
            onPressed: onSearch,
            icon: const Icon(Icons.search,
                size: 24, color: SocialFeedRefreshScreen._textHi),
            splashRadius: 22,
          ),
          IconButton(
            onPressed: onNotifications,
            icon: const Icon(Icons.notifications_none,
                size: 24, color: SocialFeedRefreshScreen._textHi),
            splashRadius: 22,
          ),
        ],
      ),
    );
  }
}

`_TopBar` is a fixed-height 56px `Container` with asymmetric padding — 20px on the left so 'Home' aligns with the card text below, but only 8px on the right because `IconButton` already carries its own 48px touch target and would otherwise look inset. The bottom edge is a `Border(bottom: BorderSide(color: _hairline))` rather than a `Divider`, so the line belongs to the bar and does not add a widget to the column. The 'Home' title is 22px `w800` with `letterSpacing: -0.8` for the tight display look, a `Spacer` pushes both icons to the trailing edge, and each `IconButton` sets `splashRadius: 22` so the ripple hugs the 24px glyph instead of spreading the full Material default. `onPressed` receives the nullable callbacks directly — passing `null` simply disables the button.

The post model and the card layout

social_feed_refresh_screen.dart
// ── post card ───────────────────────────────────────────────────────────────
class _Post {
  const _Post({
    required this.id,
    required this.name,
    required this.handle,
    required this.time,
    required this.colorA,
    required this.colorB,
    required this.body,
    required this.likes,
    required this.comments,
    required this.hasMedia,
  });
  final String id;
  final String name;
  final String handle;
  final String time;
  final int colorA;
  final int colorB;
  final String body;
  final int likes;
  final int comments;
  final bool hasMedia;
}

class _PostCard extends StatelessWidget {
  const _PostCard({required this.post, this.onTap});
  final _Post post;
  final VoidCallback? onTap;

  String get _initials {
    final List<String> p = post.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: onTap,
      behavior: HitTestBehavior.opaque,
      child: Container(
        padding: const EdgeInsets.fromLTRB(16, 16, 12, 12),
        decoration: const BoxDecoration(
          border: Border(
            bottom: BorderSide(color: SocialFeedRefreshScreen._hairline),
          ),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Row(
              children: <Widget>[
                _Monogram(
                  initials: _initials,
                  colorA: Color(post.colorA),
                  colorB: Color(post.colorB),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Row(
                        children: <Widget>[
                          Flexible(
                            child: Text(
                              post.name,
                              overflow: TextOverflow.ellipsis,
                              style: const TextStyle(
                                fontFamily: SocialFeedRefreshScreen._font,
                                fontSize: 15,
                                fontWeight: FontWeight.w700,
                                color: SocialFeedRefreshScreen._textHi,
                              ),
                            ),
                          ),
                          const SizedBox(width: 6),
                          const Icon(Icons.verified,
                              size: 15, color: SocialFeedRefreshScreen._brand),
                        ],
                      ),
                      Text(
                        '@${post.handle} · ${post.time}',
                        style: const TextStyle(
                          fontFamily: SocialFeedRefreshScreen._font,
                          fontSize: 12.5,
                          color: SocialFeedRefreshScreen._muted,
                        ),
                      ),
                    ],
                  ),
                ),
                const Icon(Icons.more_horiz,
                    color: SocialFeedRefreshScreen._muted, size: 22),
                const SizedBox(width: 6),
              ],
            ),
            const SizedBox(height: 10),
            Text(
              post.body,
              style: const TextStyle(
                fontFamily: SocialFeedRefreshScreen._font,
                fontSize: 14.5,
                height: 1.5,
                color: Color(0xFFDDDDE6),
              ),
            ),
            if (post.hasMedia) ...<Widget>[
              const SizedBox(height: 12),
              ClipRRect(
                borderRadius: BorderRadius.circular(14),
                child: AspectRatio(
                  aspectRatio: 16 / 10,
                  child: CustomPaint(
                    painter:
                        _MediaPainter(Color(post.colorA), Color(post.colorB)),
                    child: const SizedBox.expand(),
                  ),
                ),
              ),
            ],
            const SizedBox(height: 6),
            _ActionBar(likes: post.likes, comments: post.comments),
          ],
        ),
      ),
    );
  }
}

`_Post` is a plain const data class — ten required fields, no methods — which keeps the `_posts` list const-constructible. `_PostCard` derives the avatar initials in a getter: it trims and splits the name on `RegExp(r'\s+')`, returns a single uppercased first character for one-word names (using `characters.first` so a multi-code-unit glyph is not split), otherwise first-of-first plus first-of-last. The card is a `GestureDetector` with `HitTestBehavior.opaque` so taps on the padding count, padded 16/16/12/12 and closed with a bottom hairline so cards separate without a card surface. The header `Row` puts the name in `Flexible` with ellipsis so a long name yields space to the 15px indigo `verified` icon, followed by an '@handle · time' line at 12.5px muted. Body text is 14.5px at `height: 1.5` in a slightly dimmer `#DDDDE6`. When `hasMedia` is true, a spread inserts a 12px gap and a `ClipRRect(14)` around a 16:10 `AspectRatio` whose child is a `CustomPaint` running `_MediaPainter` over `SizedBox.expand()`.

Action bar with a count formatter

social_feed_refresh_screen.dart
class _ActionBar extends StatelessWidget {
  const _ActionBar({required this.likes, required this.comments});
  final int likes;
  final int comments;

  String _fmt(int n) => n >= 1000 ? '${(n / 1000).toStringAsFixed(1)}k' : '$n';

  @override
  Widget build(BuildContext context) {
    return Row(
      children: <Widget>[
        _Action(icon: Icons.favorite_border, label: _fmt(likes)),
        const SizedBox(width: 18),
        _Action(icon: Icons.chat_bubble_outline, label: _fmt(comments)),
        const SizedBox(width: 18),
        const _Action(icon: Icons.share_outlined, label: 'Share'),
        const Spacer(),
        const Icon(Icons.bookmark_border,
            size: 21, color: SocialFeedRefreshScreen._muted),
        const 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: SocialFeedRefreshScreen._muted),
        const SizedBox(width: 6),
        Text(
          label,
          style: const TextStyle(
            fontFamily: SocialFeedRefreshScreen._font,
            fontSize: 13,
            fontWeight: FontWeight.w500,
            color: SocialFeedRefreshScreen._muted,
          ),
        ),
      ],
    );
  }
}

`_ActionBar` takes raw ints for likes and comments and runs them through `_fmt`, a one-line helper that returns the number as-is under 1000 and otherwise divides by 1000 with `toStringAsFixed(1)` and a 'k' suffix — so 508 stays '508' while 1120 would become '1.1k'. Three `_Action` widgets follow — `favorite_border`, `chat_bubble_outline` and `share_outlined` — each 20px muted icon plus a 13px `w500` label with a 6px gap, separated by 18px `SizedBox`es rather than `MainAxisAlignment.spaceBetween` so the group stays clustered on the left. A `Spacer` then throws the 21px `bookmark_border` to the trailing edge with an 8px inset. `_Action` is deliberately a display-only `Row` with no tap handling; wiring like/comment taps is left to the host, which already receives `onPost` for the card as a whole.

Monogram avatar, spinner geometry and painted media

social_feed_refresh_screen.dart
// ── monogram + painters ─────────────────────────────────────────────────────
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: SocialFeedRefreshScreen._font,
            fontSize: 15,
            fontWeight: FontWeight.w700,
            color: Colors.white,
          ),
        ),
      ),
    );
  }
}

class _SpinnerPainter extends CustomPainter {
  _SpinnerPainter({required this.turns});
  final double turns;

  @override
  void paint(Canvas canvas, Size size) {
    final Offset center = size.center(Offset.zero);
    final double radius = size.width / 2 - 2;
    final Rect rect = Rect.fromCircle(center: center, radius: radius);
    canvas.drawCircle(
      center,
      radius,
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 3
        ..color = SocialFeedRefreshScreen._hairline,
    );
    final double start = -math.pi / 2 + turns * 2 * math.pi;
    canvas.drawArc(
      rect,
      start,
      math.pi * 1.55,
      false,
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 3
        ..strokeCap = StrokeCap.round
        ..color = SocialFeedRefreshScreen._brand,
    );
  }

  @override
  bool shouldRepaint(covariant _SpinnerPainter old) => old.turns != turns;
}

class _MediaPainter extends CustomPainter {
  _MediaPainter(this.colorA, this.colorB);
  final Color colorA;
  final Color colorB;

  @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.85),
            colorB.withValues(alpha: 0.65),
          ],
        ).createShader(rect),
    );
    final Paint streak = Paint()
      ..color = Colors.white.withValues(alpha: 0.06)
      ..style = PaintingStyle.stroke
      ..strokeWidth = size.width * 0.14;
    for (int i = -1; i < 4; i++) {
      final double x = size.width * (0.22 * i);
      canvas.drawLine(Offset(x, size.height), Offset(x + size.height, 0), streak);
    }
    canvas.drawCircle(
      Offset(size.width * 0.74, size.height * 0.30),
      size.height * 0.12,
      Paint()..color = Colors.white.withValues(alpha: 0.18),
    );
  }

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

`_Monogram` is a 42px circle whose `BoxDecoration` carries a top-left to bottom-right `LinearGradient` of the post's two colours, with initials centred in 15px `w700` white. `_SpinnerPainter` is the heart of the screen. It computes `radius = size.width / 2 - 2` so the 3px stroke never clips the 22px box, draws a full `_hairline` track circle, then an arc on the same `Rect.fromCircle`. The start angle is `-π/2 + turns * 2π` — twelve o'clock, rotated by one full turn per controller cycle — and the sweep is a long `π * 1.55`, roughly 280°, with `StrokeCap.round`. That long arc is why the frozen frame still reads as 'loading': a short arc looks like a static ring gap. `shouldRepaint` compares `turns`, so it repaints only when the value changes. `_MediaPainter` fills a diagonal gradient of the two colours at 0.85 and 0.65 alpha, then draws five diagonal streaks in a loop from `i = -1` to `3` at 6% white with a stroke 14% of the width, and a soft sun disc at 74%/30% with radius 12% of the height — an image with no asset file.

Full code

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

import 'dart:math' as math;

import 'package:flutter/material.dart';

/// Pull-to-Refresh State — the Pulse home mid-refresh: a painted spinner reveal
/// under the top bar, the current feed dimmed beneath it, and a floating
/// "new posts" pill over the feed. The spinner animates but is drawn as a long
/// arc so it still reads as loading in a single static frame. Self-contained per
/// CONVENTIONS.md: pure Flutter, bundled Inter font, own dark theme, SafeArea,
/// overflow-proof.
class SocialFeedRefreshScreen extends StatefulWidget {
  const SocialFeedRefreshScreen({
    super.key,
    this.onShowNew,
    this.onSearch,
    this.onNotifications,
    this.onPost,
  });

  final VoidCallback? onShowNew;
  final VoidCallback? onSearch;
  final VoidCallback? onNotifications;
  final ValueChanged<String>? onPost;

  static const String _font = 'Inter';
  static const Color _bg = Color(0xFF0B0B0F);
  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
  State<SocialFeedRefreshScreen> createState() =>
      _SocialFeedRefreshScreenState();
}

class _SocialFeedRefreshScreenState extends State<SocialFeedRefreshScreen>
    with SingleTickerProviderStateMixin {
  late final AnimationController _spin;

  static const List<_Post> _posts = <_Post>[
    _Post(
      id: 'r1',
      name: 'Maya Chen',
      handle: 'mayabuilds',
      time: '1m',
      colorA: 0xFF6E56F7,
      colorB: 0xFF9B8CFF,
      body:
          'Pulled to refresh and watched the whole feed exhale. Little details '
          'like this are the difference between a tool and a habit.',
      likes: 132,
      comments: 14,
      hasMedia: true,
    ),
    _Post(
      id: 'r2',
      name: 'Dev Kapoor',
      handle: 'devk',
      time: '22m',
      colorA: 0xFF34D399,
      colorB: 0xFF6E56F7,
      body: 'New posts are loading. Give it a breath — good things buffer.',
      likes: 508,
      comments: 41,
      hasMedia: false,
    ),
    _Post(
      id: 'r3',
      name: 'Lena Ortiz',
      handle: 'lenaux',
      time: '1h',
      colorA: 0xFFF4476B,
      colorB: 0xFFFBBF24,
      body: 'Palette test from this morning’s shoot.',
      likes: 274,
      comments: 19,
      hasMedia: true,
    ),
  ];

  @override
  void initState() {
    super.initState();
    _spin = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 900),
    )..repeat();
  }

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

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.dark(useMaterial3: true),
      child: Scaffold(
        backgroundColor: SocialFeedRefreshScreen._bg,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              _TopBar(
                onSearch: widget.onSearch,
                onNotifications: widget.onNotifications,
              ),
              // Refresh reveal.
              Container(
                height: 66,
                alignment: Alignment.center,
                child: Row(
                  mainAxisSize: MainAxisSize.min,
                  children: <Widget>[
                    SizedBox(
                      width: 22,
                      height: 22,
                      child: AnimatedBuilder(
                        animation: _spin,
                        builder: (BuildContext context, Widget? child) {
                          return CustomPaint(
                            painter: _SpinnerPainter(turns: _spin.value),
                          );
                        },
                      ),
                    ),
                    const SizedBox(width: 12),
                    const Text(
                      'Checking for new posts…',
                      style: TextStyle(
                        fontFamily: SocialFeedRefreshScreen._font,
                        fontSize: 13.5,
                        fontWeight: FontWeight.w500,
                        color: SocialFeedRefreshScreen._muted,
                      ),
                    ),
                  ],
                ),
              ),
              Expanded(
                child: Stack(
                  children: <Widget>[
                    Opacity(
                      opacity: 0.55,
                      child: ListView(
                        padding: EdgeInsets.zero,
                        children: <Widget>[
                          for (final _Post p in _posts)
                            _PostCard(
                              post: p,
                              onTap: () => widget.onPost?.call(p.id),
                            ),
                          const SizedBox(height: 24),
                        ],
                      ),
                    ),
                    Positioned(
                      top: 12,
                      left: 0,
                      right: 0,
                      child: Center(
                        child: _NewPostsPill(onTap: widget.onShowNew),
                      ),
                    ),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _NewPostsPill extends StatelessWidget {
  const _NewPostsPill({this.onTap});
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
        decoration: BoxDecoration(
          color: SocialFeedRefreshScreen._brand,
          borderRadius: BorderRadius.circular(24),
          boxShadow: <BoxShadow>[
            BoxShadow(
              color: SocialFeedRefreshScreen._brand.withValues(alpha: 0.35),
              blurRadius: 18,
              offset: const Offset(0, 6),
            ),
          ],
        ),
        child: const Row(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Icon(Icons.arrow_upward, size: 16, color: Colors.white),
            SizedBox(width: 8),
            Text(
              '12 new posts',
              style: TextStyle(
                fontFamily: SocialFeedRefreshScreen._font,
                fontSize: 13.5,
                fontWeight: FontWeight.w700,
                color: Colors.white,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _TopBar extends StatelessWidget {
  const _TopBar({this.onSearch, this.onNotifications});
  final VoidCallback? onSearch;
  final VoidCallback? onNotifications;

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 56,
      padding: const EdgeInsets.fromLTRB(20, 0, 8, 0),
      decoration: const BoxDecoration(
        border: Border(
          bottom: BorderSide(color: SocialFeedRefreshScreen._hairline),
        ),
      ),
      child: Row(
        children: <Widget>[
          const Text(
            'Home',
            style: TextStyle(
              fontFamily: SocialFeedRefreshScreen._font,
              fontSize: 22,
              fontWeight: FontWeight.w800,
              letterSpacing: -0.8,
              color: SocialFeedRefreshScreen._textHi,
            ),
          ),
          const Spacer(),
          IconButton(
            onPressed: onSearch,
            icon: const Icon(Icons.search,
                size: 24, color: SocialFeedRefreshScreen._textHi),
            splashRadius: 22,
          ),
          IconButton(
            onPressed: onNotifications,
            icon: const Icon(Icons.notifications_none,
                size: 24, color: SocialFeedRefreshScreen._textHi),
            splashRadius: 22,
          ),
        ],
      ),
    );
  }
}

// ── post card ───────────────────────────────────────────────────────────────
class _Post {
  const _Post({
    required this.id,
    required this.name,
    required this.handle,
    required this.time,
    required this.colorA,
    required this.colorB,
    required this.body,
    required this.likes,
    required this.comments,
    required this.hasMedia,
  });
  final String id;
  final String name;
  final String handle;
  final String time;
  final int colorA;
  final int colorB;
  final String body;
  final int likes;
  final int comments;
  final bool hasMedia;
}

class _PostCard extends StatelessWidget {
  const _PostCard({required this.post, this.onTap});
  final _Post post;
  final VoidCallback? onTap;

  String get _initials {
    final List<String> p = post.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: onTap,
      behavior: HitTestBehavior.opaque,
      child: Container(
        padding: const EdgeInsets.fromLTRB(16, 16, 12, 12),
        decoration: const BoxDecoration(
          border: Border(
            bottom: BorderSide(color: SocialFeedRefreshScreen._hairline),
          ),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Row(
              children: <Widget>[
                _Monogram(
                  initials: _initials,
                  colorA: Color(post.colorA),
                  colorB: Color(post.colorB),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Row(
                        children: <Widget>[
                          Flexible(
                            child: Text(
                              post.name,
                              overflow: TextOverflow.ellipsis,
                              style: const TextStyle(
                                fontFamily: SocialFeedRefreshScreen._font,
                                fontSize: 15,
                                fontWeight: FontWeight.w700,
                                color: SocialFeedRefreshScreen._textHi,
                              ),
                            ),
                          ),
                          const SizedBox(width: 6),
                          const Icon(Icons.verified,
                              size: 15, color: SocialFeedRefreshScreen._brand),
                        ],
                      ),
                      Text(
                        '@${post.handle} · ${post.time}',
                        style: const TextStyle(
                          fontFamily: SocialFeedRefreshScreen._font,
                          fontSize: 12.5,
                          color: SocialFeedRefreshScreen._muted,
                        ),
                      ),
                    ],
                  ),
                ),
                const Icon(Icons.more_horiz,
                    color: SocialFeedRefreshScreen._muted, size: 22),
                const SizedBox(width: 6),
              ],
            ),
            const SizedBox(height: 10),
            Text(
              post.body,
              style: const TextStyle(
                fontFamily: SocialFeedRefreshScreen._font,
                fontSize: 14.5,
                height: 1.5,
                color: Color(0xFFDDDDE6),
              ),
            ),
            if (post.hasMedia) ...<Widget>[
              const SizedBox(height: 12),
              ClipRRect(
                borderRadius: BorderRadius.circular(14),
                child: AspectRatio(
                  aspectRatio: 16 / 10,
                  child: CustomPaint(
                    painter:
                        _MediaPainter(Color(post.colorA), Color(post.colorB)),
                    child: const SizedBox.expand(),
                  ),
                ),
              ),
            ],
            const SizedBox(height: 6),
            _ActionBar(likes: post.likes, comments: post.comments),
          ],
        ),
      ),
    );
  }
}

class _ActionBar extends StatelessWidget {
  const _ActionBar({required this.likes, required this.comments});
  final int likes;
  final int comments;

  String _fmt(int n) => n >= 1000 ? '${(n / 1000).toStringAsFixed(1)}k' : '$n';

  @override
  Widget build(BuildContext context) {
    return Row(
      children: <Widget>[
        _Action(icon: Icons.favorite_border, label: _fmt(likes)),
        const SizedBox(width: 18),
        _Action(icon: Icons.chat_bubble_outline, label: _fmt(comments)),
        const SizedBox(width: 18),
        const _Action(icon: Icons.share_outlined, label: 'Share'),
        const Spacer(),
        const Icon(Icons.bookmark_border,
            size: 21, color: SocialFeedRefreshScreen._muted),
        const 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: SocialFeedRefreshScreen._muted),
        const SizedBox(width: 6),
        Text(
          label,
          style: const TextStyle(
            fontFamily: SocialFeedRefreshScreen._font,
            fontSize: 13,
            fontWeight: FontWeight.w500,
            color: SocialFeedRefreshScreen._muted,
          ),
        ),
      ],
    );
  }
}

// ── monogram + painters ─────────────────────────────────────────────────────
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: SocialFeedRefreshScreen._font,
            fontSize: 15,
            fontWeight: FontWeight.w700,
            color: Colors.white,
          ),
        ),
      ),
    );
  }
}

class _SpinnerPainter extends CustomPainter {
  _SpinnerPainter({required this.turns});
  final double turns;

  @override
  void paint(Canvas canvas, Size size) {
    final Offset center = size.center(Offset.zero);
    final double radius = size.width / 2 - 2;
    final Rect rect = Rect.fromCircle(center: center, radius: radius);
    canvas.drawCircle(
      center,
      radius,
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 3
        ..color = SocialFeedRefreshScreen._hairline,
    );
    final double start = -math.pi / 2 + turns * 2 * math.pi;
    canvas.drawArc(
      rect,
      start,
      math.pi * 1.55,
      false,
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 3
        ..strokeCap = StrokeCap.round
        ..color = SocialFeedRefreshScreen._brand,
    );
  }

  @override
  bool shouldRepaint(covariant _SpinnerPainter old) => old.turns != turns;
}

class _MediaPainter extends CustomPainter {
  _MediaPainter(this.colorA, this.colorB);
  final Color colorA;
  final Color colorB;

  @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.85),
            colorB.withValues(alpha: 0.65),
          ],
        ).createShader(rect),
    );
    final Paint streak = Paint()
      ..color = Colors.white.withValues(alpha: 0.06)
      ..style = PaintingStyle.stroke
      ..strokeWidth = size.width * 0.14;
    for (int i = -1; i < 4; i++) {
      final double x = size.width * (0.22 * i);
      canvas.drawLine(Offset(x, size.height), Offset(x + size.height, 0), streak);
    }
    canvas.drawCircle(
      Offset(size.width * 0.74, size.height * 0.30),
      size.height * 0.12,
      Paint()..color = Colors.white.withValues(alpha: 0.18),
    );
  }

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

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-refresh

2. AI agent (MCP)

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

FAQ

Can I use this pull-to-refresh screen in a commercial app?

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

Does this screen need any pub packages or fonts?

No packages — it is pure Flutter with `dart:math` for the arc angles. The only asset is the Inter font, which `flutterkit add social-feed-refresh` bundles and registers in your pubspec for you; if you paste the code manually, add Inter yourself or drop the `fontFamily` lines.

Which Flutter version does this require?

Flutter 3.22 or newer, because the pill shadow and `_MediaPainter` use `Color.withValues(alpha: ...)` and the constructor uses `super.key`. On an older SDK replace each `withValues(alpha: x)` with `withOpacity(x)` and expand the constructor to `{Key? key, ...} : super(key: key)`.

How do I turn this static state into a real pull-to-refresh?

Wrap the feed `ListView` in a `RefreshIndicator` and move the reveal row behind a boolean such as `_refreshing`. Start the controller with `..repeat()` only while the refresh future is pending, then set the flag false and raise `Opacity` back to 1.0 when the new posts arrive. Keep `_SpinnerPainter` if you want the custom arc instead of the Material indicator.

Where does the '12 new posts' count come from?

It is a literal inside `_NewPostsPill`. Add an `int count` parameter to the pill and to the screen, then interpolate `'$count new posts'`; hide the pill entirely when the count is zero by returning `SizedBox.shrink()` from the `Positioned` child, and use `onShowNew` to scroll to the top and merge the buffered posts.

Related screens