visual_feedback β€” in-app bug reports with annotated screenshots for Flutter

pub package pub points CI License: MIT

visual_feedback is a Flutter package for in-app user feedback and bug reporting. Users draw on a screenshot of your live app β€” pencil, brush, lines, rectangles, circles, arrows and text β€” and you receive the annotated PNG together with the recent logs. Use it for bug reports, beta testing, QA and design review on Android, iOS, web, macOS, Windows and Linux.

Looking for an actively maintained alternative to the feedback package? See how they compare and how to migrate.

Flutter desktop app with visual_feedback: annotated screenshot and the toolbar along the bottom edge Annotating a screenshot of a Flutter app on a phone with visual_feedback

Live demo: https://jemisgoti.github.io/visual_feedback/ Β· more screenshots

Contents

Features

  • πŸ–ŠοΈ Eight annotation tools: pencil, brush, line, rectangle, circle, arrow, inline text and eraser, with colour, stroke-width and text-size palettes.
  • βœ‹ Everything stays editable. Tap any mark to select it, then move, resize or delete it. Lines and arrows pivot around either endpoint.
  • ↩️ One undo history across every tool. A whole move or resize is one step.
  • πŸͺ΅ Recent logs included. Give it a package:logging logger and every report carries the logs from the last few minutes β€” plus framework errors and debugPrint output if you want them.
  • πŸ“ Optional description step. Let users explain the problem in words before sending, right over their annotated screenshot.
  • πŸ“Έ Clean captures. The controls and selection handles are never part of the screenshot.
  • πŸ–₯️ Phones, tablets and desktop. A column beside the floating button in portrait, a bar along the bottom edge in landscape and on desktop.
  • 🧲 Draggable controls plus an optional draggable floating button. Minimise the panel to draw underneath it without losing your work.
  • 🎨 Themeable glass toolbars that follow light and dark mode. Colours, icons, sizes, palettes, labels and timing all live in VisualFeedbackTheme.
  • β™Ώ Accessible. Every control has a screen-reader label you can translate.
  • 🌍 RTL-safe. The controls stay left-to-right; your app keeps its own text direction.
  • πŸ“¦ Lightweight. Depends only on Flutter and logging.

visual_feedback vs feedback

feedback is the well-known Flutter Favorite for this job, and visual_feedback started as its replacement in a production app. Pick whichever fits; here are the differences.

Maintenance. As of 13 September 2026, feedback's latest release is 3.2.0 from 6 July 2025, with 61 open issues and 25 open pull requests and 2 issues closed since that release. Recent reports such as missing screen-reader support (#390) and custom icons (#382) have no replies yet. visual_feedback is under active development and covers both.

