Social70 views

How to Build a Social Home Feed Screen in Flutter (Full Code + Preview)

A social app's home tab has to carry the entire product in one scroll — who is posting stories right now, what the feed looks like, and where every other tab lives. This tutorial builds Pulse's home feed in Flutter as one self-contained dark screen: a 56px brand bar with an unread dot, a 120px stories rail whose unseen rings are painted with a SweepGradient, three post cards covering text, single-image and two-up media, an end-of-feed state, and a five-tab nav with a floating composer. No network images, no packages.

Pulse · Home Feed — Social Flutter UI screen
Live preview — Pulse · Home Feed, built in pure Flutter.

What you'll build

  • A horizontal stories rail built from ListView.separated with a 'Your story' tile injected at index 0
  • Story avatars whose unseen ring is a SweepGradient stroke and whose seen ring is a flat grey one
  • Post cards that switch between no media, a 16:10 painted gradient and a two-up split, driven by one enum
  • Like and comment counts abbreviated to '1.1k' by a three-line formatter, with share and bookmark alongside
  • A five-tab bottom nav plus a squircle FloatingActionButton, both routing through injected callbacks

Step-by-step build

1

Create the file

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

The screen contract, palette and story fixtures

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

import 'package:flutter/material.dart';

/// Home Feed — the Pulse app shell and Home tab. A sticky brand top bar, a
/// horizontal stories rail with painted gradient unseen-rings, an infinite
/// column of post cards (text / photo / multi), and the 5-tab bottom nav with a
/// floating compose button. This is the post-onboarding app root; every action
/// is exposed as a callback so the gallery registry wires navigation and the
/// tab switch. Self-contained per CONVENTIONS.md: pure Flutter, bundled Inter
/// font, own dark theme, SafeArea, fully overflow-proof.
class SocialFeedHomeScreen extends StatelessWidget {
  const SocialFeedHomeScreen({
    super.key,
    this.onTab,
    this.onCompose,
    this.onSearch,
    this.onNotifications,
    this.onStory,
    this.onPost,
    this.onProfile,
  });

  /// Bottom-nav tab tap: 0 Home · 1 Explore · 2 Reels · 3 Chats · 4 Profile.
  final ValueChanged<int>? onTab;
  final VoidCallback? onCompose;
  final VoidCallback? onSearch;
  final VoidCallback? onNotifications;

  /// A story ring tap (index into the rail).
  final ValueChanged<int>? onStory;

  /// A post tap (post id).
  final ValueChanged<String>? onPost;
  final VoidCallback? onProfile;

  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);

  static const List<_Story> _stories = <_Story>[
    _Story('Maya Chen', 0xFF6E56F7, 0xFF9B8CFF, unseen: true),
    _Story('Dev Kapoor', 0xFF34D399, 0xFF6E56F7, unseen: true),
    _Story('Lena Ortiz', 0xFFF4476B, 0xFFFBBF24, unseen: true),
    _Story('Sam Ito', 0xFF9B8CFF, 0xFF34D399, unseen: false),
    _Story('Priya N', 0xFFFBBF24, 0xFFF4476B, unseen: true),
    _Story('Theo B', 0xFF6E56F7, 0xFF34D399, unseen: false),
  ];

`SocialFeedHomeScreen` is a `StatelessWidget` that owns no state at all — every interaction leaves through one of seven callbacks (`onTab`, `onCompose`, `onSearch`, `onNotifications`, `onStory`, `onPost`, `onProfile`), so the host app decides what navigation means. The palette is five constants: `_bg` at `#0B0B0F` is near-black rather than pure black so the `#26262F` hairlines still read; `_brand` indigo `#6E56F7` is spent only on the active tab, the verified tick, the unread dot and the FAB. `_stories` stores its two ring colours as raw `int` values instead of `Color` objects, which is what lets the whole list stay `const`.

Three posts, and the shell that stacks everything

social_feed_home_screen.dart
  static const List<_Post> _posts = <_Post>[
    _Post(
      id: 'p1',
      name: 'Maya Chen',
      handle: 'mayabuilds',
      time: '12m',
      colorA: 0xFF6E56F7,
      colorB: 0xFF9B8CFF,
      body:
          'Shipped the new composer today. Tap-and-hold to reorder blocks, '
          'inline polls, and a calmer draft view. Small thing, big relief.',
      likes: 248,
      comments: 31,
      media: _MediaKind.gradient,
    ),
    _Post(
      id: 'p2',
      name: 'Dev Kapoor',
      handle: 'devk',
      time: '48m',
      colorA: 0xFF34D399,
      colorB: 0xFF6E56F7,
      body:
          'Reminder: the best feed is the one you curate. Unfollow noise, '
          'follow craft. Your future self scrolls with intention.',
      likes: 1120,
      comments: 96,
      media: _MediaKind.none,
    ),
    _Post(
      id: 'p3',
      name: 'Lena Ortiz',
      handle: 'lenaux',
      time: '2h',
      colorA: 0xFFF4476B,
      colorB: 0xFFFBBF24,
      body: 'Golden hour on the studio wall. Three swatches made the cut.',
      likes: 542,
      comments: 44,
      media: _MediaKind.multi,
    ),
  ];

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.dark(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _bg,
        body: SafeArea(
          bottom: false,
          child: Column(
            children: <Widget>[
              const _TopBar(),
              Expanded(
                child: ListView(
                  padding: EdgeInsets.zero,
                  children: <Widget>[
                    _StoriesRail(
                      stories: _stories,
                      onTap: onStory,
                    ),
                    const Divider(height: 1, color: _hairline),
                    for (int i = 0; i < _posts.length; i++)
                      _PostCard(
                        post: _posts[i],
                        onTap: () => onPost?.call(_posts[i].id),
                      ),
                    const SizedBox(height: 24),
                    const _CaughtUp(),
                    const SizedBox(height: 24),
                  ],
                ),
              ),
            ],
          ),
        ),
        floatingActionButton: Padding(
          padding: const EdgeInsets.only(bottom: 4),
          child: FloatingActionButton(
            onPressed: onCompose,
            backgroundColor: _brand,
            elevation: 0,
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(18),
            ),
            child: const Icon(Icons.add, color: Colors.white, size: 28),
          ),
        ),
        bottomNavigationBar: _PulseNavBar(
          current: 0,
          onTab: onTab,
          onProfile: onProfile,
        ),
      ),
    );
  }
}

