feedback_kit 0.1.0
feedback_kit: ^0.1.0 copied to clipboard
In-app feedback boards, voting and a public roadmap for Flutter. Auth-agnostic: bring your own identity via a FeedbackIdentityProvider, or let the built-in anonymous device identity handle it. Pairs w [...]
feedback_kit #
In-app feedback boards, voting and a public roadmap for Flutter. Users submit feature requests, vote on each other's, and follow what you decided to build. Pairs with the platform Feedback service, and speaks nothing but its public API.
Auth-agnostic by construction. The package never holds a board's signing key
and never signs an identity itself. For an authenticated board your backend
signs a short-lived token and this package carries it; for an anonymous board
AnonymousDeviceIdentity needs no backend at all.
Install #
dependencies:
feedback_kit: ^0.1.0
Anonymous board — no login required #
The lowest-friction setup. A random device token is generated once and kept in shared preferences; the service stores only its HMAC, so a database dump cannot be replayed as somebody's device.
final config = FeedbackConfig(
baseUrl: Uri.parse('https://feedback.example.com'),
boardPublicKey: 'cms6ie1qh0001ngy27ynms88q', // from the board's settings
identityProvider: AnonymousDeviceIdentity(),
);
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => FeedbackBoardScreen(config: config),
));
The board's public key is safe to ship in an app binary. Its signing key is not — that one belongs in a backend, and only for the mode below.
Authenticated board — your users, your identity #
Your backend holds the board's signing key and mints a token for the signed-in user. The kit asks your code for that token and sends it as a header; it never sees the key.
identityProvider: CallbackIdentityProvider(
getToken: () async {
final res = await myApi.get('/feedback-token'); // your endpoint
return res.data['token'] as String?;
},
),
Returning null is a legitimate answer — a signed-out user then gets a readable
board with a sign-in hint instead of an error.
What your backend has to do #
Sign a compact HMAC token. It is deliberately not a JWT: no algorithm field means
no alg: none to get wrong.
payload = base64url(json({ sub, name?, email?, exp })) // exp: unix seconds, ≤ 24h
token = payload + "." + base64url(HMAC-SHA256(signingKey, payload))
sub is your own user id — opaque to the service, and what one-vote-per-person is
counted against. Node example:
const payload = Buffer.from(JSON.stringify({
sub: user.id, name: user.displayName, exp: Math.floor(Date.now() / 1000) + 3600,
})).toString('base64url')
const mac = crypto.createHmac('sha256', FEEDBACK_SIGNING_KEY)
.update(payload).digest('base64url')
res.json({ token: `${payload}.${mac}` })
Rotating the key in the platform invalidates every issued token immediately, so deploy the new key before rotating.
Screens #
| Widget | What it is |
|---|---|
FeedbackBoardScreen |
The entry point: list, sort, vote, submit, link to the roadmap |
FeedbackPostScreen |
One request with its comment thread and a reply box |
FeedbackSubmitScreen |
The submission form, with an opt-in for status mail |
FeedbackRoadmapScreen |
The public roadmap, one tab per column |
FeedbackBoardScreen reads the board's configuration first, so which tabs appear
and whether anyone may act follows from the board's settings rather than from what
the app was compiled with — switching a board to read-only needs no app release.
Hosting it inside your own tab bar:
FeedbackBoardScreen(config: config, showAppBar: false)
Theming #
Defaults derive from the ambient ThemeData. The board's own brandColor, when
set, wins over accent — a product embedding somebody else's board should look
like the board.
FeedbackBoardScreen(
config: config,
theme: const FeedbackTheme(accent: Colors.teal, showStatusChips: false),
)
Errors worth handling #
FeedbackException.failure is an enum rather than a string, so the UI can branch:
identityRequired— prompt a sign-in; the board stays readable.tokenExpired— handled internally by exactly one refresh-and-retry.tokenInvalid— a developer error: wrong board or wrong key.rejected— rate limited or over quota.notFound,network,unknown.
Using the client without the screens #
Every screen is a thin layer over FeedbackClient, which is usable on its own:
final client = FeedbackClient(config);
final page = await client.posts(sort: 'top');
await client.vote(page.items.first.id, on: true);
client.dispose();
A vote may come back naming a different post than the one you voted on — the
service redirects a vote on a merged duplicate to the post that now carries the
discussion. Trust VoteResult.postId.