outcode_bug_reporter
In-app bug reporter for Flutter. A draggable floating button captures the current screen, lets users annotate it (draw, arrow, box, redact, text), set a severity and type, and file a report to ClickUp or any custom backend.
Part of the OutCode Bug Reporter monorepo. For the web (React, Angular, Vue, Svelte, Solid, vanilla), see
@outcode/bug-reporter-web; for React Native,@outcode/bug-reporter-native. All of them file the same report format, so tickets look identical whichever client they came from.
Features
- 🐛 Draggable floating button that snaps to the screen edge
- ✏️ Annotate — pen, arrow, box, opaque redaction, and text on the captured screenshot
- 📸 No plugins — capture is Flutter's own
RepaintBoundary, so there is nothing to configure - 🪟 Works over dialogs and bottom sheets, and the system back gesture steps back through the flow
- 🧭 Auto-captured context — route, platform, viewport, screen, text scale, orientation, locale, timezone, console/network summaries… consent-free
- 🎨 Themeable — Indigo / Noir / Mint presets or your own tokens
- 📳 Shake to report — opt-in; the detector is pure Dart and you supply the accelerometer
- 📡 Offline retry queue, breadcrumb capture, screenshot size guard
- 🧩 Pluggable backends — ClickUp out of the box, or any
repository/ HTTP endpoint
Install
dependencies:
outcode_bug_reporter: ^2.1.0
No platform setup, no native peers: everything is pure Dart plus http. Shake-to-report is opt-in
and needs an accelerometer stream from your app (sensors_plus is one expression) — the package
itself still takes no plugin dependencies.
Platform views don't capture. A native map, webview, or camera preview composites outside the Flutter layer tree and comes back blank or black in the screenshot. Everything Flutter draws itself captures faithfully.
Quick start
Wrap your app in MaterialApp.builder so the reporter sits above the Navigator:
import 'package:flutter/material.dart';
import 'package:outcode_bug_reporter/outcode_bug_reporter.dart';
MaterialApp(
home: const HomePage(),
// Fills the "Route" row in the captured context (optional).
navigatorObservers: [BugReporterNavigatorObserver()],
builder: (context, child) => BugReporter(
config: BugReporterConfig(
appName: 'My Flutter App',
appVersion: '1.0.0',
theme: BugReporterTheme.indigo,
// Recommended: reports go through the OutCode bug service, so no ClickUp token ships in the app.
repository: OutcodeBackendBugReporterRepository(
endpoint: 'https://bot.skywayinnovations.com.np/admin/api/bugs/ingest',
apiKey: const String.fromEnvironment('BUG_REPORTER_API_KEY'), // scoped `bug:create` key
targetKey: const String.fromEnvironment('BUG_REPORTER_TARGET_KEY'), // from the bot's "Bug Trends" tab
),
),
child: child!,
),
);
builder is the right mount point for two reasons: the button floats over every route without being
rebuilt on navigation, and only child ends up inside the capture boundary — so the button never
appears in its own screenshots. See
example-flutter/ for a
runnable demo.
Reporting a bug while a dialog or bottom sheet is open
This just works, and it's the one place Flutter has it easier than React Native. Dialogs and sheets are
routes inside the Navigator, and the button is mounted above it, so it stays tappable; the report
flow is then pushed as a route of its own, landing on top of whatever the user was stuck on. The
screenshot contains the dialog, because capture snapshots the whole widget tree rather than one view.
The system back gesture steps back through the flow — annotate → close, form → annotate — and never
pops the app's screen out from under it.
One consequence of mounting above the Navigator: the reporter cannot use Navigator.of(context) to
present the flow, because that searches ancestors and the navigator is a descendant. It locates the
navigator by descending from its own element, which needs nothing from you. If you already keep a
navigator key, passing it skips the search:
BugReporter(config: config, navigatorKey: myNavigatorKey, child: child!)
Opening it yourself, and turning it off
final reporter = BugReporterController();
BugReporter(config: config, controller: reporter, child: child!)
// …anywhere: reporter.open() / reporter.close() / reporter.isOpen
// Ship release builds without it:
BugReporter(enabled: kDebugMode, config: config, child: child!)
enabled: false builds child and nothing else — no button, no capture boundary, no queue flush, and
no running animation (the pulsing halo is a repeating AnimationController, so leaving it going would
keep the app rendering frames for nothing).
To present the flow from your own debug menu, push it yourself from a context that has a navigator:
Navigator.of(context).push(ReportFlowRoute(config: config, screenshot: await captureBoundary(key)));
Shake to report
Shaking the device opens the reporter. It's off until you hand it an accelerometer — the package
takes no plugin dependencies, so the sensor is yours to provide. With sensors_plus, that's one
expression:
import 'package:sensors_plus/sensors_plus.dart';
BugReporterConfig(
appName: 'My App',
shake: ShakeOptions(
accelerometer: (interval) => accelerometerEventStream(samplingPeriod: interval)
.map((e) => AccelerometerSample.fromMetersPerSecondSquared(e.x, e.y, e.z)),
),
);
Use accelerometerEventStream, not userAccelerometerEventStream: the detector measures how far
a reading is from 1g, so it needs gravity included. Samples are in g, and
AccelerometerSample.fromMetersPerSecondSquared does the conversion for you.
Shake-only, with no floating button:
BugReporterConfig(appName: 'My App', showButton: false, shake: ShakeOptions(accelerometer: ...));
Shake is unavailable to anyone who can't shake the device, so it should never be the only way in —
keep a BugReporterController wired to a settings row.
ShakeOptions |
Default | |
|---|---|---|
accelerometer |
null |
Your sample stream. Null means no shake. |
threshold |
1.2 |
How far from 1g a reading must be to count as a jolt. |
minDuration |
1000 ms |
How long the shaking has to last. |
requiredJolts |
4 |
Jolts needed within that window. |
maxGap |
400 ms |
A longer gap ends the run and starts a new one. |
cooldown |
3000 ms |
Quiet period after a trigger. |
joltGap |
100 ms |
Ignores repeat jolts, so one peak isn't counted twice. |
interval |
60 ms |
Sampling period passed to your accelerometer. |
Requiring a duration and not just a count is what separates a shake from a knock; the gap rule
stops one being assembled out of unrelated bumps. The reporter stops listening while a report is open
and while the app is backgrounded, and mutes for cooldown when the flow closes — so the shake that
dismissed it can't reopen it.
ShakeDetector is exported on its own: pure Dart, no timers, no subscriptions, so you can drive it
from any sample source you already have. It shares its defaults with
@outcode/bug-reporter-native, so the gesture feels the same on both platforms.
Device info and network type (optional)
The package takes no plugin dependencies, so it doesn't guess at your device model or connectivity.
Inject whatever you already collect — statically via deviceInfo / packageInfo, or per-report via
collectContext:
final info = await DeviceInfoPlugin().androidInfo; // device_info_plus
final pkg = await PackageInfo.fromPlatform(); // package_info_plus
BugReporterConfig(
appName: pkg.appName,
appVersion: pkg.version,
deviceInfo: {'model': info.model, 'osVersion': info.version.release},
packageInfo: {'packageName': pkg.packageName, 'buildNumber': pkg.buildNumber},
collectContext: () async {
final network = await Connectivity().checkConnectivity(); // connectivity_plus
return ReportContextRow.fromMap({'Network': network.first.name});
},
);
Breadcrumbs and the last failed request
late final BugReporterLogCapture capture;
void main() {
capture = BugReporterLogCapture.install(); // Flutter errors + debugPrint
runApp(const MyApp());
}
BugReporterConfig(appName: 'My App', collectDiagnostics: capture.collect);
install() chains to the error handlers already in place, so it composes with Crashlytics or Sentry
rather than replacing them. Route requests through BugReporterHttpClient(capture) to also record
failed calls. Reports then carry a Recent Logs block and, when relevant, a Last Failed API Call.
Offline retry queue
Pass any key/value store — shared_preferences, secure storage, a file — and failed submits are
persisted, then replayed the next time the reporter mounts. A queued report shows "Saved for later"
instead of a ticket id.
class PrefsStorage implements ReportQueueStorage {
@override
Future<String?> getItem(String key) async =>
(await SharedPreferences.getInstance()).getString(key);
@override
Future<void> setItem(String key, String value) async =>
(await SharedPreferences.getInstance()).setString(key, value);
@override
Future<void> removeItem(String key) async =>
(await SharedPreferences.getInstance()).remove(key);
}
BugReporterConfig(appName: 'My App', storage: PrefsStorage());
At most 10 reports are kept; if a write fails because the screenshots are too large for your store, the
queue retries without them rather than losing the text. InMemoryReportQueueStorage is bundled for
tests and demos.
Configuration
BugReporterConfig mirrors the shared config documented in the
root README, with Dart types:
| Option | Type |
|---|---|
appName (required) / appVersion |
String / String? |
repository |
BugReporterRepository? — when set, used instead of apiUrl |
apiUrl / headers |
String? / Map<String, String>? |
theme |
BugReporterTheme — a preset, or preset.copyWith(...) |
backendName / label |
String — form footer text / button semantics label |
defaultPriority |
ReportPriority? |
screenshot |
ScreenshotOptions — maxWidth (default 1280) or an exact pixelRatio |
storage |
ReportQueueStorage? — enables the offline queue |
showButton |
bool — default true; false for shake-only or controller-only |
shake |
ShakeOptions? — an accelerometer provider plus tuning; null means off |
collectDiagnostics |
Map<String, Object?>? Function()? |
collectContext |
FutureOr<List<ReportContextRow>> Function()? |
deviceInfo / packageInfo |
Map<String, Object?>? |
BugReporter itself also takes controller, enabled, and navigatorKey.
Severity → backend priority: critical→urgent, high→high, medium→normal, low→low. Type → a backend tag.
Two Flutter-specific notes on screenshot: maxWidth is applied as a capture pixel ratio rather than
a post-hoc resize, and output is always PNG (dart:ui encodes nothing else), so there is no quality
knob — lower maxWidth to shrink a report. Annotations are flattened at the resolution of the original
capture, not the on-screen preview.
The Redact tool paints an opaque block, not a blur. A blur can sometimes be inverted; a password or a customer's name in a screenshot deserves better than that.
Redaction also fails closed. Annotations are flattened into the screenshot before it is submitted, and if that flatten fails the original capture — which still shows what was redacted — is not sent as a fallback. The editor stays open with an error instead, and Continue retries. Reports with no redaction still fall back to the unflattened capture, since nothing was hidden to lose.
Custom backends
class MyRepository implements BugReporterRepository {
@override
Future<BugReportResponse> createReport(CreateReportParams params) async {
// params.title, .description, .screenshotBase64, .severity, .type,
// .context, .deviceInfo, .packageInfo, .diagnostics
return const BugReportResponse(success: true, id: 'ISSUE-123');
}
}
Return success: false rather than throwing for expected failures — that's what lets the reporter
queue the report for retry. With no repository at all, reports are POSTed to apiUrl as JSON with
snake_case keys (screenshot_base64, metadata.app_name), identical to the web and React Native
packages, so one endpoint serves all three.
⚠️ Prefer the OutCode backend over a direct ClickUp token.
OutcodeBackendBugReporterRepositoryships only a scopedbug:createkey and a projecttargetKey— the ClickUp token stays server-side. If you useClickUpBugReporterRepository(apiKey: ..., listId: ...)instead, never hardcode the key; inject it with--dart-define. That key ships inside your app binary, so reserve direct-to-ClickUp for internal builds only.
Platform support
Pure Dart with no conditional imports (dart:async, dart:convert, dart:math, dart:typed_data,
dart:ui only), so it compiles everywhere Flutter does, web and Wasm included. The flow has been
driven end-to-end on a physical Android device; other platforms are verified to compile and share the
same engine capture path, but haven't been hand-tested — please open an issue if you hit something.
License
MIT © OutCode Software
Libraries
- outcode_bug_reporter
- In-app bug reporter for Flutter.