The `_posts` fixtures are chosen to exercise all three `_MediaKind` branches — a gradient photo, a plain text post, a multi-image post — so the layout is proven against every shape it can receive. `build` wraps a `Theme(data: ThemeData.dark(useMaterial3: true))` around the `Scaffold` so the screen keeps its own dark styling regardless of the host app's theme. `SafeArea(bottom: false)` guards the notch but leaves the bottom inset alone, because `_PulseNavBar` handles that itself. The `_TopBar` sits outside the `Expanded` `ListView`, which is what makes it sticky, and each `_PostCard` closes over `_posts[i].id` so `onPost` reports which post was tapped.

The brand bar and its unread dot

social_feed_home_screen.dart
class _TopBar extends StatelessWidget {
  const _TopBar();

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 56,
      padding: const EdgeInsets.fromLTRB(20, 0, 12, 0),
      decoration: const BoxDecoration(
        color: SocialFeedHomeScreen._bg,
        border: Border(
          bottom: BorderSide(color: SocialFeedHomeScreen._hairline),
        ),
      ),
      child: Row(
        children: <Widget>[
          SizedBox(
            width: 26,
            height: 26,
            child: CustomPaint(painter: _PulseMarkPainter()),
          ),
          const SizedBox(width: 10),
          const Text(
            'Pulse',
            style: TextStyle(
              fontFamily: SocialFeedHomeScreen._font,
              fontSize: 22,
              fontWeight: FontWeight.w800,
              letterSpacing: -0.8,
              color: SocialFeedHomeScreen._textHi,
            ),
          ),
          const Spacer(),
          _CircleIcon(icon: Icons.search, onTap: () {}),
          const SizedBox(width: 4),
          Stack(
            clipBehavior: Clip.none,
            children: <Widget>[
              _CircleIcon(icon: Icons.notifications_none, onTap: () {}),
              Positioned(
                right: 8,
                top: 8,
                child: Container(
                  width: 8,
                  height: 8,
                  decoration: const BoxDecoration(
                    color: SocialFeedHomeScreen._brand,
                    shape: BoxShape.circle,
                  ),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

class _CircleIcon extends StatelessWidget {
  const _CircleIcon({required this.icon, required this.onTap});
  final IconData icon;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    return IconButton(
      onPressed: onTap,
      icon: Icon(icon, size: 24, color: SocialFeedHomeScreen._textHi),
      splashRadius: 22,
    );
  }
}

A fixed 56px `Container` with a bottom `BorderSide` hairline holds a 26px `CustomPaint` logo, the 'Pulse' wordmark at 22px `w800` with `letterSpacing: -0.8` for that tightly-set product feel, then a `Spacer` pushing the two circle icons right. The notification badge is a `Stack` with `clipBehavior: Clip.none` and an 8px brand circle `Positioned(right: 8, top: 8)` — the `IconButton`'s own splash padding means the dot has to be inset from the corner to land on the bell glyph rather than beside it. Note the two `_CircleIcon`s are wired to empty `() {}` closures here, so pass `onSearch` and `onNotifications` through if you want them live.

The stories rail, from add-tile to painted ring

social_feed_home_screen.dart
// ── stories rail ────────────────────────────────────────────────────────────
class _Story {
  const _Story(this.name, this.colorA, this.colorB, {required this.unseen});
  final String name;
  final int colorA;
  final int colorB;
  final bool unseen;
}

class _StoriesRail extends StatelessWidget {
  const _StoriesRail({required this.stories, this.onTap});
  final List<_Story> stories;
  final ValueChanged<int>? onTap;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 120,
      child: ListView.separated(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
        itemCount: stories.length + 1,
        separatorBuilder: (BuildContext context, int _) =>
            const SizedBox(width: 14),
        itemBuilder: (BuildContext context, int i) {
          if (i == 0) {
            return const _AddStoryTile();
          }
          final _Story s = stories[i - 1];
          return _StoryItem(story: s, onTap: () => onTap?.call(i - 1));
        },
      ),
    );
  }
}

class _AddStoryTile extends StatelessWidget {
  const _AddStoryTile();

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 66,
      child: Column(
        children: <Widget>[
          Container(
            width: 62,
            height: 62,
            decoration: BoxDecoration(
              color: const Color(0xFF15151B),
              shape: BoxShape.circle,
              border: Border.all(color: SocialFeedHomeScreen._hairline),
            ),
            child: const Icon(Icons.add,
                color: SocialFeedHomeScreen._brand, size: 26),
          ),
          const SizedBox(height: 7),
          const Text(
            'Your story',
            maxLines: 1,
            overflow: TextOverflow.ellipsis,
            style: TextStyle(
              fontFamily: SocialFeedHomeScreen._font,
              fontSize: 11.5,
              color: SocialFeedHomeScreen._muted,
            ),
          ),
        ],
      ),
    );
  }
}

class _StoryItem extends StatelessWidget {
  const _StoryItem({required this.story, this.onTap});
  final _Story story;
  final VoidCallback? onTap;

  String get _initials {
    final List<String> p = story.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: SizedBox(
        width: 66,
        child: Column(
          children: <Widget>[
            SizedBox(
              width: 62,
              height: 62,
              child: CustomPaint(
                painter: _RingAvatarPainter(
                  colorA: Color(story.colorA),
                  colorB: Color(story.colorB),
                  unseen: story.unseen,
                ),
                child: Center(
                  child: Container(
                    width: 52,
                    height: 52,
                    decoration: BoxDecoration(
                      shape: BoxShape.circle,
                      gradient: LinearGradient(
                        begin: Alignment.topLeft,
                        end: Alignment.bottomRight,
                        colors: <Color>[
                          Color(story.colorA),
                          Color(story.colorB),
                        ],
                      ),
                    ),
                    child: Center(
                      child: Text(
                        _initials,
                        style: const TextStyle(
                          fontFamily: SocialFeedHomeScreen._font,
                          fontSize: 17,
                          fontWeight: FontWeight.w700,
                          color: Colors.white,
                        ),
                      ),
                    ),
                  ),
                ),
              ),
            ),
            const SizedBox(height: 7),
            Text(
              story.name.split(' ').first,
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
              style: const TextStyle(
                fontFamily: SocialFeedHomeScreen._font,
                fontSize: 11.5,
                color: SocialFeedHomeScreen._textHi,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

The rail is a 120px `SizedBox` around a horizontal `ListView.separated` with `itemCount: stories.length + 1`. Index 0 is special-cased to `_AddStoryTile`, and every later item reads `stories[i - 1]` and reports `onTap?.call(i - 1)` — that single offset is what lets the compose affordance live inside the same scrollable as the real stories instead of being pinned outside it. `separatorBuilder` supplies the 14px gaps so no tile carries trailing padding. `_StoryItem` puts a 62px `CustomPaint` ring around a 52px gradient circle; the 5px difference is the visible gap between ring and avatar. `_initials` splits on `RegExp(r'\s+')` and falls back to `characters.first` for a single-word name, so an emoji or accented first letter is never sliced mid-grapheme. The label prints only `story.name.split(' ').first`, ellipsised, so a long surname cannot widen a 66px tile.

The post model and the card that renders it

social_feed_home_screen.dart
// ── post card ───────────────────────────────────────────────────────────────
enum _MediaKind { none, gradient, multi }

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.media,
  });
  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 _MediaKind media;
}

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: SocialFeedHomeScreen._hairline),
          ),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Row(
              children: <Widget>[
                _Monogram(
                  initials: _initials,
                  colorA: Color(post.colorA),
                  colorB: Color(post.colorB),
                  size: 42,
                ),
                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: SocialFeedHomeScreen._font,
                                fontSize: 15,
                                fontWeight: FontWeight.w700,
                                color: SocialFeedHomeScreen._textHi,
                              ),
                            ),
                          ),
                          const SizedBox(width: 6),
                          const Icon(Icons.verified,
                              size: 15, color: SocialFeedHomeScreen._brand),
                        ],
                      ),
                      Text(
                        '@${post.handle} · ${post.time}',
                        style: const TextStyle(
                          fontFamily: SocialFeedHomeScreen._font,
                          fontSize: 12.5,
                          color: SocialFeedHomeScreen._muted,
                        ),
                      ),
                    ],
                  ),
                ),
                IconButton(
                  onPressed: () {},
                  icon: const Icon(Icons.more_horiz,
                      color: SocialFeedHomeScreen._muted, size: 22),
                  splashRadius: 20,
                ),
              ],
            ),
            const SizedBox(height: 10),
            Text(
              post.body,
              style: const TextStyle(
                fontFamily: SocialFeedHomeScreen._font,
                fontSize: 14.5,
                height: 1.5,
                color: Color(0xFFDDDDE6),
              ),
            ),
            if (post.media != _MediaKind.none) ...<Widget>[
              const SizedBox(height: 12),
              _PostMedia(post: post),
            ],
            const SizedBox(height: 6),
            _ActionBar(likes: post.likes, comments: post.comments),
          ],
        ),
      ),
    );
  }
}

