visual_feedback β in-app bug reports with annotated screenshots for Flutter
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.
Live demo: https://jemisgoti.github.io/visual_feedback/ Β· more screenshots
Contents
- Features
- visual_feedback vs feedback
- Installation
- Quick start
- Opening the editor
- Toolbar layout
- Using the editor
- Asking for a description
- Attaching recent logs
- Theming
- Handling errors
- Advanced
- Migrating from feedback
- FAQ
- Example
- Screenshots
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:logginglogger and every report carries the logs from the last few minutes β plus framework errors anddebugPrintoutput 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 |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
Contributing
Issues and pull requests are welcome β see CONTRIBUTING.md.
Main Contributors
Jemis Goti |
Thanks
Thank you for using this package and keep supporting the open-source community.
License
Libraries
- visual_feedback
- Screenshot annotation overlay for in-app feedback and bug reports.