visual_feedback feedback 3.2.0
Freehand drawing βœ… Pencil and speed-sensitive brush βœ…
Lines, rectangles, circles, arrows βœ… ❌
Text on the screenshot βœ… Inline, editable ❌ Text goes in a separate field
Eraser βœ… Removes the mark it touches ❌ Clear all only
Move, resize or delete a mark after drawing βœ… ❌
Undo βœ… βœ…
Colours βœ… Themeable palette βœ… Themeable palette
Stroke width βœ… User-selectable ❌ Fixed
Recent logs attached to the report βœ… package:logging, FlutterError, debugPrint ❌
Screen-reader labels on controls βœ… ❌ (#390)
Custom icons βœ… ❌ (#382)
Desktop layout (bottom toolbar) βœ… ❌
Built-in text field for a description βœ… Opt-in panel over the annotated screen βœ…
Translations English, every label overridable 19 built-in languages
Platforms Android, iOS, web, macOS, Windows, Linux Android, iOS, web, macOS, Windows, Linux

Installation

flutter pub add visual_feedback

Quick start

Wrap the widget tree under your navigator with VisualFeedback, and use the annotated screenshot in onFeedback:

import 'package:flutter/material.dart';
import 'package:visual_feedback/visual_feedback.dart';

final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

void main() => runApp(
  MaterialApp(
    navigatorKey: navigatorKey,
    builder: (BuildContext context, Widget? child) => VisualFeedback(
      // Show a draggable button that opens the editor.
      fabBuilder: (BuildContext context) => const CircleAvatar(
        child: Icon(Icons.bug_report),
      ),
      onFeedback: (VisualFeedbackData feedback) async {
        // feedback.screenshot is the annotated PNG, as Uint8List.
        await navigatorKey.currentState?.push(
          MaterialPageRoute<void>(
            builder: (_) => Scaffold(
              appBar: AppBar(title: const Text('Feedback')),
              body: Image.memory(feedback.screenshot),
            ),
          ),
        );
      },
      child: child!,
    ),
    home: const Scaffold(body: Center(child: Text('Hello'))),
  ),
);

MaterialApp.builder sits above the navigator it wraps, so the overlay floats over every route, dialog and bottom sheet. For the same reason, Navigator.of(context) does not work inside onFeedback β€” reach the navigator through a GlobalKey, as above.

Opening the editor

There are three ways to start a session. Use whichever suits your app.

1. The floating button. Pass fabBuilder. Users can drag it anywhere, and it snaps to the left, centre or right edge (snapToGrid). Pass null to hide it β€” for example behind a debug setting.

2. From any descendant widget.

IconButton(
  icon: const Icon(Icons.feedback_outlined),
  onPressed: () => VisualFeedback.of(context).show(),
)

3. With your own controller.

final VisualFeedbackController controller = VisualFeedbackController();

VisualFeedback(
  controller: controller,
  onFeedback: sendToBackend,
  child: child,
);

// Anywhere, for example after a shake gesture:
controller.show();

// A session can use its own callback instead of `onFeedback`:
controller.show((VisualFeedbackData feedback) => attachToChat(feedback));

// Remember to dispose a controller you created.
controller.dispose();

Toolbar layout

On a portrait screen the bars form a column that opens beside the floating button. When the screen is wider than it is tall β€” landscape phones and tablets, and desktop or web windows β€” they become a row centred along the bottom edge, with the tool, stroke and colour options opening above it. The layout follows rotation and window resizes, and both can be dragged out of the way.

To use one layout everywhere, pass toolbarLayout:

VisualFeedback(
  toolbarLayout: VisualFeedbackToolbarLayout.horizontal, // or .vertical
  onFeedback: sendToBackend,
  child: child,
);

Using the editor

Gesture Result
Drag on empty space Draws with the active tool
Tap a mark Selects it
Drag a selected mark Moves it
Drag a corner or edge handle Resizes (lines and arrows pivot)
Tap the red βœ• badge Deletes the selected mark
Tap selected text Edits it in place
Tap empty space Deselects

The control rail holds, top to bottom: close (discards the drawing), minimise (keeps it), undo, the tool, eraser, stroke width (or text size for the text tool), colour, and confirm (βœ“), which captures the screenshot and calls onFeedback.

Asking for a description

Set showDescriptionField: true to let users describe the problem before sending. Confirming captures the screenshot and logs, then opens a panel over the annotated screen with a text field, Send and Back. Send delivers the trimmed text in feedback.description; Back returns to the drawing with every annotation intact.

VisualFeedback(
  showDescriptionField: true,
  onFeedback: (VisualFeedbackData feedback) async {
    await uploadBugReport(
      feedback.screenshot,
      description: feedback.description, // '' if they typed nothing
      logs: feedback.logsAsText(),
    );
  },
  child: child,
);

description is null when the option is off. The panel follows your theme and its text comes from VisualFeedbackTheme.labels, so you can translate it with the rest of the editor.

Attaching recent logs

A screenshot shows what went wrong; logs show why. Pass a logging logger, and onFeedback receives everything it emitted in the last logDuration alongside the image:

import 'package:logging/logging.dart';

VisualFeedback(
  logger: Logger.root,                       // or any named Logger
  logDuration: const Duration(minutes: 3),   // default
  onFeedback: (VisualFeedbackData feedback) async {
    final Uint8List png = feedback.screenshot;
    final List<VisualFeedbackLogEntry> logs = feedback.logs; // oldest first

    final String report = [
      'Description: ${await askUserForDescription()}',
      '',
      'Logs:',
      feedback.logsAsText(includeStackTraces: true),
    ].join('\n');

    await uploadBugReport(png, report);
  },
  child: child,
);

The logs are a snapshot taken when the user taps confirm. Each VisualFeedbackLogEntry has time, level, loggerName, message, error and stackTrace. toLine() renders one as 2026-09-13T10:15:02.120Z [SEVERE] Store: Checkout failed | error: ….

Recording from app start

logger: records from when VisualFeedback is first built. To also keep startup logs, or to record framework errors and debugPrint output, create a VisualFeedbackLogRecorder in main() and pass it in:

void main() {
  Logger.root.level = Level.ALL;

  final VisualFeedbackLogRecorder logRecorder = VisualFeedbackLogRecorder(
    logger: Logger.root,
    retention: const Duration(minutes: 5),
    maxEntries: 1000,              // hard cap, whatever the age
    captureFlutterErrors: true,    // chains FlutterError.onError
    captureDebugPrint: true,       // wraps debugPrint
  );

  runApp(MyApp(logRecorder: logRecorder));
}

// …
VisualFeedback(
  logRecorder: logRecorder, // instead of logger:
  onFeedback: onFeedback,
  child: child,
);

You own a recorder you create: call logRecorder.dispose() if it should stop before the app does. Disposing restores FlutterError.onError and debugPrint. A named logger records only itself and its children, whether or not hierarchicalLoggingEnabled is set.

Logs can contain personal data. Only capture what you are allowed to send, and consider recording only in internal builds.

Theming

Every visual detail lives in VisualFeedbackTheme, which ships two presets: VisualFeedbackTheme.light (white glass, dark icons) and VisualFeedbackTheme.dark (near-black glass, light icons). By default the editor follows the platform brightness:

You pass Result
nothing light / dark preset, chosen by themeMode (default ThemeMode.system)
theme only that theme, in both brightnesses
theme + darkTheme chosen by themeMode
VisualFeedback(
  theme: VisualFeedbackTheme.light.copyWith(accentColor: Colors.indigo),
  darkTheme: VisualFeedbackTheme.dark.copyWith(accentColor: Colors.indigoAccent),
  themeMode: ThemeMode.system,
  onFeedback: onFeedback,
  child: child,
);

Start from a preset with copyWith and override only what you need:

VisualFeedback(
  theme: VisualFeedbackTheme.light.copyWith(
    drawColors: <Color>[Colors.red, Colors.blue, Colors.black],
    strokeWidths: <double>[2, 4, 8],
    defaultStrokeWidth: 4,
    textSizes: <double>[14, 20, 28],
    defaultTextSize: 20,
    accentColor: Colors.indigo,              // fill of the βœ“ button
    selectedControlColor: const Color(0x245B5BD6), // active highlight
    selectedControlForegroundColor: Colors.indigo,
    railBlurSigma: 0,                        // disable the glass blur
    labels: const VisualFeedbackLabels(undo: 'Deshacer'), // screen readers
    selectionColor: Colors.indigo,
    inlineEditorHintText: 'Add a note',
    confirmIcon: Icons.send_rounded,
  ),
  onFeedback: onFeedback,
  child: child,
);

It also covers icons for every tool, rail sizing and radii, selection handle geometry, the delete badge, the inline text editor and all animation durations. See the API reference.

Labels and localization

The editor's text is English by default: the screen-reader labels of every control, and the title, hint and buttons of the description panel. To use your own language, pass VisualFeedbackLabels β€” for example from your app's localizations:

VisualFeedback(
  theme: VisualFeedbackTheme.light.copyWith(
    labels: const VisualFeedbackLabels().copyWith(
      close: 'Schließen',
      undo: 'RΓΌckgΓ€ngig',
      confirm: 'Fertig',
      descriptionTitle: 'Beschreibung hinzufΓΌgen',
      descriptionHint: 'Was ist passiert?',
      send: 'Senden',
      back: 'ZurΓΌck',
    ),
    inlineEditorHintText: 'Text eingeben',
  ),
  onFeedback: onFeedback,
  child: child,
);

Handling errors

If the capture fails, or onFeedback throws, errorBuilder is called with a short message and the underlying error:

VisualFeedback(
  onFeedback: upload,
  errorBuilder: (String message, Object? error) {
    scaffoldMessengerKey.currentState?.showSnackBar(
      SnackBar(content: Text(message)),
    );
  },
  child: child,
);

Without an errorBuilder, failures are reported through FlutterError.reportError.

Advanced

Keeping the annotations visible during onFeedback

By default the editor closes as soon as the screenshot is captured. Set keepPreview: true to keep the annotated surface on screen while onFeedback runs, and close it yourself with controller.hide() when your next screen is ready. This avoids a one-frame flash of the un-annotated app when you push a page that shows the screenshot:

VisualFeedback(
  controller: controller,
  keepPreview: true,
  onFeedback: (VisualFeedbackData feedback) async {
    final Future<void> route = navigatorKey.currentState!.push(
      PageRouteBuilder<void>(
        opaque: false,
        transitionDuration: Duration.zero,
        pageBuilder: (_, _, _) => Image.memory(feedback.screenshot),
      ),
    );
    // The still is now underneath the editor: drop the editor.
    WidgetsBinding.instance.addPostFrameCallback((_) => controller.hide());
    await route;
  },
  child: child,
);

Mounting above MaterialApp

To also cover content outside the navigator, wrap MaterialApp itself. The inline text tool is a TextField, so provide Localizations and a MediaQuery above it:

runApp(
  MediaQuery.fromView(
    view: WidgetsBinding.instance.platformDispatcher.implicitView!,
    child: Localizations(
      locale: const Locale('en'),
      delegates: const <LocalizationsDelegate<Object>>[
        DefaultMaterialLocalizations.delegate,
        DefaultWidgetsLocalizations.delegate,
      ],
      child: VisualFeedback(
        onFeedback: onFeedback,
        child: MaterialApp(navigatorKey: navigatorKey, home: const Home()),
      ),
    ),
  ),
);

Capturing without the editor

VisualFeedbackScreenshot and VisualFeedbackScreenshotController are exported for plain captures:

final VisualFeedbackScreenshotController screenshots =
    VisualFeedbackScreenshotController();

VisualFeedbackScreenshot(controller: screenshots, child: const Chart());

final Uint8List? png = await screenshots.capture(pixelRatio: 3);

// Or render a widget that is not on screen at all:
final Uint8List? card = await screenshots.captureFromWidget(
  const ShareCard(),
  targetSize: const Size(600, 315),
);

Capture resolution

pixelRatio (default 3) controls the resolution of the PNG passed to onFeedback. Lower it to shrink uploads.

On the web, capturing and PNG encoding run on the browser's main thread, so the page pauses briefly after the user confirms. The confirm button shows its busy spinner before that pause, but the spinner cannot turn during it. The pause grows with the square of pixelRatio, so a lower value on the web keeps confirming snappy:

VisualFeedback(
  pixelRatio: kIsWeb ? 2 : 3,
  onFeedback: sendToBackend,
  child: child,
);

Migrating from feedback

Most apps move over in a few minutes. The concepts map one to one:

feedback visual_feedback
BetterFeedback(child: …) VisualFeedback(onFeedback: …, child: …)
BetterFeedback.of(context).show((UserFeedback f) { … }) VisualFeedback.of(context).show((VisualFeedbackData f) { … })
BetterFeedback.of(context).hide() VisualFeedback.of(context).hide()
feedback.screenshot feedback.screenshot (PNG Uint8List)
feedback.text feedback.description, with showDescriptionField: true
theme / darkTheme / themeMode theme / darkTheme / themeMode (VisualFeedbackTheme)
pixelRatio pixelRatio
β€” feedback.logs and logsAsText()

Before:

BetterFeedback(child: const MyApp());

BetterFeedback.of(context).show((UserFeedback feedback) {
  upload(feedback.screenshot, feedback.text);
});

After:

MaterialApp(
  navigatorKey: navigatorKey,
  builder: (BuildContext context, Widget? child) => VisualFeedback(
    showDescriptionField: true, // like feedback's text field
    logger: Logger.root, // optional: attach recent logs
    onFeedback: (VisualFeedbackData feedback) async {
      await upload(
        feedback.screenshot,
        feedback.description,
        feedback.logsAsText(),
      );
    },
    child: child!,
  ),
  home: const Home(),
);

VisualFeedback.of(context).show();

Prefer your own UI? Leave showDescriptionField off and collect the description in onFeedback β€” the example pushes a review page with a text field.

feedback ships translations; visual_feedback's text is English unless you pass your own labels.

FAQ

How do I add a bug report button to a Flutter app? Wrap your app in VisualFeedback and pass fabBuilder for a draggable button, or call VisualFeedback.of(context).show() from your own button. See Quick start.

Can I send the screenshot to Jira, GitHub, Sentry, Slack or my own backend? Yes. onFeedback receives the PNG bytes and the logs; upload them with http, share them with share_plus, or pass them to any SDK.

Does it work on Flutter web and desktop? Yes β€” Android, iOS, web (JavaScript and WebAssembly), macOS, Windows and Linux. On wide screens the toolbar moves to the bottom edge. Try the live demo.

Can users describe the problem in words? Yes. Set showDescriptionField: true and the text arrives in feedback.description. See Asking for a description.

Can I translate the editor? Yes. Everything it shows or announces comes from VisualFeedbackLabels. See Labels and localization.

Are the toolbar and selection handles in the screenshot? No. They are drawn outside the captured area, so the PNG shows only your app and the annotations.

Can I use it in release builds only for testers? Yes. Pass fabBuilder: null to hide the button and open the editor only when your own setting or gesture calls show().

Does it capture sensitive data? It captures what is on screen and, if you enable it, recent logs. Only send what your privacy policy allows.

Example

The example app is a small storefront showing the floating button, opening from the app bar, a custom theme, a log recorder started in main(), and a review page that shows the captured PNG with its logs and copies a ready-made bug report. Tap Run under Demo settings to log a failed checkout before sending feedback.

Live demo: https://jemisgoti.github.io/visual_feedback/ β€” deployed from main on every push. It runs on WebAssembly where the browser supports it and falls back to JavaScript elsewhere. To run it locally:

cd example
flutter run

Screenshots

Tool palette Selecting a shape The delivered PNG Logs in the report
Annotation tool palette: pencil, brush, line, rectangle, circle, arrow and text Selecting an annotation to move, resize or delete it The annotated PNG delivered to onFeedback Recent logs attached to the bug report

Contributing

Issues and pull requests are welcome β€” see CONTRIBUTING.md.

Main Contributors

Jemis Goti
Jemis Goti

Thanks

Thank you for using this package and keep supporting the open-source community.

License

MIT

Libraries

visual_feedback
Screenshot annotation overlay for in-app feedback and bug reports.