Skip to content
12 min read Guides Flutter

How to Add a Feedback Board to a Flutter App

Last updated

This guide covers the Flutter SDK 1.0.1 build from pub.dev. Later releases, tagged 1.1.0, 1.1.1, 1.2.0 and 2.1.0, exist as git tags on the SDK repository but are not on pub.dev yet, so flutter pub add feedbackkit_flutter gives you 1.0.1. The changelog sums up what 1.0 contains and what the tagged releases change. This page is the walkthrough for the registry build: what it does today, and how to ship a board on it, theming and widget tests included.

By the end of this you will have a screen inside your Flutter app where users read the feature requests others have submitted, upvote the ones they want, and file their own, without leaving the app 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 one wrapper widget, two routes and a stable user id.

What works in 1.0.1, and what does not

Unlike the JavaScript and React Native 1.0.1 builds, the Flutter package reads the server's snake_case fields itself, so vote counts, the voted state and comment counts all show correctly. Reading the board, filtering it, opening a request, filing one, voting and posting a comment work. Measured against the FeedbackKit API on 18 October 2026, these do not:

  • Removing a vote fails. unvote puts the user id on the query string, and the server reads it from the request body. The vote button returns to its voted state. Treat votes as permanent on 1.0.1.
  • The form's email does not reach FeedbackKit. The SDK sends it as email, and the server reads userEmail, so the submitter is not emailed about status changes.
  • A vote cannot ask for status emails. votes.vote takes no email, and its notifyOnStatusChange flag goes out under a key the server does not read.
  • Comment authors show as "Anonymous", and team replies carry no Team label. The server sends no author name, and marks team replies with is_admin, a field 1.0.1 does not read.
  • users.register and event tracking fail. Both send field names the server does not accept, so requests from Flutter 1.0.1 carry no MRR.

1. Install the package

flutter pub add feedbackkit_flutter

That adds 1.0.1. It needs Flutter 3.10 and Dart 3.0 or newer, and brings http and shared_preferences with it. To keep it on this build when later versions reach pub.dev, pin it in pubspec.yaml:

dependencies:
  feedbackkit_flutter: 1.0.1

2. Wrap your app in the provider

FeedbackKitProvider builds the API client, holds the shared list and vote state, and exposes both to everything below it. Put it above your MaterialApp so pushed routes and bottom sheets can reach it:

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'),
      userId: userId,
      child: const MaterialApp(
        title: 'Trailmate',
        home: IdeasScreen(),
      ),
    );
  }
}

1.0.1's provider takes five parameters:

ParameterTypeRequiredNotes
apiKeyStringYesSent as X-API-Key
childWidgetYesUsually your MaterialApp
userIdString?No, but see section 6Identifies the submitter and voter
baseUrlString?NoThe default reaches FeedbackKit's production server
themeFeedbackKitTheme?NoColours, radius, spacing; FeedbackKitTheme.dark is a preset

The API key is a project key, copied from your project's settings. Building with --dart-define=FEEDBACKKIT_API_KEY=sf_your_api_key keeps it out of version control. It is still compiled into the shipped binary, as any client-side key is. It is scoped to one project, covers end-user actions such as reading, filing and voting, and is meant to live in an app.

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. It composes fine with Riverpod, Bloc, or whatever you already use.

3. Build the board screen

FeedbackList loads the board when it appears and renders a card per item, each with a vote button, a status badge and a category badge:

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();
        },
        onVoteChange: (item, response) {
          feedbackKit.feedbackList.updateFeedback(
            item.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 the moment it is tapped, then reconciles with the server response. The shared list the notifier holds does not know that happened, so writing the response back with updateFeedback keeps a revisited card in step. A vote cast on the detail screen is a different matter: each card's vote button keeps its own count once drawn, so the refresh() after the detail route returns redraws the cards with the server's numbers, showing the loading state while it runs. FeedbackList also accepts emptyBuilder, errorBuilder and loadingBuilder if you want to supply your own states, and autoLoad: false stops the load on appearance if you would rather call feedbackList.loadFeedback() yourself. It has no pull-to-refresh gesture, which is why the app bar has a refresh button.

The detail screen is one widget. FeedbackDetailView draws the badges, the vote button, the description and the comments, and has no app bar of its own:

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),
    );
  }
}

4. The submit flow

SubmitFeedbackView is a form: category chips, title, description, and an optional email, which on 1.0.1 does not reach the server, as noted above. Consider telling users so, or hiding the board's promise of updates. The form 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) => Padding(
      padding: MediaQuery.viewInsetsOf(sheetContext),
      child: SubmitFeedbackView(
        initialCategory: FeedbackCategory.featureRequest,
        onSubmitted: (item) {
          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. The form draws its own "Submit Feedback" heading, and its Cancel button appears because onCancel is set. The category chips are Feature Request, the default, Bug Report, Improvement and Other: initialCategory: FeedbackCategory.bugReport suits a "Report a problem" row in Settings. The text fields show no placeholder text in 1.0.1, so the labels above them do the explaining.

5. Where it belongs in your app

  • 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 your routes map 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 when the board is a real part of the product, a place users come back to vote and follow status changes. 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.

Whichever you pick, keep the submit form one tap from the list. Users who arrive intending to complain often find their complaint already filed and upvote it instead: you get a count rather than a duplicate.

6. Who is voting: the user id

Every request and vote belongs to a user id, and 1.0.1 never creates one. Without it 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()));
}

