outcode_bug_reporter 2.0.0
outcode_bug_reporter: ^2.0.0 copied to clipboard
In-app bug reporter for Flutter: a draggable floating button that captures the screen, lets users annotate and redact it, set severity and type, and file a detailed report to ClickUp or any custom backend.
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
- 📡 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.0.0
No platform setup, no native peers: everything is pure Dart plus http.
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,
repository: ClickUpBugReporterRepository(
apiKey: const String.fromEnvironment('CLICKUP_API_KEY'),
problemListId: problemListId,
suggestionListId: suggestionListId,
),
),
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 a shake gesture or debug menu instead of the button, push it yourself from a context that has a navigator:
Navigator.of(context).push(ReportFlowRoute(config: config, screenshot: await captureBoundary(key)));
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 |
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.
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.
⚠️ Never hardcode a ClickUp API key. Inject it with
--dart-define. The key ships inside your app binary, so for public releases prefer theapiUrlpath behind a server proxy and reserve direct-to-ClickUp for internal builds.
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