diff --git a/feedback/README.md b/feedback/README.md index a6ae3af..7e7ac3d 100644 --- a/feedback/README.md +++ b/feedback/README.md @@ -226,6 +226,17 @@ void main() { } ``` +## Hiding sensitive content in the screenshot + +Any widget that contains sensitive content can be wrapped with a `FeedbackRedacted` widget. This will ensure that the content is blurred in the screenshot. + +```dart +FeedbackRedacted( + blurAmount: 10, // Optional, defaults to 5. + child: Text('sensitive information'), +); +``` + ## 💡 Tips, tricks and usage scenarios - You can combine this with [device_info_plus](https://pub.dev/packages/device_info_plus) diff --git a/feedback/example/lib/main.dart b/feedback/example/lib/main.dart index 20cc49b..61af1ed 100644 --- a/feedback/example/lib/main.dart +++ b/feedback/example/lib/main.dart @@ -58,6 +58,7 @@ class _MyAppState extends State { GlobalCupertinoLocalizations.delegate, GlobalWidgetsLocalizations.delegate, ], + localeOverride: const Locale('en'), mode: FeedbackMode.draw, pixelRatio: 1, @@ -205,6 +206,10 @@ class MyHomePage extends StatelessWidget { ); }, ), + SizedBox(height: 10), + FeedbackRedacted( + child: const Text('This is some sensitive information.'), + ), ], ), ), diff --git a/feedback/lib/feedback.dart b/feedback/lib/feedback.dart index 121146b..1d8c4e9 100644 --- a/feedback/lib/feedback.dart +++ b/feedback/lib/feedback.dart @@ -5,6 +5,8 @@ library feedback; export 'src/better_feedback.dart'; export 'src/feedback_controller.dart'; export 'src/feedback_mode.dart'; +export 'src/feedback_redacted.dart'; +export 'src/feedback_redaction_controller.dart'; export 'src/l18n/translation.dart'; export 'src/theme/feedback_theme.dart' show FeedbackThemeData; export 'src/user_feedback.dart'; diff --git a/feedback/lib/src/feedback_controller.dart b/feedback/lib/src/feedback_controller.dart index f8f68f9..d8823c2 100644 --- a/feedback/lib/src/feedback_controller.dart +++ b/feedback/lib/src/feedback_controller.dart @@ -25,6 +25,7 @@ class FeedbackController extends ChangeNotifier { /// Typically, this does not need to be called by the user of this library void hide() { _isVisible = false; + redactionController.unredact(); notifyListeners(); } @@ -34,4 +35,11 @@ class FeedbackController extends ChangeNotifier { /// true and feedback is currently displayed. final DraggableScrollableController sheetController = DraggableScrollableController(); + + /// Controller for managing redaction of sensitive content. + /// + // Having a seperate controller for redaction ensures that the entire feedback + // widget isn't rebuilt when redaction state changes. + final FeedbackRedactionController redactionController = + FeedbackRedactionController(); } diff --git a/feedback/lib/src/feedback_redacted.dart b/feedback/lib/src/feedback_redacted.dart new file mode 100644 index 0000000..4973f21 --- /dev/null +++ b/feedback/lib/src/feedback_redacted.dart @@ -0,0 +1,63 @@ +import 'dart:ui'; + +import 'package:feedback/src/better_feedback.dart'; +import 'package:feedback/src/feedback_redaction_controller.dart'; +import 'package:flutter/material.dart'; + +/// A widget that applies a blur effect to its child when the [BetterFeedback] +/// draw mode is active or the screenshot is being taken. This is useful for +/// redacting sensitive information from the screenshot. +class FeedbackRedacted extends StatefulWidget { + /// Creates a [FeedbackRedacted] widget. + const FeedbackRedacted({required this.child, this.blurAmount = 5, super.key}); + + /// The child widget to which the blur effect will be applied. + final Widget child; + + /// The amount of blur to apply when redaction mode is enabled. + final double blurAmount; + + @override + State createState() => _FeedbackRedactedState(); +} + +class _FeedbackRedactedState extends State { + FeedbackRedactionController? controller; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + // Ensure that the listener is only added once. + controller ??= BetterFeedback.of(context).redactionController + ..addListener(onUpdateOfController); + } + + @override + void dispose() { + super.dispose(); + controller?.removeListener(onUpdateOfController); + } + + @override + Widget build(BuildContext context) { + if (controller?.isRedacted == true) { + // Re-wrapping the child instead of just changing the sigma values makes + // testing easier. + return ImageFiltered( + key: const Key('redaction_blur'), + imageFilter: ImageFilter.blur( + sigmaX: widget.blurAmount, + sigmaY: widget.blurAmount, + ), + child: widget.child, + ); + } + + return widget.child; + } + + void onUpdateOfController() { + setState(() {}); + } +} diff --git a/feedback/lib/src/feedback_redaction_controller.dart b/feedback/lib/src/feedback_redaction_controller.dart new file mode 100644 index 0000000..7f86dba --- /dev/null +++ b/feedback/lib/src/feedback_redaction_controller.dart @@ -0,0 +1,29 @@ +import 'package:feedback/feedback.dart'; +import 'package:flutter/material.dart'; + +/// Controls the state of the feedback redaction. +class FeedbackRedactionController extends ChangeNotifier { + bool _isRedacted = false; + + /// Whether sensitive content (wrapped in [FeedbackRedacted]) is currently + /// redacted. + bool get isRedacted => _isRedacted; + + /// Redacts all sensitive content (wrapped in [FeedbackRedacted]). + /// After draw mode is enabled or the screenshot is being taken. + /// Typically, this does not need to be called by the user of this library. + void redact() { + if (_isRedacted) return; + _isRedacted = true; + notifyListeners(); + } + + /// Unredacts all sensitive content (wrapped in [FeedbackRedacted]). + /// After navigation mode is enabled or the screenshot has been taken. + /// Typically, this does not need to be called by the user of this library. + void unredact() { + if (!_isRedacted) return; + _isRedacted = false; + notifyListeners(); + } +} diff --git a/feedback/lib/src/feedback_widget.dart b/feedback/lib/src/feedback_widget.dart index 5a9a271..b99cb61 100644 --- a/feedback/lib/src/feedback_widget.dart +++ b/feedback/lib/src/feedback_widget.dart @@ -110,10 +110,13 @@ class FeedbackWidgetState extends State super.didUpdateWidget(oldWidget); // update feedback mode with the initial value mode = widget.mode; + if (oldWidget.mode != widget.mode) _toggleRedaction(); + if (oldWidget.isFeedbackVisible != widget.isFeedbackVisible && oldWidget.isFeedbackVisible == false) { // Feedback is now visible, // start animation to show it. + _toggleRedaction(); _controller.forward(); } @@ -239,6 +242,7 @@ class FeedbackWidgetState extends State this.mode = mode; _hideKeyboard(context); }); + _toggleRedaction(); }, onCloseFeedback: () { _hideKeyboard(context); @@ -325,6 +329,14 @@ class FeedbackWidgetState extends State ); } + void _toggleRedaction() { + if (mode == FeedbackMode.draw) { + BetterFeedback.of(context).redactionController.redact(); + } else { + BetterFeedback.of(context).redactionController.unredact(); + } + } + static Future _sendFeedback( BuildContext context, OnFeedbackCallback onFeedbackSubmitted, @@ -335,6 +347,8 @@ class FeedbackWidgetState extends State bool showKeyboard = false, Map? extras, }) async { + BetterFeedback.of(context).redactionController.redact(); + if (!showKeyboard) { _hideKeyboard(context); } diff --git a/feedback/test/feedback_controller_test.dart b/feedback/test/feedback_controller_test.dart index d8a3f65..62c341a 100644 --- a/feedback/test/feedback_controller_test.dart +++ b/feedback/test/feedback_controller_test.dart @@ -32,5 +32,20 @@ void main() { expect(controller.isVisible, false); expect(listenerWasCalled, true); }); + + test(' change redaction from redacted to unredacted when hidden', () { + final controller = FeedbackController(); + controller.show((_) {}); + controller.redactionController.redact(); + + var listenerWasCalled = false; + controller.redactionController.addListener(() { + listenerWasCalled = true; + }); + + controller.hide(); + expect(controller.redactionController.isRedacted, false); + expect(listenerWasCalled, true); + }); }); } diff --git a/feedback/test/feedback_redaction_controller_test.dart b/feedback/test/feedback_redaction_controller_test.dart new file mode 100644 index 0000000..fa1afb9 --- /dev/null +++ b/feedback/test/feedback_redaction_controller_test.dart @@ -0,0 +1,38 @@ +import 'package:feedback/src/feedback_redaction_controller.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('FeedbackRedactionController', () { + test(' default is unredacted', () { + final controller = FeedbackRedactionController(); + expect(controller.isRedacted, false); + }); + + test(' change redaction from unredacted to redacted', () { + final controller = FeedbackRedactionController(); + + var listenerWasCalled = false; + controller.addListener(() { + listenerWasCalled = true; + }); + + controller.redact(); + expect(controller.isRedacted, true); + expect(listenerWasCalled, true); + }); + + test(' change redaction from redacted to unredacted', () { + final controller = FeedbackRedactionController(); + controller.redact(); + + var listenerWasCalled = false; + controller.addListener(() { + listenerWasCalled = true; + }); + + controller.unredact(); + expect(controller.isRedacted, false); + expect(listenerWasCalled, true); + }); + }); +} diff --git a/feedback/test/feedback_test.dart b/feedback/test/feedback_test.dart index d55e950..a165f51 100644 --- a/feedback/test/feedback_test.dart +++ b/feedback/test/feedback_test.dart @@ -25,8 +25,10 @@ void main() { // feedback is closed var userInputFields = find.byKey(const Key('feedback_bottom_sheet')); + final redactionBlur = find.byKey(const Key('redaction_blur')); expect(userInputFields, findsNothing); + expect(redactionBlur, findsNothing); // open feedback final openFeedbackButton = find.text('open feedback'); @@ -55,8 +57,10 @@ void main() { // feedback is closed var userInputFields = find.byKey(const Key('feedback_bottom_sheet')); + final redactionBlur = find.byKey(const Key('redaction_blur')); expect(userInputFields, findsNothing); + expect(redactionBlur, findsNothing); // open feedback final openFeedbackButton = find.text('open feedback'); @@ -67,6 +71,7 @@ void main() { final activeDrawingColor = getActiveColorButton(); expect(userInputFields, findsOneWidget); + expect(redactionBlur, findsOneWidget); expect(activeDrawingColor.evaluate().length, 4); }); @@ -85,8 +90,10 @@ void main() { // feedback is closed var userInputFields = find.byKey(const Key('feedback_bottom_sheet')); + final redactionBlur = find.byKey(const Key('redaction_blur')); expect(userInputFields, findsNothing); + expect(redactionBlur, findsNothing); // open feedback final openFeedbackButton = find.text('open feedback'); @@ -97,6 +104,7 @@ void main() { final activeDrawingColor = getActiveColorButton(); expect(userInputFields, findsOneWidget); + expect(redactionBlur, findsNothing); expect(activeDrawingColor, findsNothing); }); @@ -110,8 +118,10 @@ void main() { // feedback is closed final userInputFields = find.byKey(const Key('feedback_bottom_sheet')); + final redactionBlur = find.byKey(const Key('redaction_blur')); expect(userInputFields, findsNothing); + expect(redactionBlur, findsNothing); // open feedback final openFeedbackButton = find.byKey(const Key('open_feedback')); @@ -127,10 +137,11 @@ void main() { await tester.pumpAndSettle(); expect(userInputFields, findsNothing); + expect(redactionBlur, findsNothing); }); testWidgets( - 'back button in drawing mode reverses drawings and ' + 'back button in drawing mode reverses drawings and redaction and ' 'then leaves the feedback interface', (tester) async { const widget = BetterFeedback( mode: FeedbackMode.draw, @@ -142,6 +153,7 @@ void main() { // feedback is closed final userInputFields = find.byKey(const Key('feedback_bottom_sheet')); + final redactionBlur = find.byKey(const Key('redaction_blur')); // open feedback final openFeedbackButton = find.byKey(const Key('open_feedback')); @@ -149,6 +161,7 @@ void main() { await tester.pumpAndSettle(); expect(userInputFields, findsOneWidget); + expect(redactionBlur, findsOneWidget); // add fake step to test reversing final feedbackWidgetState = @@ -167,6 +180,7 @@ void main() { await tester.pumpAndSettle(); expect(userInputFields, findsNothing); + expect(redactionBlur, findsNothing); }); testWidgets('feedback callback gets called', (tester) async { diff --git a/feedback/test/test_app.dart b/feedback/test/test_app.dart index 68b32f3..7d6b6d9 100644 --- a/feedback/test/test_app.dart +++ b/feedback/test/test_app.dart @@ -66,9 +66,11 @@ class MyTestPageState extends State { const Text( 'You have pushed the button this many times:', ), - Text( - '$counter', - style: Theme.of(context).textTheme.headlineMedium, + FeedbackRedacted( + child: Text( + '$counter', + style: Theme.of(context).textTheme.headlineMedium, + ), ), const TextField(), TextButton(