Flutter Feedback Board Tutorial: Flutter SDK 1.0
This tutorial covers Flutter SDK 1.0. If you are starting today, use the current Flutter tutorial, which covers the latest release. What 1.0 contains is summed up in Introducing the FeedbackKit Flutter SDK.
By the end of this guide your Flutter app has a screen where people read the requests others have filed, vote for the ones they want, and file their own, without leaving the app. The screens are widgets that come with the package. Your part is the package, one wrapper widget, two routes and a stable user id. Version 1.0 also has a few rough edges against the server, and this guide points them out where they come up, with a way around each one where there is one.
The short version
- Add
feedbackkit_flutter1.0.1 from pub.dev. It needs Flutter 3.10 and Dart 3.0 or later. - Wrap your
MaterialAppinFeedbackKitProviderwith your key, the production address and a user id. - Put
FeedbackListin a screen, and give it routes toFeedbackDetailViewandSubmitFeedbackView. - Pass a stable user id for each person. Without one, filing and voting fail.
- Theme it with
FeedbackKitTheme, or build your own screens on the client.
What you need
Flutter 3.10 and Dart 3.0 or later, and a FeedbackKit project with its API key. Create the project in the FeedbackKit admin app for iPhone, iPad or Mac, and copy the key from the project's settings. Keys start with sf_.
The examples use Trailmate, a fictional hiking app whose users keep asking for offline maps.
1. Install the package
flutter pub add feedbackkit_flutter
That adds 1.0.1, the release this tutorial is written against. To keep it there when later versions appear, pin it in pubspec.yaml:
dependencies:
feedbackkit_flutter: 1.0.1
The package brings http and shared_preferences with it. Section 7 uses shared_preferences in your own code too, so add it as a direct dependency with flutter pub add shared_preferences.
2. Wrap your app in the provider
FeedbackKitProvider builds the API client and holds the list and vote state for every FeedbackKit widget below it. Put it above MaterialApp, not inside a route, so pushed routes and bottom sheets can reach it too:
import 'package:flutter/material.dart';
import 'package:feedbackkit_flutter/feedbackkit_flutter.dart';
class TrailmateApp extends StatelessWidget {
const TrailmateApp({super.key, required this.userId});
final String userId;
@override
Widget build(BuildContext context) {
return FeedbackKitProvider(
apiKey: const String.fromEnvironment('FEEDBACKKIT_API_KEY'),
baseUrl: 'https://api.feedbackkit.prod.swiftly-developed.com/api/v1',
userId: userId,
child: const MaterialApp(
title: 'Trailmate',
home: IdeasScreen(),
),
);
}
}
Section 7 shows where userId comes from. Pass the production address explicitly. A key belongs to the server it was created on, projects made in the admin app live on the production server, and the SDK's built-in default is a different address.
The key travels as an X-API-Key header. It is a client key for one project, made to ship inside an app, and it covers end-user actions: reading, filing and voting. Build with --dart-define=FEEDBACKKIT_API_KEY=sf_your_api_key so it stays out of your repository. It is still compiled into the app, as any client key is.
3. Show the board with FeedbackList
FeedbackList loads the project's requests when it appears and draws a card for each: status and category badges, a vote button, the title, three lines of description, the comment count and the request's age. The screen around it is yours:
class IdeasScreen extends StatelessWidget {
const IdeasScreen({super.key});
@override
Widget build(BuildContext context) {
final feedbackKit = FeedbackKitProvider.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Ideas'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: feedbackKit.feedbackList.refresh,
),
],
),
body: FeedbackList(
onFeedbackTap: (item) async {
await Navigator.of(context).push(MaterialPageRoute(
builder: (_) => IdeaDetailScreen(item: item),
));
feedbackKit.feedbackList.refresh();
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const SuggestIdeaScreen(),
)),
child: const Icon(Icons.add),
),
);
}
}
Three things about that screen:
- Refresh after the detail screen. Each card's vote button keeps its own count once drawn, so a vote cast on the detail screen does not show on the card until the list reloads. The
refresh()after the route returns takes care of it. - The list has no refresh gesture. The app bar button covers that. While it reloads, the list shows its loading state.
- The states are yours to replace.
loadingBuilder,emptyBuilderanderrorBuilderswap in your own widgets, andautoLoad: falsestops the load on appearance if you want to callfeedbackList.loadFeedback()yourself.
4. The detail screen
FeedbackDetailView takes a FeedbackItem and draws its badges, vote button, title, full description, when it was filed, and its comments. It has no app bar, so give it a Scaffold:
class IdeaDetailScreen extends StatelessWidget {
const IdeaDetailScreen({super.key, required this.item});
final FeedbackItem item;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Idea')),
body: FeedbackDetailView(feedback: item),
);
}
}
In 1.0 the comment part of this screen does not work against the server. Reading comments works, but every author shows as "Anonymous" and replies from your team carry no Team label, because the server sends no author name and marks team replies with a field the SDK does not read. Posting a comment fails: the request carries no user id, the server refuses it, and the text stays in the field with no message.
If you would rather not show that field, build the screen from the public pieces instead. They read the theme from the provider:
class IdeaDetailScreen extends StatelessWidget {
const IdeaDetailScreen({super.key, required this.item});
final FeedbackItem item;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Idea')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Row(
children: [
StatusBadge(status: item.status),
const SizedBox(width: 8),
CategoryBadge(category: item.category),
const Spacer(),
VoteButton(feedback: item),
],
),
const SizedBox(height: 16),
Text(item.title, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 8),
Text(item.description),
],
),
);
}
}
5. The submit form
SubmitFeedbackView is a form with category chips (Feature Request, the default, Bug Report, Improvement and Other), a title, a description and an optional email. It shows an error if the title or description is empty, and calls onSubmitted with the new request. Like the detail view, it has no app bar:
class SuggestIdeaScreen extends StatelessWidget {
const SuggestIdeaScreen({super.key});
@override
Widget build(BuildContext context) {
final feedbackKit = FeedbackKitProvider.of(context);
return Scaffold(
appBar: AppBar(title: const Text('Suggest an idea')),
body: SubmitFeedbackView(
onSubmitted: (item) {
feedbackKit.feedbackList.refresh();
Navigator.of(context).pop();
},
onCancel: () => Navigator.of(context).pop(),
),
);
}
}
The form draws its own "Submit Feedback" heading, and its Cancel button appears because onCancel is set. initialCategory: FeedbackCategory.bugReport preselects a chip, which suits a "Report a problem" row in Settings. The text fields show no placeholder text in this version, so the labels above them do the explaining.
Leave the email field alone, or tell people what it does not do yet: in 1.0 the address does not reach the server. The SDK sends it as email, the server reads userEmail, and so the person who filed is not emailed when the request's status changes.
6. Voting
Voting needs no code of yours. The vote button sits on every card and on the detail screen. A tap raises the count at once and sends the vote, and the button returns to its earlier state if the server refuses. The rules behind it:
- A new request starts with its submitter's vote, so it shows a count of 1.
- Voting stops at Completed and Rejected. The button fades and ignores taps, and the server refuses new votes.
- Each user id votes once per request.
hasVotedon eachFeedbackItemtells the button which state to draw. - Removing a vote fails in 1.0. The SDK sends the user id on the query string, the server reads it from the request body, and the button returns to its voted state. Treat votes as permanent for now.
Here is how the board looks in FeedbackKit, with sample data from Trailmate:
One board on every screen
7. Who is voting: the user id
Every request and vote belongs to a user id, and in 1.0 the SDK never creates one. If the provider has none, the list still loads, but filing and voting fail with a ValidationError. If your app has accounts, use the account id. If it does not, make one id per install and keep it:
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
Future<String> trailmateUserId() async {
final prefs = await SharedPreferences.getInstance();
var id = prefs.getString('trailmate_user_id');
if (id == null) {
id = 'install-${Random.secure().nextInt(0x7fffffff).toRadixString(16)}'
'-${DateTime.now().microsecondsSinceEpoch}';
await prefs.setString('trailmate_user_id', id);
}
return id;
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(TrailmateApp(userId: await trailmateUserId()));
}
Never pass a fixed string such as 'trailmate-user-42' in a release build: every install would be the same voter, and each request could collect one vote in total.
When someone signs in, switch to their account id:
await FeedbackKitProvider.of(context).setUserId(account.id);
That applies at once and is stored with shared_preferences. Know the rule that follows from it: at launch, a stored id beats the one you pass to the provider. Once setUserId has run, or once the userId you pass has changed while the app was running, that stored id is used at every later launch. So call setUserId with the account id on each sign-in, and with the per-install id on sign-out. Votes cast under an earlier id stay with that id.
Version 1.0 has no way to pass what a user pays, so requests from Flutter users carry no MRR.
8. Theming
The widgets are built from Flutter's widgets library rather than Material, so they ignore your ThemeData and take their look from FeedbackKitTheme:
FeedbackKitProvider(
apiKey: const String.fromEnvironment('FEEDBACKKIT_API_KEY'),
baseUrl: 'https://api.feedbackkit.prod.swiftly-developed.com/api/v1',
userId: userId,
theme: const FeedbackKitTheme(
primaryColor: Color(0xFF2E7D32),
borderRadius: 16,
statusColors: StatusColors(testflight: Color(0xFF00838F)),
categoryColors: CategoryColors(featureRequest: Color(0xFF5E35B1)),
),
child: const MaterialApp(home: IdeasScreen()),
)
| Property | Default | Used for |
|---|---|---|
primaryColor | Blue 0xFF3B82F6 | Vote buttons, the Submit button, the Team label |
backgroundColor | Near white | Behind the detail screen and the form |
cardBackgroundColor | White | Cards, fields and comments |
textColor, secondaryTextColor | Near black, grey | Titles, then descriptions and dates |
borderColor | Light grey | Card and field borders |
errorColor | Red | Error messages |
statusColors, categoryColors | One colour each | The badges |
borderRadius | 12 | Corners |
spacing | 8 | The base unit for padding and gaps |
FeedbackKitTheme.dark is a ready-made dark theme, and copyWith adjusts it, for example FeedbackKitTheme.dark.copyWith(primaryColor: const Color(0xFF66BB6A)). The SDK does not follow the system setting by itself. Pass the dark theme when your app is dark, and the provider applies a new theme whenever it rebuilds with one.
9. Your own screens
Everything the widgets do goes through FeedbackKitProvider.of(context).client. Here Trailmate's home screen shows the most-voted Approved request. The status filter is applied on the server:
class MostWanted extends StatelessWidget {
const MostWanted({super.key});
@override
Widget build(BuildContext context) {
final client = FeedbackKitProvider.of(context).client;
return FutureBuilder<List<FeedbackItem>>(
future: client.feedback.list(
const ListFeedbackOptions(status: FeedbackStatus.approved),
),
builder: (context, snapshot) {
final items = [...?snapshot.data]
..sort((a, b) => b.voteCount.compareTo(a.voteCount));
if (items.isEmpty) return const SizedBox.shrink();
return ListTile(
leading: const Icon(Icons.trending_up),
title: Text(items[0].title),
trailing: Text('${items[0].voteCount} votes'),
);
},
);
}
}
A FutureBuilder with the future created in build fetches again on every rebuild. For a screen that rebuilds often, keep the future in a State.
The client also has feedback.get(), feedback.create() with a CreateFeedbackRequest, votes.vote() and comments.list(). Failures arrive as FeedbackKitError subclasses, each with the server's message:
Future<void> fileIdea(FeedbackKit client, String title, String details) async {
try {
await client.feedback.create(CreateFeedbackRequest(
title: title,
description: details,
category: FeedbackCategory.featureRequest,
));
} on AuthenticationError {
debugPrint('Check the API key and the server address.');
} on ValidationError catch (e) {
debugPrint('Refused: ${e.message}');
} on NetworkError catch (e) {
debugPrint(e.isTimeout ? 'Timed out after 30 seconds' : 'No connection');
}
}
10. Where the board goes in your app
Keep the submit form one tap from the list. People who arrive to ask for offline maps often find the request already there, and vote for it instead of filing a duplicate.
A pushed screen
The IdeasScreen above, opened from a "Suggest a feature" row in Settings. It suits most apps.
A tab
Worth it when the board is a place people come back to. Put IdeasScreen in a BottomNavigationBar layout. If you keep tabs alive in an IndexedStack, the list loads when the app starts rather than when the tab opens, because every child of the stack is built at once.
A bottom sheet for the form
Because the provider sits above MaterialApp, the form works in a modal sheet too:
void showSuggestSheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (sheetContext) => Padding(
padding: MediaQuery.viewInsetsOf(sheetContext),
child: SubmitFeedbackView(
onSubmitted: (_) {
FeedbackKitProvider.of(context).feedbackList.refresh();
Navigator.of(sheetContext).pop();
},
onCancel: () => Navigator.of(sheetContext).pop(),
),
),
);
}
11. Testing tips
A wrong key shows an error, not an empty list. When the server rejects the key, the list shows "Failed to load feedback" with the server's message and a Retry button. Check the key and the address together, since a key works on its own server.
Release builds on Android need the internet permission. Flutter's Android template declares it for debug and profile builds, not in android/app/src/main/AndroidManifest.xml. Add <uses-permission android:name="android.permission.INTERNET"/> there, or the board loads in debug and fails in release.
Keep test requests off the real board. Everything you file while testing lands in the project your key belongs to. Pro includes two projects, so you can give debug builds a key from a second one with a different --dart-define. Otherwise, delete test requests in the admin app afterwards.
Start over as a new user. Call client.clearStorage() from a debug menu, clear your own stored id, and relaunch.
Test the cards without a server. FeedbackItem has a public constructor, and the card and badges fall back to the default theme without a provider:
testWidgets('shows a request', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: FeedbackCard(
feedback: FeedbackItem(
id: 'preview',
title: 'Offline maps',
description: 'Download a region before a hike so the map works without signal.',
status: FeedbackStatus.approved,
category: FeedbackCategory.featureRequest,
voteCount: 248,
hasVoted: false,
commentCount: 12,
createdAt: DateTime(2026, 2, 1),
updatedAt: DateTime(2026, 2, 1),
),
),
),
));
expect(find.text('Offline maps'), findsOneWidget);
expect(find.text('Approved'), findsOneWidget);
});
Tapping the vote button in such a test needs a provider above it, since the vote goes through the client.
Where to go next
Once requests come in, the work moves to your side: triage them in the FeedbackKit admin app on iPhone, iPad or Mac, which notifies your team of each new request and comment, merge duplicates, and move the ones you build through Approved, In Progress and TestFlight to Completed. On Pro and up you can send requests to GitHub, Linear, Notion or another of the trackers FeedbackKit connects to, and post new ones to Slack. The reference for every widget and method is in the Flutter SDK docs, and the next release is described in What's New in Flutter SDK 1.1.
Every plan includes the Flutter SDK. Start free, copy your project key, and the board can be in your next test build.