`_MediaKind` is a three-value enum — `none`, `gradient`, `multi` — and it is the only switch the card needs to decide whether media appears at all. `_PostCard` separates itself from its neighbour with a bottom hairline instead of elevation or margin, so the feed reads as one continuous column rather than a stack of floating cards. In the name row the text is `Flexible` while `Icons.verified` is not, so a long display name ellipsises and the tick stays visible instead of being pushed off-screen. Body copy is 14.5px at `height: 1.5` in `#DDDDE6`, a step below `_textHi`, which keeps the author's name the brightest thing in the card. The media block is spread with `if (post.media != _MediaKind.none) ...<Widget>[...]`, so a text-only post never leaves a 12px orphan gap.

Two media layouts and the action bar

social_feed_home_screen.dart
class _PostMedia extends StatelessWidget {
  const _PostMedia({required this.post});
  final _Post post;

  @override
  Widget build(BuildContext context) {
    if (post.media == _MediaKind.multi) {
      return ClipRRect(
        borderRadius: BorderRadius.circular(14),
        child: SizedBox(
          height: 150,
          child: Row(
            children: <Widget>[
              Expanded(
                child: CustomPaint(
                  painter: _MediaPainter(Color(post.colorA), Color(post.colorB)),
                  child: const SizedBox.expand(),
                ),
              ),
              const SizedBox(width: 3),
              Expanded(
                child: CustomPaint(
                  painter: _MediaPainter(Color(post.colorB), Color(post.colorA)),
                  child: const SizedBox.expand(),
                ),
              ),
            ],
          ),
        ),
      );
    }
    return ClipRRect(
      borderRadius: BorderRadius.circular(14),
      child: AspectRatio(
        aspectRatio: 16 / 10,
        child: CustomPaint(
          painter: _MediaPainter(Color(post.colorA), Color(post.colorB)),
          child: const SizedBox.expand(),
        ),
      ),
    );
  }
}

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(),
        IconButton(
          onPressed: () {},
          icon: const Icon(Icons.bookmark_border,
              size: 21, color: SocialFeedHomeScreen._muted),
          splashRadius: 20,
        ),
      ],
    );
  }
}

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: SocialFeedHomeScreen._muted),
        const SizedBox(width: 6),
        Text(
          label,
          style: const TextStyle(
            fontFamily: SocialFeedHomeScreen._font,
            fontSize: 13,
            fontWeight: FontWeight.w500,
            color: SocialFeedHomeScreen._muted,
          ),
        ),
      ],
    );
  }
}

