visual_feedback 0.0.1 copy "visual_feedback: ^0.0.1" to clipboard
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.

example/lib/main.dart

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',
              ),
            ),
          ),
        ],
      ),
    );
  }
}
0
likes
160
points
36
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

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.

Repository (GitHub)
View/report issues
Contributing

Topics

#feedback #screenshot #annotation #bug-report #drawing

License

MIT (license)

Dependencies

flutter, logging

More

Packages that depend on visual_feedback