How to Build a Find Friends Contacts Sync Screen in Flutter (Full Code + Preview)
Asking for the contacts permission cold is the fastest way to get a permanent 'Don't allow'. This tutorial builds Pulse's Find Friends onboarding step in Flutter: a priming card with three `_PermissionPoint` privacy rows and an 'Allow access to contacts' button, which a single `_granted` flag swaps for a `ListView.separated` of matched friends. Each `_ContactRow` pairs an initials `_Monogram` with a `_PillButton` that toggles Add/Added through an `_added` Set, or reads Invite for contacts not yet on Pulse.

Watch the Flutter UI walkthrough
A short screen recording of Pulse · Find Friends 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 two-state screen where one `bool _granted` swaps a permission explainer for a contacts list and relabels the footer from 'Not now' to 'Continue'
- ✓A priming card with three `_PermissionPoint` rows and a filled 'Allow access to contacts' button that flips the state
- ✓A `ListView.separated` whose index 0 is a green check header counting matched contacts with a `where` filter
- ✓A `_PillButton` that reads Add, Added or Invite depending on `onPulse` and membership in an `_added` Set of names
- ✓A `_Monogram` avatar that derives initials from any name with a regex split and scales its font to `size * 0.36`
Step-by-step build
Create the file
Add a new file at lib/social_setup_contacts/social_setup_contacts_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.
A two-callback widget and a purple-on-black palette
import 'package:flutter/material.dart';
/// Find Friends — sixth step of Pulse profile setup. Two inline states: a
/// contacts-permission explainer card (privacy note + Allow access), and, once
/// granted, a list of matched friends already on Pulse with Add toggles plus an
/// invite row for contacts who aren’t. Step progress tops the screen; the pinned
/// CTA continues the flow. Self-contained per CONVENTIONS.md: pure Flutter,
/// bundled Inter font, own dark theme, SafeArea.
class SocialSetupContactsScreen extends StatefulWidget {
const SocialSetupContactsScreen({
super.key,
this.onBack,
this.onContinue,
});
final VoidCallback? onBack;
final VoidCallback? onContinue;
static const String _font = 'Inter';
static const Color _bg = Color(0xFF0B0B0F);
static const Color _surface = Color(0xFF15151B);
static const Color _surfaceAlt = Color(0xFF1D1D26);
static const Color _brand = Color(0xFF6E56F7);
static const Color _accent = Color(0xFF9B8CFF);
static const Color _success = Color(0xFF34D399);
static const Color _hairline = Color(0xFF26262F);
static const Color _textHi = Color(0xFFF4F4F7);
static const Color _textLo = Color(0xFFB5B5C2);
static const Color _muted = Color(0xFF8A8A99);
@override
State<SocialSetupContactsScreen> createState() =>
_SocialSetupContactsScreenState();
}
class _Contact {
const _Contact(this.name, this.handleOrPhone, this.onPulse, this.color);
final String name;
final String handleOrPhone;
final bool onPulse;
final Color color;
}The screen is a `StatefulWidget` — unlike most onboarding steps, this one has a real state transition — but it exposes only `onBack` and `onContinue`; the permission decision itself is internal. Ten `static const Color`s live on the widget class so the private helper widgets below can reach them as `SocialSetupContactsScreen._brand` without a theme lookup. `_bg` #0B0B0F, `_surface` #15151B and `_surfaceAlt` #1D1D26 are three near-black steps that give cards and pills depth without visible borders doing all the work; `_brand` #6E56F7 is reserved for the two filled buttons and the progress bar, `_accent` #9B8CFF for the icon inside the badge, and `_success` #34D399 appears once, on the matched-count check. The tiny `_Contact` class holds `name`, `handleOrPhone`, `onPulse` and a `color` — the last is per-person so monograms differ, and `handleOrPhone` is a single string because the row decides at render time whether to prefix an `@`.
Fixture contacts and the one flag that drives everything
class _SocialSetupContactsScreenState extends State<SocialSetupContactsScreen> {
bool _granted = false;
static const List<_Contact> _contacts = <_Contact>[
_Contact('Nina Park', 'ninap', true, Color(0xFF9B8CFF)),
_Contact('Diego Alvarez', 'diego.a', true, Color(0xFF34D399)),
_Contact('Emma Wells', 'emmawells', true, Color(0xFFF4476B)),
_Contact('Tom Becker', '+1 (555) 019 4420', false, Color(0xFF38BDF8)),
_Contact('Grace Liu', 'graceliu', true, Color(0xFFFBBF24)),
_Contact('Ravi Shah', '+1 (555) 077 8810', false, Color(0xFFEC4899)),
];
final Set<String> _added = <String>{};
@override
Widget build(BuildContext context) {
return Theme(
data: ThemeData.dark(useMaterial3: true),
child: Scaffold(
backgroundColor: SocialSetupContactsScreen._bg,
body: SafeArea(
child: Column(
children: <Widget>[
_SetupHeader(step: 6, total: 8, onBack: widget.onBack),
Expanded(
child: _granted ? _buildList() : _buildPermission(),
),
_SetupFooter(
label: _granted ? 'Continue' : 'Not now',
onContinue: widget.onContinue,
),
],
),
),
),
);
}State is two fields: `bool _granted`, which starts false, and a `Set<String> _added` keyed by contact name. The six `_Contact` fixtures deliberately mix four `onPulse: true` entries carrying handles with two `false` entries carrying phone numbers like '+1 (555) 019 4420' — that split is what lets the list demonstrate both the Add toggle and the Invite row. `build` forces `ThemeData.dark(useMaterial3: true)` on its own `Theme` so the screen renders the same dropped into a light app. The `Column` is header, `Expanded` body, footer, and the body is a plain ternary: `_granted ? _buildList() : _buildPermission()`. The footer label is a second ternary on the same flag — 'Not now' while asking, 'Continue' once granted — so declining is always available as the pinned CTA rather than a small skip link, and both routes call the same `widget.onContinue`.
The permission priming card
Widget _buildPermission() {
return ListView(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
children: <Widget>[
const Text(
'Find your friends',
style: TextStyle(
fontFamily: SocialSetupContactsScreen._font,
fontSize: 27,
fontWeight: FontWeight.w700,
letterSpacing: -0.7,
color: SocialSetupContactsScreen._textHi,
),
),
const SizedBox(height: 8),
const Text(
'See which of your contacts are already on Pulse. We never post or message anyone without you.',
style: TextStyle(
fontFamily: SocialSetupContactsScreen._font,
fontSize: 14.5,
height: 1.5,
color: SocialSetupContactsScreen._textLo,
),
),
const SizedBox(height: 28),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: SocialSetupContactsScreen._surface,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: SocialSetupContactsScreen._hairline),
),
child: Column(
children: <Widget>[
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: SocialSetupContactsScreen._brand.withValues(alpha: 0.16),
),
child: const Icon(Icons.contacts_outlined,
size: 28, color: SocialSetupContactsScreen._accent),
),
const SizedBox(height: 16),
const _PermissionPoint(
icon: Icons.lock_outline,
text: 'Your contacts are matched privately and never shared.',
),
const SizedBox(height: 12),
const _PermissionPoint(
icon: Icons.person_search_outlined,
text: 'Only friends already on Pulse will appear.',
),
const SizedBox(height: 12),
const _PermissionPoint(
icon: Icons.settings_backup_restore,
text: 'You can revoke access anytime in Settings.',
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
height: 50,
child: FilledButton(
onPressed: () => setState(() => _granted = true),
style: FilledButton.styleFrom(
backgroundColor: SocialSetupContactsScreen._brand,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: const Text(
'Allow access to contacts',
style: TextStyle(
fontFamily: SocialSetupContactsScreen._font,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
],
);
}`_buildPermission` is a `ListView` with 24px side padding so the card scrolls on short phones. The 27px `w700` title uses `letterSpacing: -0.7`, and the subtitle makes the promise up front: 'We never post or message anyone without you.' The card is a `_surface` container with an 18px radius and a `_hairline` #26262F border. Its badge is a 60px circle filled with `_brand.withValues(alpha: 0.16)` holding a 28px `Icons.contacts_outlined` in `_accent` — a tinted disc reads calmer than a solid purple one. Three `_PermissionPoint` rows follow with 12px gaps, each pairing an icon (lock, person_search, settings_backup_restore) with one reassurance. The 50px `FilledButton` is styled directly with `_brand` and a 14px `RoundedRectangleBorder`, and its `onPressed` is just `setState(() => _granted = true)`. This is a priming screen: the OS dialog would be requested from that callback, and only after the user has already said yes here.
The matched-friends list with a count header at index 0
Widget _buildList() {
final int matched = _contacts.where((_Contact c) => c.onPulse).length;
return ListView.separated(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
itemCount: _contacts.length + 1,
separatorBuilder: (BuildContext context, int i) =>
const SizedBox(height: 12),
itemBuilder: (BuildContext context, int i) {
if (i == 0) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: <Widget>[
const Icon(Icons.check_circle,
size: 17, color: SocialSetupContactsScreen._success),
const SizedBox(width: 6),
Text(
'$matched friends found on Pulse',
style: const TextStyle(
fontFamily: SocialSetupContactsScreen._font,
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: SocialSetupContactsScreen._textLo,
),
),
],
),
);
}
final _Contact c = _contacts[i - 1];
final bool added = _added.contains(c.name);
return _ContactRow(
contact: c,
added: added,
onToggle: () => setState(() {
if (added) {
_added.remove(c.name);
} else {
_added.add(c.name);
}
}),
);
},
);
}
}`_buildList` first computes `matched` with `_contacts.where((c) => c.onPulse).length`, which is 4 for the fixtures, and interpolates it into '$matched friends found on Pulse'. The list is a `ListView.separated` with `itemCount: _contacts.length + 1`; index 0 returns the header row — a 17px `Icons.check_circle` in `_success` beside 13.5px `w600` text — and every other index reads `_contacts[i - 1]`. Putting the header inside the list rather than above it means it scrolls away with the rows and still gets the 12px `SizedBox` separator for free. Per row, `added` is `_added.contains(c.name)`, and `onToggle` wraps a `setState` that removes or adds the name. Keying the set by name is fine for fixtures; with real data you would key by a contact ID so two people with the same name do not toggle together.
`_PermissionPoint` and `_ContactRow`
class _PermissionPoint extends StatelessWidget {
const _PermissionPoint({required this.icon, required this.text});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Icon(icon, size: 18, color: SocialSetupContactsScreen._muted),
const SizedBox(width: 12),
Expanded(
child: Text(
text,
style: const TextStyle(
fontFamily: SocialSetupContactsScreen._font,
fontSize: 13.5,
height: 1.45,
color: SocialSetupContactsScreen._textLo,
),
),
),
],
);
}
}
class _ContactRow extends StatelessWidget {
const _ContactRow({
required this.contact,
required this.added,
this.onToggle,
});
final _Contact contact;
final bool added;
final VoidCallback? onToggle;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: SocialSetupContactsScreen._surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: SocialSetupContactsScreen._hairline),
),
child: Row(
children: <Widget>[
_Monogram(name: contact.name, color: contact.color, size: 44),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
contact.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: SocialSetupContactsScreen._font,
fontSize: 15,
fontWeight: FontWeight.w600,
color: SocialSetupContactsScreen._textHi,
),
),
const SizedBox(height: 2),
Text(
contact.onPulse ? '@${contact.handleOrPhone}' : contact.handleOrPhone,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: SocialSetupContactsScreen._font,
fontSize: 12.5,
color: SocialSetupContactsScreen._muted,
),
),
],
),
),
const SizedBox(width: 10),
if (contact.onPulse)
_PillButton(
label: added ? 'Added' : 'Add',
filled: !added,
onTap: onToggle,
)
else
_PillButton(label: 'Invite', filled: false, onTap: onToggle),
],
),
);
}
}`_PermissionPoint` is a `Row` with `crossAxisAlignment.start` so an 18px `_muted` icon stays pinned to the first line when the 13.5px text wraps at `height: 1.45`; the text sits in `Expanded` to take the remaining width. `_ContactRow` is a 12px-padded `_surface` card with a 16px radius — slightly tighter than the 18px permission card — holding a 44px `_Monogram`, then an `Expanded` column with the name at 15px `w600` and a 12.5px `_muted` secondary line. That line is `contact.onPulse ? '@${contact.handleOrPhone}' : contact.handleOrPhone`, so members show '@ninap' while off-app contacts show their phone number. Both texts carry `TextOverflow.ellipsis`. The trailing widget is an `if/else` in the children list: members get a `_PillButton` labelled `added ? 'Added' : 'Add'` with `filled: !added`, so the pill goes from solid purple to a quiet outline once added; non-members always get an unfilled 'Invite'.
The pill button and the initials monogram
class _PillButton extends StatelessWidget {
const _PillButton({
required this.label,
required this.filled,
this.onTap,
});
final String label;
final bool filled;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Material(
color: filled
? SocialSetupContactsScreen._brand
: SocialSetupContactsScreen._surfaceAlt,
borderRadius: BorderRadius.circular(11),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(11),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(11),
border: Border.all(
color: filled
? SocialSetupContactsScreen._brand
: SocialSetupContactsScreen._hairline,
),
),
child: Text(
label,
style: TextStyle(
fontFamily: SocialSetupContactsScreen._font,
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: filled ? Colors.white : SocialSetupContactsScreen._textLo,
),
),
),
),
);
}
}
class _Monogram extends StatelessWidget {
const _Monogram({required this.name, required this.color, required this.size});
final String name;
final Color color;
final double size;
String get _initials {
final List<String> parts = name
.trim()
.split(RegExp(r'\s+'))
.where((String s) => s.isNotEmpty)
.toList();
if (parts.isEmpty) return '?';
if (parts.length == 1) return parts.first[0].toUpperCase();
return (parts.first[0] + parts.last[0]).toUpperCase();
}
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color.withValues(alpha: 0.18),
border: Border.all(color: color.withValues(alpha: 0.5)),
),
child: Center(
child: Text(
_initials,
style: TextStyle(
fontFamily: SocialSetupContactsScreen._font,
fontSize: size * 0.36,
fontWeight: FontWeight.w700,
color: color,
),
),
),
);
}
}`_PillButton` puts a `Material` under the `InkWell` so the ripple clips to the 11px radius; the fill is `_brand` when `filled` and `_surfaceAlt` otherwise, and the inner `Container` border matches — `_brand` on `_brand` for the solid state, `_hairline` for the outline — so both states are the same height and never shift the row. Text is 13.5px `w600`, white when filled, `_textLo` otherwise. `_Monogram` computes its initials in a getter: trim, split on `RegExp(r'\s+')`, drop empties, then return '?' for nothing, the single initial for one word, or first-plus-last uppercased. 'Diego Alvarez' becomes 'DA'. The circle uses the contact's colour at `withValues(alpha: 0.18)` with a `0.5`-alpha border of the same colour and the full colour for the letters, and `fontSize: size * 0.36` keeps the initials proportional if you reuse the widget at a different size.
Shared step header and pinned footer
class _SetupHeader extends StatelessWidget {
const _SetupHeader({required this.step, required this.total, this.onBack});
final int step;
final int total;
final VoidCallback? onBack;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 24, 4),
child: Row(
children: <Widget>[
IconButton(
onPressed: onBack,
icon: const Icon(Icons.arrow_back_ios_new,
size: 18, color: SocialSetupContactsScreen._textHi),
),
Expanded(
child: Row(
children: List<Widget>.generate(total, (int i) {
return Expanded(
child: Container(
height: 4,
margin: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2),
color: i < step
? SocialSetupContactsScreen._brand
: SocialSetupContactsScreen._hairline,
),
),
);
}),
),
),
const SizedBox(width: 12),
Text(
'$step/$total',
style: const TextStyle(
fontFamily: SocialSetupContactsScreen._font,
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: SocialSetupContactsScreen._muted,
),
),
],
),
);
}
}
class _SetupFooter extends StatelessWidget {
const _SetupFooter({required this.label, this.onContinue});
final String label;
final VoidCallback? onContinue;
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: SocialSetupContactsScreen._bg,
border:
Border(top: BorderSide(color: SocialSetupContactsScreen._hairline)),
),
padding: const EdgeInsets.fromLTRB(24, 14, 24, 16),
child: SizedBox(
width: double.infinity,
height: 54,
child: FilledButton(
onPressed: onContinue,
style: FilledButton.styleFrom(
backgroundColor: SocialSetupContactsScreen._brand,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
),
child: Text(
label,
style: const TextStyle(
fontFamily: SocialSetupContactsScreen._font,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
);
}
}`_SetupHeader` takes `step` and `total` as ints and builds the progress bar with `List.generate(total, ...)` — eight `Expanded` containers 4px tall with 3px horizontal margins, coloured `_brand` when `i < step` and `_hairline` otherwise, so six of eight fill for this screen. The row is padded `fromLTRB(8, 8, 24, 4)` because the leading `IconButton` carries its own touch padding; the 12.5px '$step/$total' counter closes the row in `_muted`. `_SetupFooter` is a `_bg`-coloured container with only a top `BorderSide` in `_hairline`, so the divider is visible but the footer blends into the canvas. It holds one 54px full-width `FilledButton` at a 15px radius whose `label` is passed in — that is how the parent swaps 'Not now' for 'Continue' without the footer knowing anything about permission state.
Full code
The complete, ready-to-paste source. Free to use in your projects — one click copies it all.
import 'package:flutter/material.dart';
/// Find Friends — sixth step of Pulse profile setup. Two inline states: a
/// contacts-permission explainer card (privacy note + Allow access), and, once
/// granted, a list of matched friends already on Pulse with Add toggles plus an
/// invite row for contacts who aren’t. Step progress tops the screen; the pinned
/// CTA continues the flow. Self-contained per CONVENTIONS.md: pure Flutter,
/// bundled Inter font, own dark theme, SafeArea.
class SocialSetupContactsScreen extends StatefulWidget {
const SocialSetupContactsScreen({
super.key,
this.onBack,
this.onContinue,
});
final VoidCallback? onBack;
final VoidCallback? onContinue;
static const String _font = 'Inter';
static const Color _bg = Color(0xFF0B0B0F);
static const Color _surface = Color(0xFF15151B);
static const Color _surfaceAlt = Color(0xFF1D1D26);
static const Color _brand = Color(0xFF6E56F7);
static const Color _accent = Color(0xFF9B8CFF);
static const Color _success = Color(0xFF34D399);
static const Color _hairline = Color(0xFF26262F);
static const Color _textHi = Color(0xFFF4F4F7);
static const Color _textLo = Color(0xFFB5B5C2);
static const Color _muted = Color(0xFF8A8A99);
@override
State<SocialSetupContactsScreen> createState() =>
_SocialSetupContactsScreenState();
}
class _Contact {
const _Contact(this.name, this.handleOrPhone, this.onPulse, this.color);
final String name;
final String handleOrPhone;
final bool onPulse;
final Color color;
}
class _SocialSetupContactsScreenState extends State<SocialSetupContactsScreen> {
bool _granted = false;
static const List<_Contact> _contacts = <_Contact>[
_Contact('Nina Park', 'ninap', true, Color(0xFF9B8CFF)),
_Contact('Diego Alvarez', 'diego.a', true, Color(0xFF34D399)),
_Contact('Emma Wells', 'emmawells', true, Color(0xFFF4476B)),
_Contact('Tom Becker', '+1 (555) 019 4420', false, Color(0xFF38BDF8)),
_Contact('Grace Liu', 'graceliu', true, Color(0xFFFBBF24)),
_Contact('Ravi Shah', '+1 (555) 077 8810', false, Color(0xFFEC4899)),
];
final Set<String> _added = <String>{};
@override
Widget build(BuildContext context) {
return Theme(
data: ThemeData.dark(useMaterial3: true),
child: Scaffold(
backgroundColor: SocialSetupContactsScreen._bg,
body: SafeArea(
child: Column(
children: <Widget>[
_SetupHeader(step: 6, total: 8, onBack: widget.onBack),
Expanded(
child: _granted ? _buildList() : _buildPermission(),
),
_SetupFooter(
label: _granted ? 'Continue' : 'Not now',
onContinue: widget.onContinue,
),
],
),
),
),
);
}
Widget _buildPermission() {
return ListView(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
children: <Widget>[
const Text(
'Find your friends',
style: TextStyle(
fontFamily: SocialSetupContactsScreen._font,
fontSize: 27,
fontWeight: FontWeight.w700,
letterSpacing: -0.7,
color: SocialSetupContactsScreen._textHi,
),
),
const SizedBox(height: 8),
const Text(
'See which of your contacts are already on Pulse. We never post or message anyone without you.',
style: TextStyle(
fontFamily: SocialSetupContactsScreen._font,
fontSize: 14.5,
height: 1.5,
color: SocialSetupContactsScreen._textLo,
),
),
const SizedBox(height: 28),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: SocialSetupContactsScreen._surface,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: SocialSetupContactsScreen._hairline),
),
child: Column(
children: <Widget>[
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: SocialSetupContactsScreen._brand.withValues(alpha: 0.16),
),
child: const Icon(Icons.contacts_outlined,
size: 28, color: SocialSetupContactsScreen._accent),
),
const SizedBox(height: 16),
const _PermissionPoint(
icon: Icons.lock_outline,
text: 'Your contacts are matched privately and never shared.',
),
const SizedBox(height: 12),
const _PermissionPoint(
icon: Icons.person_search_outlined,
text: 'Only friends already on Pulse will appear.',
),
const SizedBox(height: 12),
const _PermissionPoint(
icon: Icons.settings_backup_restore,
text: 'You can revoke access anytime in Settings.',
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
height: 50,
child: FilledButton(
onPressed: () => setState(() => _granted = true),
style: FilledButton.styleFrom(
backgroundColor: SocialSetupContactsScreen._brand,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: const Text(
'Allow access to contacts',
style: TextStyle(
fontFamily: SocialSetupContactsScreen._font,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
],
);
}
Widget _buildList() {
final int matched = _contacts.where((_Contact c) => c.onPulse).length;
return ListView.separated(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
itemCount: _contacts.length + 1,
separatorBuilder: (BuildContext context, int i) =>
const SizedBox(height: 12),
itemBuilder: (BuildContext context, int i) {
if (i == 0) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: <Widget>[
const Icon(Icons.check_circle,
size: 17, color: SocialSetupContactsScreen._success),
const SizedBox(width: 6),
Text(
'$matched friends found on Pulse',
style: const TextStyle(
fontFamily: SocialSetupContactsScreen._font,
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: SocialSetupContactsScreen._textLo,
),
),
],
),
);
}
final _Contact c = _contacts[i - 1];
final bool added = _added.contains(c.name);
return _ContactRow(
contact: c,
added: added,
onToggle: () => setState(() {
if (added) {
_added.remove(c.name);
} else {
_added.add(c.name);
}
}),
);
},
);
}
}
class _PermissionPoint extends StatelessWidget {
const _PermissionPoint({required this.icon, required this.text});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Icon(icon, size: 18, color: SocialSetupContactsScreen._muted),
const SizedBox(width: 12),
Expanded(
child: Text(
text,
style: const TextStyle(
fontFamily: SocialSetupContactsScreen._font,
fontSize: 13.5,
height: 1.45,
color: SocialSetupContactsScreen._textLo,
),
),
),
],
);
}
}
class _ContactRow extends StatelessWidget {
const _ContactRow({
required this.contact,
required this.added,
this.onToggle,
});
final _Contact contact;
final bool added;
final VoidCallback? onToggle;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: SocialSetupContactsScreen._surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: SocialSetupContactsScreen._hairline),
),
child: Row(
children: <Widget>[
_Monogram(name: contact.name, color: contact.color, size: 44),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
contact.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: SocialSetupContactsScreen._font,
fontSize: 15,
fontWeight: FontWeight.w600,
color: SocialSetupContactsScreen._textHi,
),
),
const SizedBox(height: 2),
Text(
contact.onPulse ? '@${contact.handleOrPhone}' : contact.handleOrPhone,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: SocialSetupContactsScreen._font,
fontSize: 12.5,
color: SocialSetupContactsScreen._muted,
),
),
],
),
),
const SizedBox(width: 10),
if (contact.onPulse)
_PillButton(
label: added ? 'Added' : 'Add',
filled: !added,
onTap: onToggle,
)
else
_PillButton(label: 'Invite', filled: false, onTap: onToggle),
],
),
);
}
}
class _PillButton extends StatelessWidget {
const _PillButton({
required this.label,
required this.filled,
this.onTap,
});
final String label;
final bool filled;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Material(
color: filled
? SocialSetupContactsScreen._brand
: SocialSetupContactsScreen._surfaceAlt,
borderRadius: BorderRadius.circular(11),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(11),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(11),
border: Border.all(
color: filled
? SocialSetupContactsScreen._brand
: SocialSetupContactsScreen._hairline,
),
),
child: Text(
label,
style: TextStyle(
fontFamily: SocialSetupContactsScreen._font,
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: filled ? Colors.white : SocialSetupContactsScreen._textLo,
),
),
),
),
);
}
}
class _Monogram extends StatelessWidget {
const _Monogram({required this.name, required this.color, required this.size});
final String name;
final Color color;
final double size;
String get _initials {
final List<String> parts = name
.trim()
.split(RegExp(r'\s+'))
.where((String s) => s.isNotEmpty)
.toList();
if (parts.isEmpty) return '?';
if (parts.length == 1) return parts.first[0].toUpperCase();
return (parts.first[0] + parts.last[0]).toUpperCase();
}
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color.withValues(alpha: 0.18),
border: Border.all(color: color.withValues(alpha: 0.5)),
),
child: Center(
child: Text(
_initials,
style: TextStyle(
fontFamily: SocialSetupContactsScreen._font,
fontSize: size * 0.36,
fontWeight: FontWeight.w700,
color: color,
),
),
),
);
}
}
class _SetupHeader extends StatelessWidget {
const _SetupHeader({required this.step, required this.total, this.onBack});
final int step;
final int total;
final VoidCallback? onBack;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 24, 4),
child: Row(
children: <Widget>[
IconButton(
onPressed: onBack,
icon: const Icon(Icons.arrow_back_ios_new,
size: 18, color: SocialSetupContactsScreen._textHi),
),
Expanded(
child: Row(
children: List<Widget>.generate(total, (int i) {
return Expanded(
child: Container(
height: 4,
margin: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2),
color: i < step
? SocialSetupContactsScreen._brand
: SocialSetupContactsScreen._hairline,
),
),
);
}),
),
),
const SizedBox(width: 12),
Text(
'$step/$total',
style: const TextStyle(
fontFamily: SocialSetupContactsScreen._font,
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: SocialSetupContactsScreen._muted,
),
),
],
),
);
}
}
class _SetupFooter extends StatelessWidget {
const _SetupFooter({required this.label, this.onContinue});
final String label;
final VoidCallback? onContinue;
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: SocialSetupContactsScreen._bg,
border:
Border(top: BorderSide(color: SocialSetupContactsScreen._hairline)),
),
padding: const EdgeInsets.fromLTRB(24, 14, 24, 16),
child: SizedBox(
width: double.infinity,
height: 54,
child: FilledButton(
onPressed: onContinue,
style: FilledButton.styleFrom(
backgroundColor: SocialSetupContactsScreen._brand,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
),
child: Text(
label,
style: const TextStyle(
fontFamily: SocialSetupContactsScreen._font,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
);
}
}
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-setup-contacts2. AI agent (MCP)
Connect FlutterKit's MCP server in Claude or Cursor and just ask your agent to install social-setup-contacts — it fetches and writes the files for you.
FAQ
Can I use this Find Friends screen in a commercial app?
Yes. FlutterKit screens are free for personal and commercial projects under an MIT-style licence — no key, no attribution, no sign-up. Copy the Dart from this page or run `flutterkit add social-setup-contacts` and ship it.
Does it need any pub packages or fonts?
No packages — it is pure Flutter and imports only `package:flutter/material.dart`. The only asset is the Inter font, which `flutterkit add social-setup-contacts` bundles and registers in your pubspec; if you copy the file by hand, add Inter yourself or drop the `fontFamily` lines to fall back to the system font.
Which Flutter version does this need?
Flutter 3.22 or newer, because the badge and `_Monogram` use `Color.withValues(alpha: ...)` and the constructor uses `super.key`. On an older SDK replace each `withValues(alpha: x)` with `withOpacity(x)` and expand the constructor to `{Key? key, ...} : super(key: key)`.
How do I request the real contacts permission and load actual matches?
Keep the priming card as is and change the Allow button's `onPressed`: call your permission plugin there, and only set `_granted = true` when it returns granted. Then replace the `static const _contacts` fixture with a list your matching service returns, mapping each result to `_Contact` with `onPulse` set from whether the server found an account. Nothing in `_buildList` needs to change.
Why does the footer say 'Not now' instead of a small skip link?
Because declining is a legitimate outcome, not a failure. Making 'Not now' the full-width pinned CTA gives users an obvious, non-guilty way past the permission ask, and since both labels call the same `onContinue` the flow advances either way. Once `_granted` flips, the same button relabels to 'Continue'.