How to Add a Feedback Board to a Flutter App
By the end of this you will have a screen inside your Flutter app where users read every feature request others have submitted, upvote the ones they want, comment on them, and file their own — without leaving the app, creating an account, or emailing you. On your side, those submissions land in a triage queue you can filter, re-status, and push into GitHub, Linear, Notion or another tracker. The client-side work is roughly one wrapper widget and two routes. Most of this guide is the part that is not obvious from an API reference: where the board belongs in your navigation, what the server does with a submission after it accepts it, and the four mistakes that account for most failed first integrations.
This uses the feedbackkit_flutter package, which ships pre-built widgets plus a plain HTTP client if you would rather build your own UI. It targets Flutter 3.10 and Dart 3.0 or newer.
1. Install the package
flutter pub add feedbackkit_flutter
It depends on http and shared_preferences — the latter is how the SDK remembers which user ID cast which vote across launches, so a user who votes today does not get a fresh, empty vote state tomorrow.
2. Wrap your app in the provider
FeedbackKitProvider builds the API client, holds the shared list state, and exposes both to everything below it. Put it above your MaterialApp so any route can reach it.
import 'package:flutter/material.dart';
import 'package:feedbackkit_flutter/feedbackkit_flutter.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return FeedbackKitProvider(
apiKey: const String.fromEnvironment('FEEDBACKKIT_API_KEY'),
userId: 'user_12345',
child: MaterialApp(
title: 'My App',
home: const HomePage(),
),
);
}
}
The API key is a project key from your FeedbackKit dashboard. The SDK sends it as an X-API-Key header on every request. Reading it through String.fromEnvironment and passing --dart-define=FEEDBACKKIT_API_KEY=... at build time keeps it out of version control — be clear-eyed that it is still compiled into the shipped binary, as any client-side key is. It is scoped to one project and is meant to live in an app.
Every constructor parameter:
| Parameter | Type | Required | Notes |
|---|---|---|---|
apiKey | String | Yes | Sent as X-API-Key |
child | Widget | Yes | Usually your MaterialApp |
userId | String? | No, but see below | Identifies the submitter and voter |
baseUrl | String? | No | Defaults to the production API; override for a local server |
theme | FeedbackKitTheme? | No | Colors, radius, spacing; FeedbackKitTheme.dark is a preset |
locale | String? | No | Auto-detected when omitted |
translations | Map<String, String>? | No | Override individual strings |
A note on the word "provider": this is not the provider package from pub.dev, and you do not need it. FeedbackKitProvider is a StatefulWidget that publishes an InheritedWidget; the list and vote state live in ChangeNotifier subclasses that the SDK's widgets observe with ListenableBuilder. So it composes fine with Riverpod, Bloc, or whatever you already use — it does not compete with them for the root of your tree.
3. Build the board screen
FeedbackList loads on mount and renders a card per item, each with a vote button, a status badge and a category badge.
class FeedbackPage extends StatelessWidget {
const FeedbackPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Feature requests')),
body: FeedbackList(
onFeedbackTap: (feedback) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => FeedbackDetailPage(feedback: feedback),
),
);
},
onVoteChange: (feedback, response) {
FeedbackKitProvider.of(context).feedbackList.updateFeedback(
feedback.copyWith(
voteCount: response.voteCount,
hasVoted: response.hasVoted,
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => openSubmitSheet(context),
child: const Icon(Icons.add),
),
);
}
}
That onVoteChange block is worth understanding rather than copying. The vote button updates itself optimistically the moment it is tapped, then reconciles with the server response. But the shared list the notifier holds does not know that happened, so if the user backs out and returns, the count reverts. Writing the response back with updateFeedback keeps the two in sync. FeedbackList also accepts emptyBuilder, errorBuilder, loadingBuilder and autoLoad if you want to supply your own states.
The list widget does not bring its own pull-to-refresh. Because it renders a scrollable, you can add one:
RefreshIndicator(
onRefresh: () => FeedbackKitProvider.of(context).feedbackList.refresh(),
child: FeedbackList(onFeedbackTap: openDetail),
)
The detail screen is a one-liner. FeedbackDetailView loads and posts comments itself.
class FeedbackDetailPage extends StatelessWidget {
final FeedbackItem feedback;
const FeedbackDetailPage({super.key, required this.feedback});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Details')),
body: FeedbackDetailView(feedback: feedback),
);
}
}
4. The submit flow
SubmitFeedbackView is a form: category chips, title, description, and an optional email with a mailing-list opt-in that only appears once an address is typed. It has no chrome of its own, which makes it easy to host in a modal sheet.
Future<void> openSubmitSheet(BuildContext context) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (sheetContext) => FractionallySizedBox(
heightFactor: 0.9,
child: SubmitFeedbackView(
initialCategory: FeedbackCategory.featureRequest,
onSubmitted: (feedback) {
Navigator.of(sheetContext).pop();
FeedbackKitProvider.of(context).feedbackList.refresh();
},
onCancel: () => Navigator.of(sheetContext).pop(),
),
),
);
}
Neither callback closes the sheet for you — that is deliberate, so the same widget works as a full route, a tab, or a sheet. Pop it yourself in onSubmitted and onCancel.
5. Where it belongs in your app
Three placements that work, in rough order of how much traffic they get:
- A settings row. "Feature requests" or "Suggest an idea" next to "Rate this app". Lowest friction to add, and it is where users already go when they want something changed.
- A named route. Register
'/feedback'in yourroutesmap and push it from a drawer item, an empty state, or a nudge after a user completes something for the tenth time. - A tab. Justified only if the board is a real part of the product — a public roadmap, say. Otherwise it takes prime navigation space for a screen most users open once.
Whichever you pick, keep the submit form one tap from the list rather than a separate entry point. Users who arrive intending to complain often find their complaint already filed and just upvote it, which is a better outcome for both of you: you get a count instead of a duplicate.
6. What the server does with it
A submission is accepted and immediately given one vote — the creator's. So a brand-new item shows a count of 1, not 0, and the submitter sees the button already in its voted state. It lands with the pending status, invisible to nobody but unreviewed by you.
From your dashboard you move it along:
| Status | Voting allowed |
|---|---|
| pending | Yes |
| approved | Yes |
| in_progress | Yes |
| testflight | Yes |
| completed | No |
| rejected | No |
Voting is closed on completed and rejected — the server rejects those votes with a 403, and VoteButton disables itself client-side so users do not run into it. In the enum these are FeedbackStatus.inProgress and so on; the underscored spellings above are the wire values.
Once an item is worth building, you can push it to a tracker: GitHub, Notion, ClickUp, Linear, Monday.com, Trello, Airtable, Asana and Basecamp are supported, plus Slack for notifications and HubSpot, Salesforce and Email Campaign for contact sync. Capabilities are not identical across providers — status sync is broadly available, comment sync is not (GitHub, for one, does not have it). Check the integrations page for the per-provider matrix before you standardise on one.
7. Dropping to the client
If the built-in widgets do not fit your design, use the same client directly and render whatever you like.
final fk = FeedbackKitProvider.of(context);
final List<FeedbackItem> planned = await fk.client.feedback.list(
const ListFeedbackOptions(status: FeedbackStatus.approved),
);
final VoteResponse result = await fk.client.votes.vote(planned.first.id);
final comments = await fk.client.comments.list(planned.first.id);
Note the shape: FeedbackKitProvider.of(context) returns a context object, and the HTTP client hangs off it as .client. The same object exposes .feedbackList and .votes notifiers, .theme, .userId, and .setUserId. Filtering the shared list, rather than fetching your own copy, goes through the notifier:
final fk = FeedbackKitProvider.of(context);
await fk.feedbackList.setStatusFilter(FeedbackStatus.inProgress);
await fk.feedbackList.clearFilters();
Full endpoint reference lives in the OpenAPI docs.
8. Four things that will trip you up
Submitting without a user ID fails. This is the most common one. The SDK attaches the current user ID to create and vote requests, and the server requires it — no user ID means a 400, surfaced as ValidationError. If userId is null on the provider and nothing was persisted from a previous session, submission breaks. Set it at the root, or call setUserId once you know who the user is:
await FeedbackKitProvider.of(context).setUserId('user_12345');
Any stable opaque string works — your own account ID, or a UUID you generate on first launch for anonymous users. It is what de-duplicates votes, so do not regenerate it per session.
Release builds cannot reach the network on Android. The Flutter template puts the internet permission in the debug and profile manifests only. Everything works locally, then the release build shows a network error. Add it to android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
On macOS the equivalent is the com.apple.security.network.client entitlement, which must be present in both DebugProfile.entitlements and Release.entitlements.
A wrong or revoked key looks like an empty board, not an error, if you supplied your own emptyBuilder and it swallows the error case. The SDK throws AuthenticationError on a 401. Handle the typed errors explicitly rather than catching broadly:
try {
await fk.client.feedback.create(
const CreateFeedbackRequest(
title: 'Add dark mode',
description: 'A system-matching dark theme, please.',
category: FeedbackCategory.featureRequest,
),
);
} on AuthenticationError {
// 401 - the API key was rejected
} on ValidationError {
// 400 - often a missing user ID
} on ForbiddenError {
// 403 - e.g. voting on completed or rejected feedback
} on ConflictError {
// 409 - this user has already voted
} on NetworkError {
// offline, or the request timed out
}
A submission succeeds but never appears. On the Free tier there is a cap on visible feedback per project. Past it, the write still returns 2xx — the item saves and stays visible to the person who submitted it, but nobody else sees it. So a lone tester finds nothing wrong and the board looks empty to everyone else. If items are vanishing at a suspiciously round number, that is what is happening; see pricing for the tier limits.
Where to go next
The board is worth more once you are actually working it: triage weekly, move things to in_progress so voters can see momentum, and mark items completed when they ship — voters who opted in get told, which is the part that makes people submit again. A public roadmap falls out of the same data for free.
For the full widget and model reference, see the Flutter SDK docs; other platforms are listed under documentation.