Add shared_preferences as a direct dependency for that code with flutter pub add shared_preferences. 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 with await FeedbackKitProvider.of(context).setUserId(account.id). It applies at once and is stored. Know the rule that follows: at launch, a stored id beats the one you pass to the provider. So call setUserId on each sign-in with the account id, and on sign-out with the per-install id. Votes cast under an earlier id stay with that id.

7. What the server does with it

A submission is accepted and given one vote, its submitter's. So a brand new item shows a count of 1, not 0. It lands as Pending. From the admin app your team moves it through the six statuses: Pending, Approved, In Progress, TestFlight, Completed and Rejected. Voting closes at Completed and Rejected: the server refuses those votes with a 403, and VoteButton disables itself for those statuses. In the enum these are FeedbackStatus.inProgress and so on. Here is how the board looks in FeedbackKit, with sample data from Trailmate, a fictional hiking app:

One board on every screen

The same FeedbackKit request list in an app on Mac, iPad, iPhone and Android, where users submit and vote on requests from inside the app.

Once an item is worth building, Pro and up can push it to GitHub, Linear, ClickUp, Notion, Monday.com, Trello, Airtable, Asana or Basecamp, and post new ones to Slack. Capabilities are not identical across providers: status sync is broadly available, comment sync is not, and GitHub has none. Check the integrations page for the per-provider matrix.

8. Dropping to the client

If the built-in widgets do not fit your design, use the same client and render whatever you like:

final fk = FeedbackKitProvider.of(context);

final List<FeedbackItem> approved = await fk.client.feedback.list(
  const ListFeedbackOptions(status: FeedbackStatus.approved),
);

final FeedbackItem top = approved[0];
final VoteResponse result = await fk.client.votes.vote(top.id);
final comments = await fk.client.comments.list(top.id);

FeedbackKitProvider.of(context) returns a context object, and the HTTP client hangs off it as .client. The same object exposes the .feedbackList and .votes notifiers, .theme, .userId and .setUserId. Filtering the shared list goes through the notifier:

final fk = FeedbackKitProvider.of(context);
await fk.feedbackList.setStatusFilter(FeedbackStatus.inProgress);
await fk.feedbackList.clearFilters();

Failures arrive as typed errors. Catch them explicitly rather than broadly:

try {
  await fk.client.feedback.create(
    const CreateFeedbackRequest(
      title: 'Offline maps',
      description: 'Download a region before a hike so the map works without signal.',
      category: FeedbackCategory.featureRequest,
    ),
  );
} on AuthenticationError {
  // 401: the API key was rejected
} on ValidationError catch (e) {
  // 400: often a missing user id; e.message says which field
} on ForbiddenError {
  // 403: an archived project
} on NetworkError catch (e) {
  // offline, or e.isTimeout after 30 seconds
}

9. Theming

The widgets are built from Flutter's widgets library rather than Material, so they ignore your ThemeData and take their look from the provider's FeedbackKitTheme:

FeedbackKitProvider(
  apiKey: const String.fromEnvironment('FEEDBACKKIT_API_KEY'),
  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()),
)
PropertyDefaultUsed for
primaryColorBlue 0xFF3B82F6Vote buttons, the Submit button, the comment field
backgroundColorNear whiteBehind the detail screen and the form
cardBackgroundColorWhiteCards, fields and comments
textColor, secondaryTextColorNear black, greyTitles, then descriptions and dates
borderColorLight greyCard and field borders
errorColorRedError messages
statusColors, categoryColorsOne colour eachThe badges
borderRadius12Corners
spacing8The 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.

10. Things that will trip you up

Release builds cannot reach the network on Android. The Flutter template declares the internet permission in the debug and profile manifests, not the main one. 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 in both DebugProfile.entitlements and Release.entitlements.

A wrong key looks like an empty board if you supplied your own emptyBuilder and an errorBuilder that swallows the error. The default error state shows the server's message and a Retry button.

A submission succeeds but never appears for anyone else. On the Free plan a project shows 10 requests. Past that, the write still returns success, with no 402: the item is saved, the submitter still sees it, and everyone else's list leaves it out. If items vanish at 10, the cap is what happened. See pricing.

A vote cannot be taken back. That is the 1.0.1 unvote defect above, not your code.

11. Testing tips

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 FeedbackKitProvider.of(context).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

The board is worth more once you are working it: triage weekly, move things to In Progress so voters can see momentum, and mark items Completed when they ship. Your team gets a push notification in the admin app for each new request and comment. For the widget and model reference, see the Flutter SDK docs; other platforms are listed under documentation, and the Android guide covers a native Kotlin app. If you ship both frameworks, collecting feature requests in Flutter and React Native sets the two up side by side.

Every plan includes the Flutter SDK. Start free, copy your project key, and the board can be in your next test build.

Keep reading

Collect feedback in your own app

FeedbackKit ships SDKs for Swift, Kotlin, React Native, Flutter and JavaScript, and syncs what your users ask for into the tracker your team already uses.