`_PostMedia` returns one of two shapes. `multi` is a fixed 150px `Row` of two `Expanded` painters split by a 3px gutter, with `colorA` and `colorB` swapped on the second so the pair looks like two related photos rather than one duplicated tile. Anything else gets an `AspectRatio(16 / 10)`, which keeps the image height proportional to the phone's width instead of hard-coding it. Both are wrapped in `ClipRRect(borderRadius: 14)` because a `CustomPaint` will happily paint into the corners otherwise. `_ActionBar._fmt` collapses anything from 1000 up to `'1.1k'` via `toStringAsFixed(1)`, and the `Spacer` before the bookmark is what parks it on the far right while like, comment and share stay grouped at 18px intervals.

End-of-feed, the reusable monogram and the nav bar

social_feed_home_screen.dart
class _CaughtUp extends StatelessWidget {
  const _CaughtUp();

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        SizedBox(
          width: 46,
          height: 46,
          child: CustomPaint(painter: _CheckBadgePainter()),
        ),
        const SizedBox(height: 12),
        const Text(
          'You’re all caught up',
          style: TextStyle(
            fontFamily: SocialFeedHomeScreen._font,
            fontSize: 15,
            fontWeight: FontWeight.w700,
            color: SocialFeedHomeScreen._textHi,
          ),
        ),
        const SizedBox(height: 4),
        const Text(
          'You’ve seen all new posts from the last 3 days.',
          style: TextStyle(
            fontFamily: SocialFeedHomeScreen._font,
            fontSize: 13,
            color: SocialFeedHomeScreen._muted,
          ),
        ),
      ],
    );
  }
}

// ── monogram avatar ─────────────────────────────────────────────────────────
class _Monogram extends StatelessWidget {
  const _Monogram({
    required this.initials,
    required this.colorA,
    required this.colorB,
    this.size = 42,
  });
  final String initials;
  final Color colorA;
  final Color colorB;
  final double size;

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

// ── bottom nav ──────────────────────────────────────────────────────────────
class _PulseNavBar extends StatelessWidget {
  const _PulseNavBar({required this.current, this.onTab, this.onProfile});
  final int current;
  final ValueChanged<int>? onTab;
  final VoidCallback? onProfile;

  static const List<IconData> _icons = <IconData>[
    Icons.home_rounded,
    Icons.explore_outlined,
    Icons.play_circle_outline,
    Icons.chat_bubble_outline,
    Icons.person_outline,
  ];

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: const BoxDecoration(
        color: SocialFeedHomeScreen._bg,
        border: Border(
          top: BorderSide(color: SocialFeedHomeScreen._hairline),
        ),
      ),
      child: SafeArea(
        top: false,
        child: SizedBox(
          height: 60,
          child: Row(
            children: List<Widget>.generate(5, (int i) {
              final bool active = i == current;
              return Expanded(
                child: InkWell(
                  onTap: () {
                    if (i == 4) {
                      onProfile?.call();
                    }
                    onTab?.call(i);
                  },
                  child: Icon(
                    _icons[i],
                    size: 26,
                    color: active
                        ? SocialFeedHomeScreen._brand
                        : SocialFeedHomeScreen._muted,
                  ),
                ),
              );
            }),
          ),
        ),
      ),
    );
  }
}

`_CaughtUp` closes the feed with a 46px painted tick and two lines of copy — an explicit stopping point is what stops an endless scroll from feeling broken when it runs out. `_Monogram` sizes its text at `size * 0.36`, so the same widget serves the 42px card avatar and any other size without a second style. `_PulseNavBar` generates its five tabs with `List<Widget>.generate(5, ...)` wrapped in `Expanded`, giving every tab an exact fifth of the width regardless of glyph shape; `active` is a plain `i == current` comparison, and tab 4 fires `onProfile` *and* `onTab(4)` so a host can either route generically or handle profile specially. `SafeArea(top: false)` sits inside the `Container`, letting the bar's background paint under the home indicator while the icons stay above it.

Four painters: logo, ring, media and tick

social_feed_home_screen.dart
class _PulseMarkPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final RRect box = RRect.fromRectAndRadius(
      Offset.zero & size,
      Radius.circular(size.width * 0.28),
    );
    canvas.drawRRect(
      box,
      Paint()
        ..shader = const LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: <Color>[Color(0xFF9B8CFF), Color(0xFF6E56F7)],
        ).createShader(Offset.zero & size),
    );
    final Paint wave = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = size.width * 0.09
      ..strokeCap = StrokeCap.round
      ..strokeJoin = StrokeJoin.round
      ..color = Colors.white;
    final double h = size.height;
    final double w = size.width;
    final Path p = Path()
      ..moveTo(w * 0.16, h * 0.55)
      ..lineTo(w * 0.36, h * 0.55)
      ..lineTo(w * 0.46, h * 0.30)
      ..lineTo(w * 0.58, h * 0.72)
      ..lineTo(w * 0.66, h * 0.55)
      ..lineTo(w * 0.84, h * 0.55);
    canvas.drawPath(p, wave);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

