visual_feedback 0.0.1
visual_feedback: ^0.0.1 copied to clipboard
Let users annotate a live screenshot of your Flutter app with pencil, shapes, arrows and text, then get the PNG plus recent logs for bug reports.
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:logging/logging.dart';
import 'package:visual_feedback/visual_feedback.dart';
final Logger _log = Logger('Store');
void main() {
// 1. Configure package:logging as usual.
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen(
(LogRecord record) => developer.log(
record.message,
time: record.time,
level: record.level.value,
name: record.loggerName,
error: record.error,
stackTrace: record.stackTrace,
),
);
// 2. Start recording BEFORE runApp, so startup logs end up in reports too.
// Framework errors and debugPrint output are recorded alongside.
final VisualFeedbackLogRecorder logRecorder = VisualFeedbackLogRecorder(
logger: Logger.root,
retention: const Duration(minutes: 5),
captureFlutterErrors: true,
captureDebugPrint: true,
);
Logger('App').info('Starting Northwind Outfitters');
runApp(FeedbackExampleApp(logRecorder: logRecorder));
}
/// A small storefront app with an in-app bug-report flow.
///
/// It shows what most apps need:
///
/// * mounting [VisualFeedback] around every route with `MaterialApp.builder`,
/// * opening a session from a floating button OR from your own UI through
/// [VisualFeedback.of],
/// * recording recent logs with a [VisualFeedbackLogRecorder], and
/// * receiving the annotated PNG plus those logs in `onFeedback`, and turning
/// them into a bug report.
class FeedbackExampleApp extends StatefulWidget {
/// Creates the example app.
const FeedbackExampleApp({super.key, this.logRecorder});
/// The recorder started in [main].
///
/// When null — as in the widget tests — the app passes
/// `logger: Logger.root` instead, and [VisualFeedback] records from its
/// first build.
final VisualFeedbackLogRecorder? logRecorder;
@override
State<FeedbackExampleApp> createState() => _FeedbackExampleAppState();
}
class _FeedbackExampleAppState extends State<FeedbackExampleApp> {
// `MaterialApp.builder` sits above the Navigator it wraps, so callbacks from
// the overlay reach the navigator and messenger through these keys.
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
final GlobalKey<ScaffoldMessengerState> _messengerKey =
GlobalKey<ScaffoldMessengerState>();
final VisualFeedbackController _feedbackController =
VisualFeedbackController();
bool _showFab = true;
bool _useCustomTheme = false;
bool _useDescriptionField = false;
@override
void dispose() {
_feedbackController.dispose();
super.dispose();
}
Future<void> _onFeedback(VisualFeedbackData feedback) async {
_log.info('Feedback captured with ${feedback.logs.length} log entries');
final NavigatorState? navigator = _navigatorKey.currentState;
if (navigator == null) {
return;
}
await navigator.push(
MaterialPageRoute<void>(
builder: (BuildContext context) =>
FeedbackPreviewPage(feedback: feedback),
),
);
}
void _onFeedbackError(String message, Object? error) {
_log.severe(message, error);
_messengerKey.currentState?.showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) => MaterialApp(
title: 'visual_feedback example',
debugShowCheckedModeBanner: false,
navigatorKey: _navigatorKey,
scaffoldMessengerKey: _messengerKey,
theme: ThemeData(colorSchemeSeed: Colors.indigo),
builder: (BuildContext context, Widget? child) => VisualFeedback(
controller: _feedbackController,
onFeedback: _onFeedback,
errorBuilder: _onFeedbackError,
// Either hand over a recorder you started early...
logRecorder: widget.logRecorder,
// ...or just a logger, and let the widget record from here on.
logger: widget.logRecorder == null ? Logger.root : null,
// Null follows the platform brightness with the built-in light and
// dark glass presets.
theme: _useCustomTheme ? _customTheme : null,
fabBuilder: _showFab ? (_) => const _FeedbackFab() : null,
// Ask for a description in the built-in panel, delivered as
// `feedback.description`. Off by default: the review page below has its
// own field, prefilled with whatever the panel collected.
showDescriptionField: _useDescriptionField,
child: child!,
),
home: StoreHomePage(
showFab: _showFab,
useCustomTheme: _useCustomTheme,
useDescriptionField: _useDescriptionField,
onShowFabChanged: (bool value) => setState(() => _showFab = value),
onUseCustomThemeChanged: (bool value) =>
setState(() => _useCustomTheme = value),
onUseDescriptionFieldChanged: (bool value) =>
setState(() => _useDescriptionField = value),
),
);
}
/// A brand theme: the light glass preset tinted with the app's indigo.
final VisualFeedbackTheme _customTheme = VisualFeedbackTheme.light.copyWith(
drawColors: const <Color>[
Color(0xFF5B5BD6),
Color(0xFFE5484D),
Color(0xFF30A46C),
Color(0xFFF76B15),
Color(0xFF1C2024),
],
defaultStrokeWidth: 3.5,
accentColor: const Color(0xFF5B5BD6),
selectedControlColor: const Color(0x245B5BD6),
selectedControlForegroundColor: const Color(0xFF4A4AC4),
selectionColor: const Color(0xFF5B5BD6),
inlineEditorOutlineColor: const Color(0xFF5B5BD6),
inlineEditorHintText: 'Add a note',
);
class _FeedbackFab extends StatelessWidget {
const _FeedbackFab();
@override
Widget build(BuildContext context) => const DecoratedBox(
key: ValueKey<String>('example-feedback-fab'),
decoration: BoxDecoration(
color: Color(0xFFE5484D),
shape: BoxShape.circle,
boxShadow: <BoxShadow>[
BoxShadow(
color: Color(0x33000000),
blurRadius: 10,
offset: Offset(0, 3),
),
],
),
child: Icon(Icons.bug_report_rounded, color: Colors.white, size: 22),
);
}
/// Logs a realistic failure, so a report has something worth reading.
void simulateCheckoutFailure() {
_log
..info(r'Checkout started: 1 x Rain Shell ($210)')
..warning('Coupon AUTUMN40 rejected: expired on 2026-09-01');
try {
throw const _PaymentException(502);
} on _PaymentException catch (error, stackTrace) {
_log.severe('Checkout failed', error, stackTrace);
}
debugPrint('Cart badge rebuilt with 1 item');
}
class _PaymentException implements Exception {
const _PaymentException(this.statusCode);
final int statusCode;
@override
String toString() => 'PaymentException: service returned HTTP $statusCode';
}
/// Sample content worth annotating.
class StoreHomePage extends StatelessWidget {
/// Creates the home page.
const StoreHomePage({
required this.showFab,
required this.useCustomTheme,
required this.useDescriptionField,
required this.onShowFabChanged,
required this.onUseCustomThemeChanged,
required this.onUseDescriptionFieldChanged,
super.key,
});
/// Whether the floating feedback button is shown.
final bool showFab;
/// Whether the custom editor theme is applied.
final bool useCustomTheme;
/// Toggles [showFab].
final ValueChanged<bool> onShowFabChanged;
/// Toggles [useCustomTheme].
final ValueChanged<bool> onUseCustomThemeChanged;
/// Whether confirming opens the built-in description panel.
final bool useDescriptionField;
/// Toggles [useDescriptionField].
final ValueChanged<bool> onUseDescriptionFieldChanged;
static const List<_Product> _products = <_Product>[
_Product('Trail Runner', 'Footwear', r'$129', Color(0xFFFFE4D6)),
_Product('Canvas Backpack', 'Bags', r'$89', Color(0xFFDDF3E4)),
_Product('Merino Beanie', 'Accessories', r'$35', Color(0xFFE3E8FF)),
_Product('Rain Shell', 'Outerwear', r'$210', Color(0xFFFFF4C2)),
];
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Northwind Outfitters'),
actions: <Widget>[
IconButton(
key: const ValueKey<String>('example-send-feedback'),
tooltip: 'Send feedback',
icon: const Icon(Icons.feedback_outlined),
// Any descendant of VisualFeedback can start a session.
onPressed: () => VisualFeedback.of(context).show(),
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: <Widget>[
Card(
color: theme.colorScheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('Autumn sale', style: theme.textTheme.titleLarge),
const SizedBox(height: 4),
Text(
'Up to 40% off outerwear this week only.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 12),
FilledButton(
onPressed: () => _log.info('Tapped "Shop now" on banner'),
child: const Text('Shop now'),
),
],
),
),
),
const SizedBox(height: 16),
Text('Popular', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
for (final _Product product in _products)
Card(
child: ListTile(
onTap: () => _log.info('Viewed product "${product.name}"'),
leading: CircleAvatar(
backgroundColor: product.tint,
child: Text(product.name.characters.first),
),
title: Text(product.name),
subtitle: Text(product.category),
trailing: Text(
product.price,
style: theme.textTheme.titleMedium,
),
),
),
const SizedBox(height: 64),
Text('Demo settings', style: theme.textTheme.titleMedium),
SwitchListTile(
title: const Text('Floating feedback button'),
subtitle: const Text('Drag it anywhere; it snaps to the edges.'),
value: showFab,
onChanged: onShowFabChanged,
),
SwitchListTile(
title: const Text('Custom editor theme'),
subtitle: const Text('Styled with VisualFeedbackTheme.'),
value: useCustomTheme,
onChanged: onUseCustomThemeChanged,
),
SwitchListTile(
title: const Text('Built-in description'),
subtitle: const Text('Ask for a description before sending.'),
value: useDescriptionField,
onChanged: onUseDescriptionFieldChanged,
),
const ListTile(
title: Text('Simulate a failed checkout'),
subtitle: Text(
'Logs a warning, an error and a debugPrint '
'to attach to a report.',
),
trailing: OutlinedButton(
key: ValueKey<String>('example-simulate-failure'),
onPressed: simulateCheckoutFailure,
child: Text('Run'),
),
),
],
),
);
}
}
class _Product {
const _Product(this.name, this.category, this.price, this.tint);
final String name;
final String category;
final String price;
final Color tint;
}
/// Shows the annotated screenshot and the logs delivered by `onFeedback`, and
/// builds a bug report from them.
///
/// A real app would upload [feedback] to an issue tracker, or hand the PNG and
/// the report text to the platform share sheet.
class FeedbackPreviewPage extends StatefulWidget {
/// Creates the preview page.
const FeedbackPreviewPage({required this.feedback, super.key});
/// The captured screenshot and logs.
final VisualFeedbackData feedback;
@override
State<FeedbackPreviewPage> createState() => _FeedbackPreviewPageState();
}
class _FeedbackPreviewPageState extends State<FeedbackPreviewPage> {
/// The most recent entries shown on screen; the report includes all of them.
static const int _visibleLogLimit = 50;
/// Prefilled when the built-in description panel was used.
late final TextEditingController _descriptionController =
TextEditingController(text: widget.feedback.description);
@override
void dispose() {
_descriptionController.dispose();
super.dispose();
}
String _buildReport() {
final VisualFeedbackData feedback = widget.feedback;
final String description = _descriptionController.text.trim();
final int kilobytes = (feedback.screenshot.lengthInBytes / 1024).round();
return (StringBuffer()
..writeln('Bug report')
..writeln('Time: ${DateTime.now().toUtc().toIso8601String()} (UTC)')
..writeln('Description: ${description.isEmpty ? 'N/A' : description}')
..writeln('Screenshot: $kilobytes KB PNG')
..writeln()
..writeln('Logs (${feedback.logs.length}):')
..writeln(
feedback.logs.isEmpty
? '(none)'
: feedback.logsAsText(includeStackTraces: true),
))
.toString();
}
Future<void> _copyReport() async {
final String report = _buildReport();
await Clipboard.setData(ClipboardData(text: report));
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Bug report copied: ${widget.feedback.logs.length} log entries',
),
),
);
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final List<VisualFeedbackLogEntry> logs = widget.feedback.logs;
final List<VisualFeedbackLogEntry> visibleLogs =
logs.length > _visibleLogLimit
? logs.sublist(logs.length - _visibleLogLimit)
: logs;
return Scaffold(
appBar: AppBar(title: const Text('Review feedback')),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 420),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.memory(
widget.feedback.screenshot,
key: const ValueKey<String>('example-preview-image'),
),
),
),
),
),
const SizedBox(height: 16),
TextField(
controller: _descriptionController,
minLines: 3,
maxLines: 5,
decoration: const InputDecoration(
labelText: 'What went wrong?',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
FilledButton.icon(
key: const ValueKey<String>('example-copy-report'),
onPressed: _copyReport,
icon: const Icon(Icons.content_copy_rounded),
label: const Text('Copy bug report'),
),
const SizedBox(height: 24),
Text(
'Recent logs (${logs.length})',
key: const ValueKey<String>('example-logs-title'),
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
if (logs.isEmpty)
Text(
'No logs were recorded. Tap "Run" under Demo settings, then '
'send feedback again.',
style: theme.textTheme.bodyMedium,
)
else
DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: <Widget>[
for (final VisualFeedbackLogEntry entry in visibleLogs)
_LogLine(entry: entry),
],
),
),
),
],
),
),
);
}
}
class _LogLine extends StatelessWidget {
const _LogLine({required this.entry});
final VisualFeedbackLogEntry entry;
Color _levelColor(ColorScheme scheme) {
if (entry.level >= Level.SEVERE) {
return scheme.error;
}
if (entry.level >= Level.WARNING) {
return const Color(0xFFF76B15);
}
return scheme.outline;
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final DateTime time = entry.time.toLocal();
final String clock = <int>[
time.hour,
time.minute,
time.second,
].map((int part) => part.toString().padLeft(2, '0')).join(':');
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(
width: 64,
padding: const EdgeInsets.symmetric(vertical: 2),
alignment: Alignment.center,
decoration: BoxDecoration(
color: _levelColor(theme.colorScheme),
borderRadius: BorderRadius.circular(6),
),
child: Text(
entry.level.name,
style: theme.textTheme.labelSmall?.copyWith(color: Colors.white),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'$clock ${entry.loggerName}: ${entry.message}'
'${entry.error == null ? '' : '\n${entry.error}'}',
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
),
),
],
),
);
}
}