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.

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
Create the file
Add a new file at lib/social_feed_home/social_feed_home_screen.dart in your Flutter project.
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:
flutter:
fonts:
- family: Inter
fonts:
- asset: fonts/Inter-Regular.ttfBuild 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
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
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
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
// ── 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
// ── 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
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
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
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-home2. 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.