class _RingAvatarPainter extends CustomPainter {
  _RingAvatarPainter({
    required this.colorA,
    required this.colorB,
    required this.unseen,
  });
  final Color colorA;
  final Color colorB;
  final bool unseen;

  @override
  void paint(Canvas canvas, Size size) {
    final Offset center = size.center(Offset.zero);
    final double radius = size.width / 2 - 1.5;
    if (unseen) {
      canvas.drawCircle(
        center,
        radius,
        Paint()
          ..style = PaintingStyle.stroke
          ..strokeWidth = 2.6
          ..shader = SweepGradient(
            colors: <Color>[colorA, colorB, colorA],
            transform: const GradientRotation(-math.pi / 2),
          ).createShader(Rect.fromCircle(center: center, radius: radius)),
      );
    } else {
      canvas.drawCircle(
        center,
        radius,
        Paint()
          ..style = PaintingStyle.stroke
          ..strokeWidth = 2.0
          ..color = const Color(0xFF3A3A46),
      );
    }
  }

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

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),
    );
    // Soft light streaks for texture.
    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,
      );
    }
    // Painted "sun" disc.
    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;
}

class _CheckBadgePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final Offset c = size.center(Offset.zero);
    final double r = size.width / 2;
    canvas.drawCircle(
      c,
      r,
      Paint()..color = const Color(0xFF34D399).withValues(alpha: 0.14),
    );
    final Paint check = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = 3
      ..strokeCap = StrokeCap.round
      ..strokeJoin = StrokeJoin.round
      ..color = const Color(0xFF34D399);
    final Path p = Path()
      ..moveTo(c.dx - r * 0.34, c.dy + r * 0.02)
      ..lineTo(c.dx - r * 0.06, c.dy + r * 0.30)
      ..lineTo(c.dx + r * 0.40, c.dy - r * 0.26);
    canvas.drawPath(p, check);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

`_PulseMarkPainter` draws a gradient `RRect` with a `size.width * 0.28` radius, then strokes a six-point heartbeat `Path` in white — every coordinate is a fraction of `w` and `h`, so the mark is crisp at any box size. `_RingAvatarPainter` is the interesting one: unseen stories get a 2.6px stroke shaded by a `SweepGradient` whose colours run `[colorA, colorB, colorA]` — repeating the first colour is what hides the seam where the sweep wraps — rotated by `GradientRotation(-math.pi / 2)` so it starts at the top. Seen stories get a thinner 2.0px flat `#3A3A46` stroke instead. `_MediaPainter` fakes a photo with a gradient at 0.85/0.65 alpha, five diagonal white streaks at 6% opacity drawn as thick lines, and one 18%-white 'sun' disc. `_CheckBadgePainter` fills a green disc at 14% alpha and strokes a three-point tick with round caps.

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';

/// Home Feed — the Pulse app shell and Home tab. A sticky brand top bar, a
/// horizontal stories rail with painted gradient unseen-rings, an infinite
/// column of post cards (text / photo / multi), and the 5-tab bottom nav with a
/// floating compose button. This is the post-onboarding app root; every action
/// is exposed as a callback so the gallery registry wires navigation and the
/// tab switch. Self-contained per CONVENTIONS.md: pure Flutter, bundled Inter
/// font, own dark theme, SafeArea, fully overflow-proof.
class SocialFeedHomeScreen extends StatelessWidget {
  const SocialFeedHomeScreen({
    super.key,
    this.onTab,
    this.onCompose,
    this.onSearch,
    this.onNotifications,
    this.onStory,
    this.onPost,
    this.onProfile,
  });

  /// Bottom-nav tab tap: 0 Home · 1 Explore · 2 Reels · 3 Chats · 4 Profile.
  final ValueChanged<int>? onTab;
  final VoidCallback? onCompose;
  final VoidCallback? onSearch;
  final VoidCallback? onNotifications;

  /// A story ring tap (index into the rail).
  final ValueChanged<int>? onStory;

