How to Build a Stories Rail Screen in Flutter (Full Code + Preview)
A stories strip squeezed into the top of a feed hides most of what your friends posted today. This tutorial builds Pulse's dedicated Stories screen in Flutter: a 112px horizontal rail led by an 'Add story' tile whose dashed indigo ring is drawn by `_DashedRingPainter`, followed by avatars wrapped in a `SweepGradient` unseen ring or a grey seen ring from `_RingAvatarPainter`. Below a hairline divider, a RECENT UPDATES list repeats the same people as full rows with handles, timestamps and a 7px unseen dot. Everything is pure Flutter, no images, and every tap is a nullable callback.

Watch the Flutter UI walkthrough
A short screen recording of Pulse · Stories Rail running, if you'd rather see it before you read the code. The written tutorial below covers everything in it.
Can't see the video? Watch it on YouTube.
What you'll build
- ✓A horizontal `ListView.separated` rail with a leading add tile that reports `-1` through the same `onStory` callback as real stories
- ✓`_RingAvatarPainter`, which strokes a `SweepGradient` ring rotated by `-math.pi / 2` for unseen stories and a `#3A3A46` grey ring for seen ones
- ✓`_DashedRingPainter`, a 22-segment dashed ring built from `drawArc` calls with a 34% gap fraction
- ✓A `_RingAvatar` widget that reuses one `_Story` model at two sizes (62px in the rail, 50px in the list) and computes initials from the name
- ✓An `_UpdateRow` list whose name colour, unseen dot and indented dividers all derive from the `unseen` flag
Step-by-step build
Create the file
Add a new file at lib/social_feed_stories_rail/social_feed_stories_rail_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.
Callbacks, palette and the story data
import 'dart:math' as math;
import 'package:flutter/material.dart';
/// Stories Rail Detail — a dedicated Pulse screen that expands the stories
/// experience into a full horizontal tray plus a scrolling list of recent
/// story updates. The tray leads with an "Add story" tile, then painted
/// gradient unseen-rings and muted seen-rings around gradient monogram
/// avatars. Below, a "RECENT UPDATES" section lists per-person updates with
/// handles, timestamps and unseen indicators, led by a bordered "Add to your
/// story" card. Every action is a nullable callback so the gallery registry
/// wires navigation. Self-contained per CONVENTIONS.md: pure Flutter, bundled
/// Inter font, own dark theme, SafeArea, fully overflow-proof.
///
/// slug: social-feed-stories-rail
class SocialFeedStoriesRailScreen extends StatelessWidget {
const SocialFeedStoriesRailScreen({
super.key,
this.onBack,
this.onSettings,
this.onStory,
this.onAddStory,
this.onSeeAll,
});
final VoidCallback? onBack;
final VoidCallback? onSettings;
/// A story ring/update tap (index into the story list).
final ValueChanged<int>? onStory;
final VoidCallback? onAddStory;
final VoidCallback? onSeeAll;
static const String _font = 'Inter';
static const Color _bg = Color(0xFF0B0B0F);
static const Color _surface = Color(0xFF15151B);
static const Color _brand = Color(0xFF6E56F7);
static const Color _hairline = Color(0xFF26262F);
static const Color _textHi = Color(0xFFF4F4F7);
static const Color _textLo = Color(0xFFB5B5C2);
static const Color _muted = Color(0xFF8A8A99);
static const List<_Story> _stories = <_Story>[
_Story('Maya Chen', 'mayabuilds', '2h ago', 0xFF6E56F7, 0xFF9B8CFF,
unseen: true),
_Story('Dev Kapoor', 'devk', '3h ago', 0xFF34D399, 0xFF6E56F7,
unseen: true),
_Story('Lena Ortiz', 'lenaux', '4h ago', 0xFFF4476B, 0xFFFBBF24,
unseen: true),
_Story('Sam Ito', 'samito', '6h ago', 0xFF9B8CFF, 0xFF34D399,
unseen: false),
_Story('Priya Nair', 'priyan', '8h ago', 0xFFFBBF24, 0xFFF4476B,
unseen: true),
_Story('Theo Brandt', 'theob', '11h ago', 0xFF6E56F7, 0xFF34D399,
unseen: false),
_Story('Amara Osei', 'amara', '14h ago', 0xFF9B8CFF, 0xFF6E56F7,
unseen: true),
_Story('Kai Fisher', 'kaifisher', '19h ago', 0xFFF4476B, 0xFF9B8CFF,
unseen: false),
];`SocialFeedStoriesRailScreen` is a `StatelessWidget` because nothing on this screen mutates locally — seen/unseen state comes from the data, and every interaction leaves through a nullable callback: `onBack`, `onSettings`, `onAddStory`, `onSeeAll`, and `onStory`, which is a `ValueChanged<int>` carrying the index into the story list rather than a plain `VoidCallback`. The palette is the Pulse near-mono dark set: `_bg` `#0B0B0F`, `_surface` `#15151B`, `_hairline` `#26262F`, with a single indigo `_brand` `#6E56F7` and three text tiers (`_textHi`, `_textLo`, `_muted`). The eight `_Story` records hold two colour ints, `colorA` and `colorB`, instead of image URLs — those two colours drive both the avatar gradient and the ring sweep, so each person gets a consistent identity with zero network assets. Note the mix of `unseen: true` and `false` so both ring styles render in the preview.
Forcing a dark theme and composing the column
@override
Widget build(BuildContext context) {
return Theme(
data: ThemeData.dark(useMaterial3: true),
child: Scaffold(
backgroundColor: _bg,
body: SafeArea(
bottom: false,
child: Column(
children: <Widget>[
_TopBar(onBack: onBack, onSettings: onSettings),
_StoriesRail(stories: _stories, onTap: onStory),
const Divider(height: 1, color: _hairline),
Expanded(
child: ListView(
padding: const EdgeInsets.only(bottom: 28),
children: <Widget>[
_AddYourStoryCard(onTap: onAddStory),
_SectionHeader(onSeeAll: onSeeAll),
for (int i = 0; i < _stories.length - 1; i++) ...<Widget>[
_UpdateRow(
story: _stories[i],
onTap: () => onStory?.call(i),
),
if (i < _stories.length - 2)
const Divider(
height: 1,
thickness: 1,
indent: 78,
color: _hairline,
),
],
],
),
),
],
),
),
),
);
}
}`build` wraps the `Scaffold` in `Theme(data: ThemeData.dark(useMaterial3: true))` so the `IconButton` ripples, `TextButton` and `Divider` pick up dark defaults even if the host app is light. `SafeArea(bottom: false)` protects the top bar from the notch but lets the list run under the home indicator, with `EdgeInsets.only(bottom: 28)` on the `ListView` providing the breathing room instead. The layout is a `Column`: `_TopBar`, the fixed-height `_StoriesRail`, a 1px hairline `Divider`, then an `Expanded` `ListView`. The list loop runs `i < _stories.length - 1`, so seven of the eight people appear as rows, and the inner `if (i < _stories.length - 2)` stops a divider being drawn after the final row. Each `_UpdateRow` gets `() => onStory?.call(i)`, so tapping a row and tapping the same avatar in the rail fire the identical callback with the identical index. Dividers use `indent: 78` — 16px padding plus the 50px avatar plus the 12px gap — so the line starts under the text, not the avatar.
Top bar, story model and the horizontal rail
// ── top bar ──────────────────────────────────────────────────────────────────
class _TopBar extends StatelessWidget {
const _TopBar({this.onBack, this.onSettings});
final VoidCallback? onBack;
final VoidCallback? onSettings;
@override
Widget build(BuildContext context) {
return Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 6),
decoration: const BoxDecoration(
color: SocialFeedStoriesRailScreen._bg,
border: Border(
bottom: BorderSide(color: SocialFeedStoriesRailScreen._hairline),
),
),
child: Row(
children: <Widget>[
IconButton(
onPressed: onBack,
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
color: SocialFeedStoriesRailScreen._textHi,
splashRadius: 22,
),
const Expanded(
child: Text(
'Stories',
style: TextStyle(
fontFamily: SocialFeedStoriesRailScreen._font,
fontSize: 19,
fontWeight: FontWeight.w800,
letterSpacing: -0.6,
color: SocialFeedStoriesRailScreen._textHi,
),
),
),
IconButton(
onPressed: onSettings,
icon: const Icon(Icons.settings_outlined, size: 22),
color: SocialFeedStoriesRailScreen._textHi,
splashRadius: 22,
),
],
),
);
}
}
// ── stories rail ─────────────────────────────────────────────────────────────
class _Story {
const _Story(
this.name,
this.handle,
this.time,
this.colorA,
this.colorB, {
required this.unseen,
});
final String name;
final String handle;
final String time;
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: 112,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(16, 14, 16, 12),
itemCount: stories.length + 1,
separatorBuilder: (BuildContext context, int _) =>
const SizedBox(width: 14),
itemBuilder: (BuildContext context, int i) {
if (i == 0) {
return _AddStoryTile(onTap: onTap);
}
final _Story s = stories[i - 1];
return _StoryItem(story: s, onTap: () => onTap?.call(i - 1));
},
),
);
}
}`_TopBar` is a fixed 56px `Container` with only 6px horizontal padding, because the `IconButton`s carry their own 48px tap targets; its bottom edge is a `Border(bottom: BorderSide(color: _hairline))` rather than a separate `Divider`. The 'Stories' title sits at 19px `w800` with `letterSpacing: -0.6` inside `Expanded`, claiming all space between the 20px back chevron and the 22px settings gear (sized differently to balance the denser chevron glyph). `_Story` is a plain const class with a named `required this.unseen`, forcing the seen state to be spelled out at every call site. `_StoriesRail` is a `SizedBox(height: 112)` around a horizontal `ListView.separated`; the fixed height lets it sit inside the parent `Column` without an unbounded-height error. `itemCount` is `stories.length + 1` and index 0 is special-cased to `_AddStoryTile`, so the add tile scrolls with the rail rather than being pinned. The `i - 1` offset appears twice, once to read `stories[i - 1]` and once in `onTap?.call(i - 1)`, so the index the callback reports matches the data list rather than rail positions. Separators are constant 14px `SizedBox`es.
The add tile and a story tile
class _AddStoryTile extends StatelessWidget {
const _AddStoryTile({this.onTap});
final ValueChanged<int>? onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onTap?.call(-1),
behavior: HitTestBehavior.opaque,
child: SizedBox(
width: 66,
child: Column(
children: <Widget>[
SizedBox(
width: 62,
height: 62,
child: CustomPaint(
painter: _DashedRingPainter(),
child: const Center(
child: Icon(
Icons.add,
color: SocialFeedStoriesRailScreen._brand,
size: 26,
),
),
),
),
const SizedBox(height: 7),
const Text(
'Your story',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: SocialFeedStoriesRailScreen._font,
fontSize: 11.5,
color: SocialFeedStoriesRailScreen._muted,
),
),
],
),
),
);
}
}
class _StoryItem extends StatelessWidget {
const _StoryItem({required this.story, this.onTap});
final _Story story;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: SizedBox(
width: 66,
child: Column(
children: <Widget>[
_RingAvatar(story: story, size: 62),
const SizedBox(height: 7),
Text(
story.name.split(' ').first,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: SocialFeedStoriesRailScreen._font,
fontSize: 11.5,
color: story.unseen
? SocialFeedStoriesRailScreen._textHi
: SocialFeedStoriesRailScreen._muted,
),
),
],
),
),
);
}
}Both tiles are 66px wide `Column`s inside a `GestureDetector` with `HitTestBehavior.opaque`, so the gap between the ring and the caption is tappable rather than falling through to the list. `_AddStoryTile` takes the same `ValueChanged<int>?` as real stories and reports `-1`, so a host app can route 'new story' through the single `onStory` handler without a second parameter — the registry's `onAddStory` is reserved for the larger card below. Its 62px `CustomPaint` uses `_DashedRingPainter` with a centred 26px indigo `Icons.add`. `_StoryItem` swaps in `_RingAvatar(size: 62)` and shows only `story.name.split(' ').first`, because a 66px tile has room for one name, with `maxLines: 1` and ellipsis as a guard for long first names. The caption colour flips with `story.unseen`: `_textHi` for unseen, `_muted` for seen, which is the same visual convention Instagram uses so users read it without thinking.
One ring avatar, two sizes
/// A gradient sweep (unseen) / grey (seen) ring around a gradient monogram.
class _RingAvatar extends StatelessWidget {
const _RingAvatar({required this.story, required this.size});
final _Story story;
final double size;
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) {
final double inner = size - 10;
return SizedBox(
width: size,
height: size,
child: CustomPaint(
painter: _RingAvatarPainter(
colorA: Color(story.colorA),
colorB: Color(story.colorB),
unseen: story.unseen,
),
child: Center(
child: Container(
width: inner,
height: inner,
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: TextStyle(
fontFamily: SocialFeedStoriesRailScreen._font,
fontSize: inner * 0.33,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
),
),
),
);
}
}`_RingAvatar` is the piece that makes the rail and the update list feel like the same people. It takes a `size` and derives `inner = size - 10`, leaving a 5px gutter on each side for the painted ring and a gap between ring and disc. The `_initials` getter splits the name on `RegExp(r'\s+')`, so double spaces don't produce an empty part, and uses `characters.first` for the single-name case so a name starting with a multi-code-unit glyph doesn't get split mid-grapheme; two-part names take the first letter of the first and last parts. The `CustomPaint` layers `_RingAvatarPainter` behind a `Center`ed circular `Container` carrying a top-left to bottom-right `LinearGradient` from `colorA` to `colorB`. The initials are `inner * 0.33` in size, so text scales with the avatar rather than being hard-coded — at 62px the inner disc is 52px and the letters about 17px; at 50px they drop to roughly 13px automatically.
The 'Add to your story' card
// ── add-to-your-story card ───────────────────────────────────────────────────
class _AddYourStoryCard extends StatelessWidget {
const _AddYourStoryCard({this.onTap});
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.fromLTRB(14, 14, 12, 14),
decoration: BoxDecoration(
color: SocialFeedStoriesRailScreen._surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: SocialFeedStoriesRailScreen._hairline),
),
child: Row(
children: <Widget>[
const _Monogram(
initials: 'YOU',
colorA: Color(0xFF6E56F7),
colorB: Color(0xFF9B8CFF),
size: 46,
),
const SizedBox(width: 14),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Your story',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: SocialFeedStoriesRailScreen._font,
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: SocialFeedStoriesRailScreen._textHi,
),
),
SizedBox(height: 3),
Text(
'Add to your story',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: SocialFeedStoriesRailScreen._font,
fontSize: 12.5,
color: SocialFeedStoriesRailScreen._muted,
),
),
],
),
),
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: SocialFeedStoriesRailScreen._brand
.withValues(alpha: 0.16),
shape: BoxShape.circle,
),
child: const Icon(
Icons.add_a_photo_outlined,
color: SocialFeedStoriesRailScreen._brand,
size: 20,
),
),
],
),
),
),
);
}
}`_AddYourStoryCard` is the first item in the scrolling list and the only bordered card on the screen, which visually promotes it above the flat rows beneath. It uses `_surface` `#15151B` with a 16px radius and a `_hairline` border, and padding of `fromLTRB(14, 14, 12, 14)` — the right side is 2px tighter because the 40px icon circle already carries visual weight. The leading avatar is a `_Monogram` with hard-coded 'YOU' initials in the brand gradient `#6E56F7` to `#9B8CFF`, which distinguishes the user from the friends' avatars without needing a profile photo. Two text lines, 'Your story' at 15px `w700` and 'Add to your story' at 12.5px muted, sit in an `Expanded` `Column` so long translations ellipsise instead of overflowing. The trailing `Icons.add_a_photo_outlined` sits in a circle tinted `_brand.withValues(alpha: 0.16)`, the two-layer accent pattern — soft tinted disc, saturated glyph — used across the Pulse kit.
Section header and the update rows
// ── section header ───────────────────────────────────────────────────────────
class _SectionHeader extends StatelessWidget {
const _SectionHeader({this.onSeeAll});
final VoidCallback? onSeeAll;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 12, 8),
child: Row(
children: <Widget>[
const Expanded(
child: Text(
'RECENT UPDATES',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: SocialFeedStoriesRailScreen._font,
fontSize: 11.5,
fontWeight: FontWeight.w700,
letterSpacing: 1.0,
color: SocialFeedStoriesRailScreen._muted,
),
),
),
TextButton(
onPressed: onSeeAll,
style: TextButton.styleFrom(
minimumSize: Size.zero,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text(
'See all',
style: TextStyle(
fontFamily: SocialFeedStoriesRailScreen._font,
fontSize: 13,
fontWeight: FontWeight.w600,
color: SocialFeedStoriesRailScreen._brand,
),
),
),
],
),
);
}
}
// ── update row ───────────────────────────────────────────────────────────────
class _UpdateRow extends StatelessWidget {
const _UpdateRow({required this.story, this.onTap});
final _Story story;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 12, 12),
child: Row(
children: <Widget>[
_RingAvatar(story: story, size: 50),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Flexible(
child: Text(
story.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: SocialFeedStoriesRailScreen._font,
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
color: story.unseen
? SocialFeedStoriesRailScreen._textHi
: SocialFeedStoriesRailScreen._textLo,
),
),
),
if (story.unseen) ...<Widget>[
const SizedBox(width: 8),
Container(
width: 7,
height: 7,
decoration: const BoxDecoration(
color: SocialFeedStoriesRailScreen._brand,
shape: BoxShape.circle,
),
),
],
],
),
const SizedBox(height: 2),
Text(
'@${story.handle}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: SocialFeedStoriesRailScreen._font,
fontSize: 12.5,
color: SocialFeedStoriesRailScreen._muted,
),
),
],
),
),
const SizedBox(width: 10),
Text(
story.time,
style: const TextStyle(
fontFamily: SocialFeedStoriesRailScreen._font,
fontSize: 12,
color: SocialFeedStoriesRailScreen._muted,
),
),
const SizedBox(width: 4),
const Icon(
Icons.chevron_right,
size: 20,
color: SocialFeedStoriesRailScreen._muted,
),
],
),
),
);
}
}`_SectionHeader` sets 'RECENT UPDATES' at 11.5px `w700` with `letterSpacing: 1.0`, the uppercase-tracked eyebrow style, next to a 'See all' `TextButton`. The button overrides `minimumSize: Size.zero`, `tapTargetSize: MaterialTapTargetSize.shrinkWrap` and a 10×6 padding, because Material's default 48px minimum would push the header row to almost twice its intended height. `_UpdateRow` reuses `_RingAvatar` at 50px next to a name/handle column and a trailing timestamp plus chevron. The name is wrapped in `Flexible` inside a `Row`, so it shrinks to make room for the 7px indigo unseen dot that follows it, rather than pushing the dot off screen for a long name; the dot and its 8px spacer only render inside `if (story.unseen)`. The name colour steps down from `_textHi` to `_textLo` for seen stories, a lighter demotion than the rail's `_muted`, because a list row still needs to be readable at a glance.
The monogram and the two painters
// ── 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: SocialFeedStoriesRailScreen._font,
fontSize: size * 0.3,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
);
}
}
// ── painters ─────────────────────────────────────────────────────────────────
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;
}
/// A dashed-look brand ring used for the "Add story" tile.
class _DashedRingPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final Offset center = size.center(Offset.zero);
final double radius = size.width / 2 - 1.5;
// Filled surface disc.
canvas.drawCircle(
center,
radius,
Paint()..color = const Color(0xFF15151B),
);
// Dashed brand ring.
final Paint dash = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.2
..strokeCap = StrokeCap.round
..color = const Color(0xFF6E56F7);
const int segments = 22;
const double gap = 0.34; // fraction of each segment left empty
final double step = (2 * math.pi) / segments;
for (int i = 0; i < segments; i++) {
final double start = i * step;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
start,
step * (1 - gap),
false,
dash,
);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}`_Monogram` is the standalone version of the avatar disc — a gradient circle with initials at `size * 0.3` — used only by the 'YOU' card. `_RingAvatarPainter` computes `radius = size.width / 2 - 1.5` so the 2.6px stroke doesn't clip at the edges. For unseen it builds a `SweepGradient` with `[colorA, colorB, colorA]` so the ring's start and end colours match and there is no visible seam, and rotates it by `GradientRotation(-math.pi / 2)` so the sweep begins at twelve o'clock instead of three. Seen rings are a thinner 2.0px `#3A3A46` stroke. `shouldRepaint` compares `unseen` and both colours, so scrolling doesn't repaint unchanged rings. `_DashedRingPainter` first fills a `#15151B` disc, then loops 22 times drawing arcs of `step * (1 - gap)` where `gap = 0.34`, leaving a third of each segment empty; `StrokeCap.round` softens the dash ends. It never repaints because it has no inputs.
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';
/// Stories Rail Detail — a dedicated Pulse screen that expands the stories
/// experience into a full horizontal tray plus a scrolling list of recent
/// story updates. The tray leads with an "Add story" tile, then painted
/// gradient unseen-rings and muted seen-rings around gradient monogram
/// avatars. Below, a "RECENT UPDATES" section lists per-person updates with
/// handles, timestamps and unseen indicators, led by a bordered "Add to your
/// story" card. Every action is a nullable callback so the gallery registry
/// wires navigation. Self-contained per CONVENTIONS.md: pure Flutter, bundled
/// Inter font, own dark theme, SafeArea, fully overflow-proof.
///
/// slug: social-feed-stories-rail
class SocialFeedStoriesRailScreen extends StatelessWidget {
const SocialFeedStoriesRailScreen({
super.key,
this.onBack,
this.onSettings,
this.onStory,
this.onAddStory,
this.onSeeAll,
});
final VoidCallback? onBack;
final VoidCallback? onSettings;
/// A story ring/update tap (index into the story list).
final ValueChanged<int>? onStory;
final VoidCallback? onAddStory;
final VoidCallback? onSeeAll;
static const String _font = 'Inter';
static const Color _bg = Color(0xFF0B0B0F);
static const Color _surface = Color(0xFF15151B);
static const Color _brand = Color(0xFF6E56F7);
static const Color _hairline = Color(0xFF26262F);
static const Color _textHi = Color(0xFFF4F4F7);
static const Color _textLo = Color(0xFFB5B5C2);
static const Color _muted = Color(0xFF8A8A99);
static const List<_Story> _stories = <_Story>[
_Story('Maya Chen', 'mayabuilds', '2h ago', 0xFF6E56F7, 0xFF9B8CFF,
unseen: true),
_Story('Dev Kapoor', 'devk', '3h ago', 0xFF34D399, 0xFF6E56F7,
unseen: true),
_Story('Lena Ortiz', 'lenaux', '4h ago', 0xFFF4476B, 0xFFFBBF24,
unseen: true),
_Story('Sam Ito', 'samito', '6h ago', 0xFF9B8CFF, 0xFF34D399,
unseen: false),
_Story('Priya Nair', 'priyan', '8h ago', 0xFFFBBF24, 0xFFF4476B,
unseen: true),
_Story('Theo Brandt', 'theob', '11h ago', 0xFF6E56F7, 0xFF34D399,
unseen: false),
_Story('Amara Osei', 'amara', '14h ago', 0xFF9B8CFF, 0xFF6E56F7,
unseen: true),
_Story('Kai Fisher', 'kaifisher', '19h ago', 0xFFF4476B, 0xFF9B8CFF,
unseen: false),
];
@override
Widget build(BuildContext context) {
return Theme(
data: ThemeData.dark(useMaterial3: true),
child: Scaffold(
backgroundColor: _bg,
body: SafeArea(
bottom: false,
child: Column(
children: <Widget>[
_TopBar(onBack: onBack, onSettings: onSettings),
_StoriesRail(stories: _stories, onTap: onStory),
const Divider(height: 1, color: _hairline),
Expanded(
child: ListView(
padding: const EdgeInsets.only(bottom: 28),
children: <Widget>[
_AddYourStoryCard(onTap: onAddStory),
_SectionHeader(onSeeAll: onSeeAll),
for (int i = 0; i < _stories.length - 1; i++) ...<Widget>[
_UpdateRow(
story: _stories[i],
onTap: () => onStory?.call(i),
),
if (i < _stories.length - 2)
const Divider(
height: 1,
thickness: 1,
indent: 78,
color: _hairline,
),
],
],
),
),
],
),
),
),
);
}
}
// ── top bar ──────────────────────────────────────────────────────────────────
class _TopBar extends StatelessWidget {
const _TopBar({this.onBack, this.onSettings});
final VoidCallback? onBack;
final VoidCallback? onSettings;
@override
Widget build(BuildContext context) {
return Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 6),
decoration: const BoxDecoration(
color: SocialFeedStoriesRailScreen._bg,
border: Border(
bottom: BorderSide(color: SocialFeedStoriesRailScreen._hairline),
),
),
child: Row(
children: <Widget>[
IconButton(
onPressed: onBack,
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
color: SocialFeedStoriesRailScreen._textHi,
splashRadius: 22,
),
const Expanded(
child: Text(
'Stories',
style: TextStyle(
fontFamily: SocialFeedStoriesRailScreen._font,
fontSize: 19,
fontWeight: FontWeight.w800,
letterSpacing: -0.6,
color: SocialFeedStoriesRailScreen._textHi,
),
),
),
IconButton(
onPressed: onSettings,
icon: const Icon(Icons.settings_outlined, size: 22),
color: SocialFeedStoriesRailScreen._textHi,
splashRadius: 22,
),
],
),
);
}
}
// ── stories rail ─────────────────────────────────────────────────────────────
class _Story {
const _Story(
this.name,
this.handle,
this.time,
this.colorA,
this.colorB, {
required this.unseen,
});
final String name;
final String handle;
final String time;
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: 112,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(16, 14, 16, 12),
itemCount: stories.length + 1,
separatorBuilder: (BuildContext context, int _) =>
const SizedBox(width: 14),
itemBuilder: (BuildContext context, int i) {
if (i == 0) {
return _AddStoryTile(onTap: onTap);
}
final _Story s = stories[i - 1];
return _StoryItem(story: s, onTap: () => onTap?.call(i - 1));
},
),
);
}
}
class _AddStoryTile extends StatelessWidget {
const _AddStoryTile({this.onTap});
final ValueChanged<int>? onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onTap?.call(-1),
behavior: HitTestBehavior.opaque,
child: SizedBox(
width: 66,
child: Column(
children: <Widget>[
SizedBox(
width: 62,
height: 62,
child: CustomPaint(
painter: _DashedRingPainter(),
child: const Center(
child: Icon(
Icons.add,
color: SocialFeedStoriesRailScreen._brand,
size: 26,
),
),
),
),
const SizedBox(height: 7),
const Text(
'Your story',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: SocialFeedStoriesRailScreen._font,
fontSize: 11.5,
color: SocialFeedStoriesRailScreen._muted,
),
),
],
),
),
);
}
}
class _StoryItem extends StatelessWidget {
const _StoryItem({required this.story, this.onTap});
final _Story story;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: SizedBox(
width: 66,
child: Column(
children: <Widget>[
_RingAvatar(story: story, size: 62),
const SizedBox(height: 7),
Text(
story.name.split(' ').first,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: SocialFeedStoriesRailScreen._font,
fontSize: 11.5,
color: story.unseen
? SocialFeedStoriesRailScreen._textHi
: SocialFeedStoriesRailScreen._muted,
),
),
],
),
),
);
}
}
/// A gradient sweep (unseen) / grey (seen) ring around a gradient monogram.
class _RingAvatar extends StatelessWidget {
const _RingAvatar({required this.story, required this.size});
final _Story story;
final double size;
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) {
final double inner = size - 10;
return SizedBox(
width: size,
height: size,
child: CustomPaint(
painter: _RingAvatarPainter(
colorA: Color(story.colorA),
colorB: Color(story.colorB),
unseen: story.unseen,
),
child: Center(
child: Container(
width: inner,
height: inner,
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: TextStyle(
fontFamily: SocialFeedStoriesRailScreen._font,
fontSize: inner * 0.33,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
),
),
),
);
}
}
// ── add-to-your-story card ───────────────────────────────────────────────────
class _AddYourStoryCard extends StatelessWidget {
const _AddYourStoryCard({this.onTap});
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.fromLTRB(14, 14, 12, 14),
decoration: BoxDecoration(
color: SocialFeedStoriesRailScreen._surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: SocialFeedStoriesRailScreen._hairline),
),
child: Row(
children: <Widget>[
const _Monogram(
initials: 'YOU',
colorA: Color(0xFF6E56F7),
colorB: Color(0xFF9B8CFF),
size: 46,
),
const SizedBox(width: 14),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Your story',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: SocialFeedStoriesRailScreen._font,
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: SocialFeedStoriesRailScreen._textHi,
),
),
SizedBox(height: 3),
Text(
'Add to your story',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: SocialFeedStoriesRailScreen._font,
fontSize: 12.5,
color: SocialFeedStoriesRailScreen._muted,
),
),
],
),
),
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: SocialFeedStoriesRailScreen._brand
.withValues(alpha: 0.16),
shape: BoxShape.circle,
),
child: const Icon(
Icons.add_a_photo_outlined,
color: SocialFeedStoriesRailScreen._brand,
size: 20,
),
),
],
),
),
),
);
}
}
// ── section header ───────────────────────────────────────────────────────────
class _SectionHeader extends StatelessWidget {
const _SectionHeader({this.onSeeAll});
final VoidCallback? onSeeAll;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 12, 8),
child: Row(
children: <Widget>[
const Expanded(
child: Text(
'RECENT UPDATES',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: SocialFeedStoriesRailScreen._font,
fontSize: 11.5,
fontWeight: FontWeight.w700,
letterSpacing: 1.0,
color: SocialFeedStoriesRailScreen._muted,
),
),
),
TextButton(
onPressed: onSeeAll,
style: TextButton.styleFrom(
minimumSize: Size.zero,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text(
'See all',
style: TextStyle(
fontFamily: SocialFeedStoriesRailScreen._font,
fontSize: 13,
fontWeight: FontWeight.w600,
color: SocialFeedStoriesRailScreen._brand,
),
),
),
],
),
);
}
}
// ── update row ───────────────────────────────────────────────────────────────
class _UpdateRow extends StatelessWidget {
const _UpdateRow({required this.story, this.onTap});
final _Story story;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 12, 12),
child: Row(
children: <Widget>[
_RingAvatar(story: story, size: 50),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Flexible(
child: Text(
story.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: SocialFeedStoriesRailScreen._font,
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
color: story.unseen
? SocialFeedStoriesRailScreen._textHi
: SocialFeedStoriesRailScreen._textLo,
),
),
),
if (story.unseen) ...<Widget>[
const SizedBox(width: 8),
Container(
width: 7,
height: 7,
decoration: const BoxDecoration(
color: SocialFeedStoriesRailScreen._brand,
shape: BoxShape.circle,
),
),
],
],
),
const SizedBox(height: 2),
Text(
'@${story.handle}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: SocialFeedStoriesRailScreen._font,
fontSize: 12.5,
color: SocialFeedStoriesRailScreen._muted,
),
),
],
),
),
const SizedBox(width: 10),
Text(
story.time,
style: const TextStyle(
fontFamily: SocialFeedStoriesRailScreen._font,
fontSize: 12,
color: SocialFeedStoriesRailScreen._muted,
),
),
const SizedBox(width: 4),
const Icon(
Icons.chevron_right,
size: 20,
color: SocialFeedStoriesRailScreen._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: SocialFeedStoriesRailScreen._font,
fontSize: size * 0.3,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
);
}
}
// ── painters ─────────────────────────────────────────────────────────────────
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;
}
/// A dashed-look brand ring used for the "Add story" tile.
class _DashedRingPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final Offset center = size.center(Offset.zero);
final double radius = size.width / 2 - 1.5;
// Filled surface disc.
canvas.drawCircle(
center,
radius,
Paint()..color = const Color(0xFF15151B),
);
// Dashed brand ring.
final Paint dash = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.2
..strokeCap = StrokeCap.round
..color = const Color(0xFF6E56F7);
const int segments = 22;
const double gap = 0.34; // fraction of each segment left empty
final double step = (2 * math.pi) / segments;
for (int i = 0; i < segments; i++) {
final double start = i * step;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
start,
step * (1 - gap),
false,
dash,
);
}
}
@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-stories-rail2. AI agent (MCP)
Connect FlutterKit's MCP server in Claude or Cursor and just ask your agent to install social-feed-stories-rail — it fetches and writes the files for you.
FAQ
Can I use this stories screen in a commercial app?
Yes. FlutterKit screens are free for personal and commercial projects under MIT-style terms. There is no licence key or attribution requirement — run `flutterkit add social-feed-stories-rail`, or copy the Dart above into your project, and ship it.
Does the screen need any pub packages or fonts?
No packages — it imports only `dart:math` and `package:flutter/material.dart`. The only asset is the Inter font, which `flutterkit add social-feed-stories-rail` bundles and registers in your pubspec. If you paste the code manually, either add Inter yourself or delete the `fontFamily` lines to fall back to the system font.
Which Flutter version does this need?
Flutter 3.22 or newer, because of `super.key` in the constructor and `_brand.withValues(alpha: 0.16)` on the add-a-photo circle. On an older 3.x SDK, change `withValues(alpha: 0.16)` to `withOpacity(0.16)` and replace `super.key` with `Key? key` plus `super(key: key)`.
How do I load real stories from a server instead of the static list?
The screen reads everything from `_stories`, a `static const List<_Story>`. Convert it to a constructor parameter (`final List<_Story> stories`) and pass it from your state layer, mapping each API record to `_Story(name, handle, time, colorA, colorB, unseen: ...)`. Both `_StoriesRail` and the update loop already take the list as input, so no other code changes. For profile photos, swap the gradient `Container` inside `_RingAvatar` for a `ClipOval` with your image while keeping the painter behind it.
Why does tapping the 'Add story' tile call `onStory(-1)` instead of `onAddStory`?
The rail is built from one `ValueChanged<int>` so every tile is handled uniformly, and `-1` is the sentinel for 'not a story index'. The bigger 'Add to your story' card below uses the dedicated `onAddStory`. If you prefer one handler, pass the same function to both and treat a negative index as the create action.