fewchurs 1.5.0
fewchurs: ^1.5.0 copied to clipboard
A plug-and-play feature-request board for Flutter. List, vote, comment, follow and submit ideas, themed to match your app on every platform.
Fewchurs for Flutter #
Add a feature-request board to your Flutter app with one dependency and one line of code. Users can browse ideas, vote, comment, follow requests and suggest new ones. The board picks up your app's theme automatically. You can customize everything from a single color up to a fully custom UI.
Runs on Android, iOS, web, macOS, Windows and Linux. Requires Flutter 3.10+ and Dart 3.0+.
Contents #
- Installation
- Platform setup
- Quick start
- Showing the board
- Submitting from your own UI
- Configuration reference
- Theming
- Text and translations
- Replacing parts of the UI (builders)
- Headless: build your own UI
- User identity
- Events and analytics
- Error handling
- Privacy and security
- Troubleshooting
- Repository layout and development
1. Installation #
Get an API key #
Open the Fewchurs dashboard, select your app and copy the API key (fr_live_…) from its settings. The key is shown only once. If you lose it, generate a new one.
Add the package #
flutter pub add fewchurs
That's it: fewchurs_core is installed with it.
To try an unreleased version, install from GitHub instead. Add this to your app's pubspec.yaml, using any tag or branch as ref:
dependencies:
fewchurs:
git:
url: https://github.com/fewchurs/fewchurs-flutter.git
path: packages/fewchurs
ref: main
dependency_overrides:
fewchurs_core:
git:
url: https://github.com/fewchurs/fewchurs-flutter.git
path: packages/fewchurs_core
ref: main
The dependency_overrides entry makes the GitHub fewchurs_core match the GitHub fewchurs. Use the same ref in both.
Import #
import 'package:fewchurs/fewchurs.dart';
This single import gives you everything: widgets, theming, controllers, the API client, models and errors.
2. Platform setup #
| Platform | What to do |
|---|---|
| Android | Add <uses-permission android:name="android.permission.INTERNET"/> to android/app/src/main/AndroidManifest.xml. Debug builds include it automatically, but release builds need it, and forgetting it is the most common "works in debug, fails in release" issue. |
| iOS | Nothing. |
| macOS | Add <key>com.apple.security.network.client</key><true/> to both macos/Runner/DebugProfile.entitlements and macos/Runner/Release.entitlements. |
| Web | Nothing in your app. The Fewchurs server must allow CORS for /api/v1. The device ID is stored per browser, so clearing site data resets it. |
| Windows / Linux | Nothing. |
3. Quick start #
import 'package:fewchurs/fewchurs.dart';
import 'package:flutter/material.dart';
void main() {
Fewchurs.configure(apiKey: 'fr_live_xxx');
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: FilledButton(
onPressed: () => Fewchurs.showBoard(context),
child: const Text('Feature requests'),
),
),
),
),
);
}
}
configure is synchronous and makes no network calls, so it doesn't slow down app startup.
Tip: keep the key out of source control with
flutter run --dart-define=FEWCHURS_API_KEY=fr_live_xxxandconst String.fromEnvironment('FEWCHURS_API_KEY').
4. Showing the board #
You can present the board in three ways. All three share the same features: Top and New tabs, instant voting, comments, follow, submit, pull to refresh and infinite scroll.
As a full-screen page #
Fewchurs.showBoard(context);
As a sheet #
Fewchurs.showBoardSheet(context);
The sheet adapts to the screen. On phones it's a bottom sheet. On screens 840 px and wider (web, desktop, tablets in landscape) it slides in from the side at full height, and requests and the submit form open inside it.
Fewchurs.showBoardSheet(context, heightFactor: 0.8); // bottom sheet height on phones
Fewchurs.showBoardSheet(context, sideSheetWidth: 480); // side sheet width on wide screens
Fewchurs.showBoardSheet(context, position: FewchursSheetPosition.side); // always a side sheet
Fewchurs.showBoardSheet(context, position: FewchursSheetPosition.bottom); // always a bottom sheet
The breakpoint is BoardOptions.wideLayoutBreakpoint.
As a widget anywhere: a tab, a drawer page or a route #
const FewchursBoard();
// Inside a bottom navigation tab (no close button):
const FewchursBoard(showCloseButton: false);
// Inside a page that already has its own app bar:
const FewchursBoard(showAppBar: false);
With go_router (no extra dependency needed):
GoRoute(path: '/ideas', builder: (context, state) => const FewchursBoard());
Open a specific request #
For deep links and notifications, pass the request's ID. The board opens and then shows that request:
Fewchurs.showBoard(context, board: FewchursBoard(showCloseButton: true, initialFeatureId: id));
If the request was deleted, or is hidden by the board's plan, the user sees "This request no longer exists." A request hidden by the plan is still returned to the device that submitted it.
Only the submit form #
final feature = await showFewchursSubmitSheet(context); // null if dismissed
It's a bottom sheet on phones and a centered dialog on screens 600 px and wider.
FewchursBoard parameters #
| Parameter | Type | Default | Description |
|---|---|---|---|
options |
BoardOptions? |
from configure |
Per-widget override of the board options. |
initialFeatureId |
String? |
— | Opens this request once the board is shown. For deep links and notifications. |
title |
String? |
strings.boardTitle | App bar title. |
showAppBar |
bool |
true |
Set to false when embedding under your own app bar. |
showCloseButton |
bool? |
null |
true shows an ✕ button and closes on Esc. false hides the leading button. null uses Flutter's default. |
headerBuilder |
WidgetBuilder? |
— | Widget shown above the list. |
emptyBuilder |
WidgetBuilder? |
— | Replaces the empty state. |
featureCardBuilder |
FeatureCardBuilder? |
— | Replaces each card. |
voteButtonBuilder |
VoteButtonBuilder? |
— | Replaces the vote button. |
statusChipBuilder |
StatusChipBuilder? |
— | Replaces the status chip. |
On wide screens (840 px and up by default: tablets, web and desktop), the board shows the list and the selected request side by side, with the tabs above the list.
5. Submitting from your own UI #
try {
final feature = await Fewchurs.submitFeature(
title: 'Add dark mode', // 3–100 characters
description: 'Would love a dark theme.', // optional, up to 1000
email: 'sam@example.com', // optional: follows the request
isSubscriber: user.isPro, // optional; defaults to configure(isSubscriber:)
);
print('Submitted ${feature.id}');
} on FewchursException catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.userMessage)));
}
Input is checked before any request is sent, using the same rules as the server. Any open board shows the new request at the top of the New tab.
6. Configuration reference #
Fewchurs.configure(
apiKey: 'fr_live_xxx', // required
style: FewchursStyle.adaptive, // material (default) | cupertino | adaptive
board: const BoardOptions(
tabs: [BoardTab.top, BoardTab.newest],
defaultTab: BoardTab.top,
showDeclined: false,
allowSubmissions: true,
allowComments: true,
allowFollow: true,
emailField: EmailField.optional, // hidden | optional | required
submitButton: SubmitButtonPlacement.fab, // fab | bottomBar | appBarAction
pageSize: 20,
maxContentWidth: 720,
wideLayoutBreakpoint: 840,
),
strings: const FewchursStrings(boardTitle: 'Ideas'),
isSubscriber: () => billing.hasActiveSubscription, // sync or async
userEmail: () => auth.currentUser?.email, // skips the email prompt when following
deviceId: () async => hashedUserId, // see "User identity"
deviceIdStore: MySecureStore(), // custom storage for the generated ID
httpClient: myHttpClient, // package:http Client (logging, proxy, pinning)
baseUrl: 'https://staging.fewchurs.shop/api/v1', // override for staging
logLevel: FewchursLogLevel.error, // none | error | debug (never logs the key)
);
| Option | Notes |
|---|---|
style |
adaptive uses Cupertino dialogs, spinners and page transitions on iOS and macOS, and Material everywhere else. |
tabs |
BoardTab.top and BoardTab.newest. BoardTab.roadmap is deprecated and ignored. |
showDeclined |
Declined requests are hidden unless this is true. |
userEmail |
If set, Follow uses this email right away. Otherwise the user is asked once, and can choose to have it remembered on the device. |
You can call configure again at any time, for example after sign-in or when switching styles.
7. Theming #
Level 1: automatic #
The board reads Theme.of(context), including the color scheme, text theme and light/dark mode. In a Material 3 app it already looks like the rest of the app.
Level 2: app-wide tokens with a ThemeExtension #
MaterialApp(
theme: ThemeData(
colorSchemeSeed: const Color(0xFFED5F18),
extensions: const [
FewchursThemeData(
cardRadius: 14,
voteButtonStyle: VoteButtonStyle.pill, // column (default) | pill | minimal
density: FewchursDensity.compact, // comfortable (default) | compact
statusColors: {FeatureStatus.done: Color(0xFF16A34A)},
),
],
),
);
Put a different FewchursThemeData in darkTheme to style dark mode separately. Theme changes animate.
Level 3: scoped to one board #
FewchursTheme(
data: const FewchursThemeData(primary: Colors.teal, fontFamily: 'Inter'),
child: const FewchursBoard(),
);
The scoped theme also applies to the detail page, submit sheet and dialogs opened from that board.
All tokens #
| Group | Tokens |
|---|---|
| Colors | primary, onPrimary, background, surface, onSurface, muted, border, error, statusColors |
| Shapes | cardRadius, buttonRadius, chipRadius, sheetRadius, cardElevation |
| Text | titleStyle, bodyStyle, labelStyle, fontFamily |
| Layout | density, voteButtonStyle |
Any token you leave null comes from your ThemeData. To read the final values in your own widgets, call FewchursThemeData.of(context).
8. Text and translations #
Override any text:
Fewchurs.configure(
apiKey: '…',
strings: const FewchursStrings(
boardTitle: 'Ideas',
submitButton: 'Suggest an idea',
emptyTitle: 'No ideas yet',
votesCount: '{count} votes',
),
);
Every label, button, status name, empty state and error message is a field on FewchursStrings. Fields you don't set keep their English default.
To load strings through Flutter's localization system, register the delegate:
MaterialApp(
localizationsDelegates: const [FewchursLocalizations.delegate /*, ...your delegates */],
);
Text passed to configure(strings:) wins over the delegate.
9. Replacing parts of the UI (builders) #
Builders receive state and actions, so paging, instant voting and error handling keep working.
FewchursBoard(
headerBuilder: (context) => const Text('Vote for what we build next.'),
emptyBuilder: (context) => const MyEmptyIllustration(),
featureCardBuilder: (context, feature, actions) => ListTile(
title: Text(feature.title),
subtitle: Text(feature.description, maxLines: 1),
onTap: actions.open,
trailing: IconButton(
icon: Icon(feature.hasVoted ? Icons.thumb_up : Icons.thumb_up_outlined),
onPressed: actions.toggleVote,
),
),
voteButtonBuilder: (context, state) => MyVoteChip(
voted: state.voted, count: state.count, onTap: state.onPressed,
),
statusChipBuilder: (context, status) => Text(status.name),
);
The built-in pieces are public too, so you can reuse them: FewchursFeatureCard, FewchursVoteButton, FewchursStatusChip, FewchursPoweredBy, FewchursFeatureDetail, FewchursSubmitForm.
10. Headless: build your own UI #
Controllers #
Controllers are plain ChangeNotifiers. They work with setState, Provider, Riverpod, Bloc or anything else.
class MyIdeasPage extends StatefulWidget { … }
class _MyIdeasPageState extends State<MyIdeasPage> {
late final board = Fewchurs.boardController(sort: FeatureSort.top);
@override
void dispose() {
board.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: board,
builder: (context, _) => switch (board.state) {
BoardLoading() => const Center(child: CircularProgressIndicator()),
BoardError(:final error) => Text(error.userMessage),
BoardContent(:final features, :final canLoadMore) => ListView(
children: [
for (final f in features)
ListTile(
title: Text(f.title),
trailing: Text('${f.upvoteCount}'),
onTap: () => board.toggleVote(f.id),
),
if (canLoadMore)
TextButton(onPressed: board.loadMore, child: const Text('Load more')),
],
),
},
);
}
}
BoardController |
|
|---|---|
state |
BoardLoading, BoardError(error) or BoardContent(features, canLoadMore, isLoadingMore, isRefreshing, loadMoreError) |
refresh() / loadMore() |
Reload the list, or load the next page. |
toggleVote(id) |
Updates the list right away and syncs with the server. Rapid taps are merged, and a failed vote is undone. |
insertAtTop(f) / replace(f) / remove(id) |
Change the list yourself. |
actionErrors |
Stream of errors from votes and refreshes, e.g. to show a snack bar. |
Filtered list, for example a "coming soon" widget:
Fewchurs.boardController(statuses: {FeatureStatus.planned, FeatureStatus.inProgress});
Fewchurs.detailController(feature) returns a FeatureDetailController with comments, loadComments(), loadMoreComments(), toggleVote(), setFollowing(bool, email:) and addComment(text, authorName:).
Raw API client #
final page = await Fewchurs.client.listFeatures(sort: FeatureSort.newest, limit: 20);
final feature = await Fewchurs.client.getFeature(featureId);
await Fewchurs.client.vote(featureId);
await Fewchurs.client.unvote(featureId);
await Fewchurs.client.follow(featureId, email: 'a@b.com');
await Fewchurs.client.unfollow(featureId);
final comments = await Fewchurs.client.listComments(featureId);
await Fewchurs.client.addComment(featureId, text: '+1', authorName: 'Sam');
final config = await Fewchurs.client.getConfig();
Models #
enum FeatureStatus { open, planned, inProgress, done, declined, unknown }
class Feature {
String id, title, description;
FeatureStatus status;
int upvoteCount, commentCount, followerCount;
bool hasVoted, isFollowing, isMine;
DateTime createdAt, updatedAt; // UTC
}
class Comment {
String id, text, authorName;
bool isDeveloper, isMine;
DateTime createdAt;
}
A status the SDK doesn't recognize becomes FeatureStatus.unknown and never throws.
11. User identity #
Votes, follows, "mine" and rate limits are tied to a device ID sent with every request.
-
Default: the package generates a random UUID the first time and saves it with
shared_preferences. It stays until the app is uninstalled, or until site data is cleared on web. It never uses hardware or advertising IDs. -
Signed-in users: give a stable ID so a user's votes follow them across devices and platforms. Use a hash of your user ID, never an email address:
Fewchurs.configure( apiKey: '…', deviceId: () async => auth.currentUser == null ? null : sha256Hex(auth.currentUser!.uid), );If the function returns
null, the generated ID is used. -
Custom storage: implement
DeviceIdStore, for example on top offlutter_secure_storage:class SecureStore implements DeviceIdStore { @override Future<String?> read() => storage.read(key: 'fewchurs_id'); @override Future<void> write(String id) => storage.write(key: 'fewchurs_id', value: id); }
12. Events and analytics #
The package sends no analytics. It emits events you can forward to your own tools:
Fewchurs.events.listen((event) {
switch (event) {
case BoardOpened():
analytics.log('fewchurs_open');
case FeatureSubmitted(:final featureId):
analytics.log('fewchurs_submit', {'id': featureId});
case Voted() || Commented() || Followed():
analytics.log('fewchurs_${event.name}');
case FewchursErrorEvent(:final error):
crashlytics.recordError(error, null);
}
});
13. Error handling #
Every failure is a subclass of sealed class FewchursException, so a switch can cover all cases. Each one has userMessage (safe to show to users), message (detail for you), code and statusCode.
| Exception | When | userMessage |
|---|---|---|
FewchursInvalidApiKeyException |
Wrong key, or its app was deleted (401, invalid_key or unauthorized) |
"Invalid API key. Check the key in your Fewchurs dashboard." Change it with FewchursStrings(errorInvalidApiKey:). A warning with details is also logged for you. |
FewchursValidationException |
Invalid input (400) | The validation message |
FewchursRateLimitedException |
15 submissions or 10 comments per device per day (429) | "You've reached today's limit. Try again tomorrow." |
FewchursLimitReachedException |
Rare (402). Plans never block submitting, voting or commenting. | "This board isn't accepting requests right now." |
FewchursNotFoundException |
Request was deleted (404) | "This request no longer exists." |
FewchursServerException |
Server error (5xx) | "Something went wrong. Try again." |
FewchursNetworkException |
Offline or timed out | "You're offline." |
FewchursUnknownException |
Forbidden (403) or an unexpected response | "Something went wrong. Try again." |
Reads and votes are retried twice on network or server errors. New content (submissions and comments) is never retried automatically, so it can't be posted twice. Requests time out after 15 seconds.
The built-in UI never crashes your app. If configure hasn't been called, the board shows a notice in debug builds and an empty view in release builds.
14. Privacy and security #
- The API key can be published by design. It ships inside every app binary and web page. Protection happens on the server with per-device limits.
- Requests use HTTPS only. The key is never logged.
- The key's owner controls the "Powered by Fewchurs" footer. It disappears automatically on the Starter and Pro plans.
Answers for the Google Play Data safety form and Apple privacy labels:
| Data | Collected | Purpose |
|---|---|---|
| Email address | Optional, typed by the user | Notify them when a request ships |
| User content | Yes: titles, descriptions, comments | App functionality |
| App-scoped identifier | Yes: a random UUID | Votes, follows, rate limits |
| Analytics, ads, location, tracking | No | — |
15. Troubleshooting #
| Problem | Fix |
|---|---|
| Works in debug, fails in a release APK | Add the Android INTERNET permission. |
| Nothing loads on macOS | Add the network.client entitlement to both entitlements files. |
| Nothing loads on web, CORS error in the console | CORS must be enabled on the Fewchurs server for /api/v1. |
| "Invalid API key" on the board | Copy the key again from the dashboard and pass it to Fewchurs.configure(apiKey:). If the app was deleted, create a new one. |
| "Something went wrong" everywhere | Set logLevel: FewchursLogLevel.debug to see requests. |
| Board shows "configure must be called" | Call Fewchurs.configure(...) before runApp. |
| Votes reset after reinstalling | Expected with the generated ID. Pass deviceId: to keep votes tied to signed-in users. |
No MaterialLocalizations found in a CupertinoApp |
The board handles this itself. If you use Fewchurs widgets outside the board, wrap them in a MaterialApp or add DefaultMaterialLocalizations.delegate. |
16. Repository layout and development #
fewchurs-flutter/
├─ packages/
│ ├─ fewchurs/ Flutter package: widgets, theming, controllers, Fewchurs facade
│ │ └─ example/ Small demos of each feature
│ └─ fewchurs_core/ Pure Dart: API client, models, errors, identity
└─ sample_app/ "Taskly", a to-do app that adds Fewchurs in each supported way
Run the tests:
cd packages/fewchurs_core && dart pub get && dart test
cd packages/fewchurs && flutter pub get && flutter test
Run the sample app:
cd sample_app
flutter create . --project-name taskly --platforms=android,ios,web,macos
flutter run --dart-define=FEWCHURS_API_KEY=fr_live_xxx
Local development uses pubspec_overrides.yaml files, so fewchurs depends on the local fewchurs_core. To publish, release fewchurs_core first and then fewchurs: run dart pub publish in packages/fewchurs_core, then flutter pub publish in packages/fewchurs.
CI (.github/workflows/ci.yml) runs analysis and tests on the minimum supported Flutter (3.10.6) and on the latest stable. It also checks that FewchursClient.defaultBaseUrl resolves and answers, so a dead domain can't ship.
License #
MIT. See LICENSE.