  /// A post tap (post id).
  final ValueChanged<String>? onPost;
  final VoidCallback? onProfile;

  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);

  static const List<_Story> _stories = <_Story>[
    _Story('Maya Chen', 0xFF6E56F7, 0xFF9B8CFF, unseen: true),
    _Story('Dev Kapoor', 0xFF34D399, 0xFF6E56F7, unseen: true),
    _Story('Lena Ortiz', 0xFFF4476B, 0xFFFBBF24, unseen: true),
    _Story('Sam Ito', 0xFF9B8CFF, 0xFF34D399, unseen: false),
    _Story('Priya N', 0xFFFBBF24, 0xFFF4476B, unseen: true),
    _Story('Theo B', 0xFF6E56F7, 0xFF34D399, unseen: false),
  ];

  static const List<_Post> _posts = <_Post>[
    _Post(
      id: 'p1',
      name: 'Maya Chen',
      handle: 'mayabuilds',
      time: '12m',
      colorA: 0xFF6E56F7,
      colorB: 0xFF9B8CFF,
      body:
          'Shipped the new composer today. Tap-and-hold to reorder blocks, '
          'inline polls, and a calmer draft view. Small thing, big relief.',
      likes: 248,
      comments: 31,
      media: _MediaKind.gradient,
    ),
    _Post(
      id: 'p2',
      name: 'Dev Kapoor',
      handle: 'devk',
      time: '48m',
      colorA: 0xFF34D399,
      colorB: 0xFF6E56F7,
      body:
          'Reminder: the best feed is the one you curate. Unfollow noise, '
          'follow craft. Your future self scrolls with intention.',
      likes: 1120,
      comments: 96,
      media: _MediaKind.none,
    ),
    _Post(
      id: 'p3',
      name: 'Lena Ortiz',
      handle: 'lenaux',
      time: '2h',
      colorA: 0xFFF4476B,
      colorB: 0xFFFBBF24,
      body: 'Golden hour on the studio wall. Three swatches made the cut.',
      likes: 542,
      comments: 44,
      media: _MediaKind.multi,
    ),
  ];

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.dark(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _bg,
        body: SafeArea(
          bottom: false,
          child: Column(
            children: <Widget>[
              const _TopBar(),
              Expanded(
                child: ListView(
                  padding: EdgeInsets.zero,
                  children: <Widget>[
                    _StoriesRail(
                      stories: _stories,
                      onTap: onStory,
                    ),
                    const Divider(height: 1, color: _hairline),
                    for (int i = 0; i < _posts.length; i++)
                      _PostCard(
                        post: _posts[i],
                        onTap: () => onPost?.call(_posts[i].id),
                      ),
                    const SizedBox(height: 24),
                    const _CaughtUp(),
                    const SizedBox(height: 24),
                  ],
                ),
              ),
            ],
          ),
        ),
        floatingActionButton: Padding(
          padding: const EdgeInsets.only(bottom: 4),
          child: FloatingActionButton(
            onPressed: onCompose,
            backgroundColor: _brand,
            elevation: 0,
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(18),
            ),
            child: const Icon(Icons.add, color: Colors.white, size: 28),
          ),
        ),
        bottomNavigationBar: _PulseNavBar(
          current: 0,
          onTab: onTab,
          onProfile: onProfile,
        ),
      ),
    );
  }
}

class _TopBar extends StatelessWidget {
  const _TopBar();

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 56,
      padding: const EdgeInsets.fromLTRB(20, 0, 12, 0),
      decoration: const BoxDecoration(
        color: SocialFeedHomeScreen._bg,
        border: Border(
          bottom: BorderSide(color: SocialFeedHomeScreen._hairline),
        ),
      ),
      child: Row(
        children: <Widget>[
          SizedBox(
            width: 26,
            height: 26,
            child: CustomPaint(painter: _PulseMarkPainter()),
          ),
          const SizedBox(width: 10),
          const Text(
            'Pulse',
            style: TextStyle(
              fontFamily: SocialFeedHomeScreen._font,
              fontSize: 22,
              fontWeight: FontWeight.w800,
              letterSpacing: -0.8,
              color: SocialFeedHomeScreen._textHi,
            ),
          ),
          const Spacer(),
          _CircleIcon(icon: Icons.search, onTap: () {}),
          const SizedBox(width: 4),
          Stack(
            clipBehavior: Clip.none,
            children: <Widget>[
              _CircleIcon(icon: Icons.notifications_none, onTap: () {}),
              Positioned(
                right: 8,
                top: 8,
                child: Container(
                  width: 8,
                  height: 8,
                  decoration: const BoxDecoration(
                    color: SocialFeedHomeScreen._brand,
                    shape: BoxShape.circle,
                  ),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

class _CircleIcon extends StatelessWidget {
  const _CircleIcon({required this.icon, required this.onTap});
  final IconData icon;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    return IconButton(
      onPressed: onTap,
      icon: Icon(icon, size: 24, color: SocialFeedHomeScreen._textHi),
      splashRadius: 22,
    );
  }
}

// ── stories rail ────────────────────────────────────────────────────────────
class _Story {
  const _Story(this.name, this.colorA, this.colorB, {required this.unseen});
  final String name;
  final int colorA;
  final int colorB;
  final bool unseen;
}

class _StoriesRail extends StatelessWidget {
  const _StoriesRail({required this.stories, this.onTap});
  final List<_Story> stories;
  final ValueChanged<int>? onTap;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 120,
      child: ListView.separated(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
        itemCount: stories.length + 1,
        separatorBuilder: (BuildContext context, int _) =>
            const SizedBox(width: 14),
        itemBuilder: (BuildContext context, int i) {
          if (i == 0) {
            return const _AddStoryTile();
          }
          final _Story s = stories[i - 1];
          return _StoryItem(story: s, onTap: () => onTap?.call(i - 1));
        },
      ),
    );
  }
}

class _AddStoryTile extends StatelessWidget {
  const _AddStoryTile();

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 66,
      child: Column(
        children: <Widget>[
          Container(
            width: 62,
            height: 62,
            decoration: BoxDecoration(
              color: const Color(0xFF15151B),
              shape: BoxShape.circle,
              border: Border.all(color: SocialFeedHomeScreen._hairline),
            ),
            child: const Icon(Icons.add,
                color: SocialFeedHomeScreen._brand, size: 26),
          ),
          const SizedBox(height: 7),
          const Text(
            'Your story',
            maxLines: 1,
            overflow: TextOverflow.ellipsis,
            style: TextStyle(
              fontFamily: SocialFeedHomeScreen._font,
              fontSize: 11.5,
              color: SocialFeedHomeScreen._muted,
            ),
          ),
        ],
      ),
    );
  }
}

class _StoryItem extends StatelessWidget {
  const _StoryItem({required this.story, this.onTap});
  final _Story story;
  final VoidCallback? onTap;

  String get _initials {
    final List<String> p = story.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: SizedBox(
        width: 66,
        child: Column(
          children: <Widget>[
            SizedBox(
              width: 62,
              height: 62,
              child: CustomPaint(
                painter: _RingAvatarPainter(
                  colorA: Color(story.colorA),
                  colorB: Color(story.colorB),
                  unseen: story.unseen,
                ),
                child: Center(
                  child: Container(
                    width: 52,
                    height: 52,
                    decoration: BoxDecoration(
                      shape: BoxShape.circle,
                      gradient: LinearGradient(
                        begin: Alignment.topLeft,
                        end: Alignment.bottomRight,
                        colors: <Color>[
                          Color(story.colorA),
                          Color(story.colorB),
                        ],
                      ),
                    ),
                    child: Center(
                      child: Text(
                        _initials,
                        style: const TextStyle(
                          fontFamily: SocialFeedHomeScreen._font,
                          fontSize: 17,
                          fontWeight: FontWeight.w700,
                          color: Colors.white,
                        ),
                      ),
                    ),
                  ),
                ),
              ),
            ),
            const SizedBox(height: 7),
            Text(
              story.name.split(' ').first,
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
              style: const TextStyle(
                fontFamily: SocialFeedHomeScreen._font,
                fontSize: 11.5,
                color: SocialFeedHomeScreen._textHi,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// ── post card ───────────────────────────────────────────────────────────────
enum _MediaKind { none, gradient, multi }

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.media,
  });
  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 _MediaKind media;
}

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: SocialFeedHomeScreen._hairline),
          ),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Row(
              children: <Widget>[
                _Monogram(
                  initials: _initials,
                  colorA: Color(post.colorA),
                  colorB: Color(post.colorB),
                  size: 42,
                ),
                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: SocialFeedHomeScreen._font,
                                fontSize: 15,
                                fontWeight: FontWeight.w700,
                                color: SocialFeedHomeScreen._textHi,
                              ),
                            ),
                          ),
                          const SizedBox(width: 6),
                          const Icon(Icons.verified,
                              size: 15, color: SocialFeedHomeScreen._brand),
                        ],
                      ),
                      Text(
                        '@${post.handle} · ${post.time}',
                        style: const TextStyle(
                          fontFamily: SocialFeedHomeScreen._font,
                          fontSize: 12.5,
                          color: SocialFeedHomeScreen._muted,
                        ),
                      ),
                    ],
                  ),
                ),
                IconButton(
                  onPressed: () {},
                  icon: const Icon(Icons.more_horiz,
                      color: SocialFeedHomeScreen._muted, size: 22),
                  splashRadius: 20,
                ),
              ],
            ),
            const SizedBox(height: 10),
            Text(
              post.body,
              style: const TextStyle(
                fontFamily: SocialFeedHomeScreen._font,
                fontSize: 14.5,
                height: 1.5,
                color: Color(0xFFDDDDE6),
              ),
            ),
            if (post.media != _MediaKind.none) ...<Widget>[
              const SizedBox(height: 12),
              _PostMedia(post: post),
            ],
            const SizedBox(height: 6),
            _ActionBar(likes: post.likes, comments: post.comments),
          ],
        ),
      ),
    );
  }
}

