outcode_bug_reporter 2.1.1 copy "outcode_bug_reporter: ^2.1.1" to clipboard
outcode_bug_reporter: ^2.1.1 copied to clipboard

In-app bug reporter for Flutter: a floating button that captures the screen, lets users annotate and redact it, set a severity, and file it to ClickUp or any backend.

outcode_bug_reporter #

pub version pub likes license: MIT Flutter

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.1

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.

Android release builds need the INTERNET permission. flutter create only declares it in android/app/src/debug/AndroidManifest.xml and .../profile/AndroidManifest.xml, because that is all the Flutter tool needs for hot reload. If your main manifest doesn't declare it, every submit works in debug and fails in release — and since a failed submit is queued for retry, the reporter tells the user it saved the report for later rather than showing a network error. Add it to android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.INTERNET"/>

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://your-bug-service.example.com/api/bugs/ingest', // your bug service URL
        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.

brand, manufacturer and model get special treatment: supply any of them and the report grows Brand name and Model rows. The brand is title-cased (samsungSamsung) and falls back to manufacturer when carriers have rewritten it; the model is kept verbatim, since Android's SKU (SM-S921B) is unambiguous where a marketing name would need a lookup table that goes stale every launch. React Native reads the same fields from Platform.constants automatically — Flutter asks you to inject them so the package stays plugin-free.

final info = await DeviceInfoPlugin().androidInfo;   // device_info_plus
final pkg = await PackageInfo.fromPlatform();        // package_info_plus

BugReporterConfig(
  appName: pkg.appName,
  appVersion: pkg.version,
  deviceInfo: {
    'brand': info.brand,          // rendered as "Brand name: Samsung"
    'manufacturer': info.manufacturer,
    'model': info.model,          // rendered as "Model: SM-S921B"
    '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});
  },
);
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.

Theming #

The three presets are a starting point — any colour works:

BugReporterConfig(appName: 'My App', theme: BugReporterTheme.noir);                        // preset
BugReporterConfig(appName: 'My App', theme: BugReporterTheme.indigo.withAccent(Color(0xFFFF5C00)));
BugReporterConfig(appName: 'My App', fabColor: Color(0xFFFF5C00));                         // just the button

Prefer withAccent() over copyWith(accent: ...) when you only have a brand colour: it also derives accentPress, ring and onAccent, so the pressed state, selection tints and the button glyph follow — a light brand colour gets a dark icon instead of white-on-white. copyWith leaves those on the preset's values, which is what you want when you're setting them yourself.

fabColor recolours the floating button and its halo only, leaving the rest of the flow on the theme accent.

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, preset.withAccent(color), or preset.copyWith(...)
fabColor Color? — the floating button and its halo only; null keeps the theme accent
backendName / label String — form footer text / button semantics label
defaultPriority ReportPriority?
screenshot ScreenshotOptionsmaxWidth (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. OutcodeBackendBugReporterRepository ships only a scoped bug:create key and a project targetKey — the ClickUp token stays server-side. If you use ClickUpBugReporterRepository(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

0
likes
160
points
147
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

In-app bug reporter for Flutter: a floating button that captures the screen, lets users annotate and redact it, set a severity, and file it to ClickUp or any backend.

Homepage
Repository (GitHub)
View/report issues

Topics

#bug-report #feedback #screenshot #annotation #clickup

License

MIT (license)

Dependencies

flutter, http, http_parser

More

Packages that depend on outcode_bug_reporter