class _PostMedia extends StatelessWidget {
  const _PostMedia({required this.post});
  final _Post post;

  @override
  Widget build(BuildContext context) {
    if (post.media == _MediaKind.multi) {
      return ClipRRect(
        borderRadius: BorderRadius.circular(14),
        child: SizedBox(
          height: 150,
          child: Row(
            children: <Widget>[
              Expanded(
                child: CustomPaint(
                  painter: _MediaPainter(Color(post.colorA), Color(post.colorB)),
                  child: const SizedBox.expand(),
                ),
              ),
              const SizedBox(width: 3),
              Expanded(
                child: CustomPaint(
                  painter: _MediaPainter(Color(post.colorB), Color(post.colorA)),
                  child: const SizedBox.expand(),
                ),
              ),
            ],
          ),
        ),
      );
    }
    return ClipRRect(
      borderRadius: BorderRadius.circular(14),
      child: AspectRatio(
        aspectRatio: 16 / 10,
        child: CustomPaint(
          painter: _MediaPainter(Color(post.colorA), Color(post.colorB)),
          child: const SizedBox.expand(),
        ),
      ),
    );
  }
}

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(),
        IconButton(
          onPressed: () {},
          icon: const Icon(Icons.bookmark_border,
              size: 21, color: SocialFeedHomeScreen._muted),
          splashRadius: 20,
        ),
      ],
    );
  }
}

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: SocialFeedHomeScreen._muted),
        const SizedBox(width: 6),
        Text(
          label,
          style: const TextStyle(
            fontFamily: SocialFeedHomeScreen._font,
            fontSize: 13,
            fontWeight: FontWeight.w500,
            color: SocialFeedHomeScreen._muted,
          ),
        ),
      ],
    );
  }
}

class _CaughtUp extends StatelessWidget {
  const _CaughtUp();

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        SizedBox(
          width: 46,
          height: 46,
          child: CustomPaint(painter: _CheckBadgePainter()),
        ),
        const SizedBox(height: 12),
        const Text(
          'You’re all caught up',
          style: TextStyle(
            fontFamily: SocialFeedHomeScreen._font,
            fontSize: 15,
            fontWeight: FontWeight.w700,
            color: SocialFeedHomeScreen._textHi,
          ),
        ),
        const SizedBox(height: 4),
        const Text(
          'You’ve seen all new posts from the last 3 days.',
          style: TextStyle(
            fontFamily: SocialFeedHomeScreen._font,
            fontSize: 13,
            color: SocialFeedHomeScreen._muted,
          ),
        ),
      ],
    );
  }
}

// ── monogram avatar ─────────────────────────────────────────────────────────
class _Monogram extends StatelessWidget {
  const _Monogram({
    required this.initials,
    required this.colorA,
    required this.colorB,
    this.size = 42,
  });
  final String initials;
  final Color colorA;
  final Color colorB;
  final double size;

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

// ── bottom nav ──────────────────────────────────────────────────────────────
class _PulseNavBar extends StatelessWidget {
  const _PulseNavBar({required this.current, this.onTab, this.onProfile});
  final int current;
  final ValueChanged<int>? onTab;
  final VoidCallback? onProfile;

  static const List<IconData> _icons = <IconData>[
    Icons.home_rounded,
    Icons.explore_outlined,
    Icons.play_circle_outline,
    Icons.chat_bubble_outline,
    Icons.person_outline,
  ];

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: const BoxDecoration(
        color: SocialFeedHomeScreen._bg,
        border: Border(
          top: BorderSide(color: SocialFeedHomeScreen._hairline),
        ),
      ),
      child: SafeArea(
        top: false,
        child: SizedBox(
          height: 60,
          child: Row(
            children: List<Widget>.generate(5, (int i) {
              final bool active = i == current;
              return Expanded(
                child: InkWell(
                  onTap: () {
                    if (i == 4) {
                      onProfile?.call();
                    }
                    onTab?.call(i);
                  },
                  child: Icon(
                    _icons[i],
                    size: 26,
                    color: active
                        ? SocialFeedHomeScreen._brand
                        : SocialFeedHomeScreen._muted,
                  ),
                ),
              );
            }),
          ),
        ),
      ),
    );
  }
}

// ── painters ────────────────────────────────────────────────────────────────
class _PulseMarkPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final RRect box = RRect.fromRectAndRadius(
      Offset.zero & size,
      Radius.circular(size.width * 0.28),
    );
    canvas.drawRRect(
      box,
      Paint()
        ..shader = const LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: <Color>[Color(0xFF9B8CFF), Color(0xFF6E56F7)],
        ).createShader(Offset.zero & size),
    );
    final Paint wave = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = size.width * 0.09
      ..strokeCap = StrokeCap.round
      ..strokeJoin = StrokeJoin.round
      ..color = Colors.white;
    final double h = size.height;
    final double w = size.width;
    final Path p = Path()
      ..moveTo(w * 0.16, h * 0.55)
      ..lineTo(w * 0.36, h * 0.55)
      ..lineTo(w * 0.46, h * 0.30)
      ..lineTo(w * 0.58, h * 0.72)
      ..lineTo(w * 0.66, h * 0.55)
      ..lineTo(w * 0.84, h * 0.55);
    canvas.drawPath(p, wave);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

class _RingAvatarPainter extends CustomPainter {
  _RingAvatarPainter({
    required this.colorA,
    required this.colorB,
    required this.unseen,
  });
  final Color colorA;
  final Color colorB;
  final bool unseen;

  @override
  void paint(Canvas canvas, Size size) {
    final Offset center = size.center(Offset.zero);
    final double radius = size.width / 2 - 1.5;
    if (unseen) {
      canvas.drawCircle(
        center,
        radius,
        Paint()
          ..style = PaintingStyle.stroke
          ..strokeWidth = 2.6
          ..shader = SweepGradient(
            colors: <Color>[colorA, colorB, colorA],
            transform: const GradientRotation(-math.pi / 2),
          ).createShader(Rect.fromCircle(center: center, radius: radius)),
      );
    } else {
      canvas.drawCircle(
        center,
        radius,
        Paint()
          ..style = PaintingStyle.stroke
          ..strokeWidth = 2.0
          ..color = const Color(0xFF3A3A46),
      );
    }
  }

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

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),
    );
    // Soft light streaks for texture.
    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,
      );
    }
    // Painted "sun" disc.
    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;
}

class _CheckBadgePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final Offset c = size.center(Offset.zero);
    final double r = size.width / 2;
    canvas.drawCircle(
      c,
      r,
      Paint()..color = const Color(0xFF34D399).withValues(alpha: 0.14),
    );
    final Paint check = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = 3
      ..strokeCap = StrokeCap.round
      ..strokeJoin = StrokeJoin.round
      ..color = const Color(0xFF34D399);
    final Path p = Path()
      ..moveTo(c.dx - r * 0.34, c.dy + r * 0.02)
      ..lineTo(c.dx - r * 0.06, c.dy + r * 0.30)
      ..lineTo(c.dx + r * 0.40, c.dy - r * 0.26);
    canvas.drawPath(p, check);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

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

2. AI agent (MCP)

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

FAQ

Can I use this home feed screen in a commercial app?

Yes. FlutterKit is free — there is no paid tier, no licence key and no sign-up. Copy the Dart from this page, install it with the CLI, or pull it through MCP, and ship it in a production app. Attribution is not required.

What packages or fonts does it need?

No packages at all — the imports are `dart:math` (for the `GradientRotation(-math.pi / 2)` in the story ring) and `package:flutter/material.dart`. Typography is Inter, declared as `fontFamily: 'Inter'`; bundle it in `pubspec.yaml` or delete the `fontFamily` lines to fall back to the platform default.

How do I replace the painted post media with real network images?

Swap the `CustomPaint` calls inside `_PostMedia` for `Image.network(post.imageUrl, fit: BoxFit.cover)` and add a URL field to `_Post`. Keep the `ClipRRect(borderRadius: 14)`, the `AspectRatio(16 / 10)` and the two-up `Row`, and pass a `loadingBuilder` that returns a `_MediaPainter` so the gradient becomes your placeholder while the photo downloads.

How do I paginate the feed instead of showing three fixed posts?

Make the screen stateful, hold a growing `List<_Post>`, and turn the `ListView` in `build` into `ListView.builder`. Keep the rail and the caught-up block by reserving index 0 and the last index for them and offsetting the post lookup, then attach a `ScrollController` listener that fetches the next page when `position.pixels` nears `maxScrollExtent`.

Which Flutter version does this need?

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

Related screens