diff --git a/CHANGELOG.md b/CHANGELOG.md index 98720b8dda..39377dee75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ - Deprecated `Hub.captureUserFeedback`, use `captureFeedback` instead. - Deprecated `SentryClient.captureUserFeedback`, use `captureFeedback` instead. - Deprecated `SentryUserFeedback`, use `SentryFeedback` instead. +- Add `SentryFeedbackWidget` ([#2240](https://github.com/getsentry/sentry-dart/pull/2240)) +```dart +Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SentryFeedbackWidget(associatedEventId: id), + fullscreenDialog: true, + ), +); +``` ### Enhancements diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index 5620c6de74..91c7c1094c 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -28,7 +28,6 @@ import 'auto_close_screen.dart'; import 'drift/connection/connection.dart'; import 'drift/database.dart'; import 'isar/user.dart'; -import 'user_feedback_dialog.dart'; // ATTENTION: Change the DSN below with your own to see the events in Sentry. Get one at sentry.io const String exampleDsn = @@ -453,7 +452,7 @@ class MainScaffold extends StatelessWidget { Sentry.captureMessage( 'This message has an attachment', withScope: (scope) { - const txt = 'Lorem Ipsum dolar sit amet'; + const txt = 'Lorem Ipsum dolor sit amet'; scope.addAttachment( SentryAttachment.fromIntList( utf8.encode(txt), @@ -501,43 +500,17 @@ class MainScaffold extends StatelessWidget { onPressed: () async { final id = await Sentry.captureMessage('UserFeedback'); if (!context.mounted) return; - await showDialog( - context: context, - builder: (context) { - return UserFeedbackDialog(eventId: id); - }, - ); - }, - text: - 'Shows a custom user feedback dialog without an ongoing event that captures and sends user feedback data to Sentry.', - buttonTitle: 'Capture User Feedback', - ), - TooltipButton( - onPressed: () async { - await showDialog( - context: context, - builder: (context) { - return UserFeedbackDialog(eventId: SentryId.newId()); - }, - ); - }, - text: '', - buttonTitle: 'Show UserFeedback Dialog without event', - ), - TooltipButton( - onPressed: () async { - final associatedEventId = - await Sentry.captureMessage('Associated Event'); - await Sentry.captureFeedback( - SentryFeedback( - message: 'message', - contactEmail: 'john.appleseed@apple.com', - name: 'John Appleseed', - associatedEventId: associatedEventId, + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + SentryFeedbackWidget(associatedEventId: id), + fullscreenDialog: true, ), ); }, - text: '', + text: + 'Shows a custom feedback dialog without an ongoing event that captures and sends user feedback data to Sentry.', buttonTitle: 'Capture Feedback', ), TooltipButton( diff --git a/flutter/example/lib/user_feedback_dialog.dart b/flutter/example/lib/user_feedback_dialog.dart deleted file mode 100644 index ac7bd4308c..0000000000 --- a/flutter/example/lib/user_feedback_dialog.dart +++ /dev/null @@ -1,464 +0,0 @@ -// ignore_for_file: library_private_types_in_public_api - -import 'package:flutter/material.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; - -class UserFeedbackDialog extends StatefulWidget { - const UserFeedbackDialog({ - super.key, - required this.eventId, - this.hub, - }) : assert(eventId != const SentryId.empty()); - - final SentryId eventId; - final Hub? hub; - - @override - _UserFeedbackDialogState createState() => _UserFeedbackDialogState(); -} - -class _UserFeedbackDialogState extends State { - TextEditingController nameController = TextEditingController(); - TextEditingController emailController = TextEditingController(); - TextEditingController commentController = TextEditingController(); - - @override - Widget build(BuildContext context) { - return AlertDialog( - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - "It looks like we're having some internal issues.", - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 4), - Text( - 'Our team has been notified. ' - "If you'd like to help, tell us what happened below.", - textAlign: TextAlign.center, - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith(color: Colors.grey), - ), - const Divider(height: 24), - TextField( - key: const ValueKey('sentry_name_textfield'), - decoration: const InputDecoration( - border: OutlineInputBorder(), - hintText: 'Name', - ), - controller: nameController, - keyboardType: TextInputType.text, - ), - const SizedBox(height: 8), - TextField( - key: const ValueKey('sentry_email_textfield'), - decoration: const InputDecoration( - border: OutlineInputBorder(), - hintText: 'E-Mail', - ), - controller: emailController, - keyboardType: TextInputType.emailAddress, - ), - const SizedBox(height: 8), - TextField( - key: const ValueKey('sentry_comment_textfield'), - minLines: 5, - maxLines: null, - decoration: const InputDecoration( - border: OutlineInputBorder(), - hintText: 'What happened?', - ), - controller: commentController, - keyboardType: TextInputType.multiline, - ), - const SizedBox(height: 8), - const _PoweredBySentryMessage(), - ], - ), - ), - actions: [ - ElevatedButton( - key: const ValueKey('sentry_submit_feedback_button'), - onPressed: () async { - // ignore: deprecated_member_use - final feedback = SentryUserFeedback( - eventId: widget.eventId, - comments: commentController.text, - email: emailController.text, - name: nameController.text, - ); - await _submitUserFeedback(feedback); - // ignore: use_build_context_synchronously - Navigator.pop(context); - }, - child: const Text('Submit Crash Report')), - TextButton( - key: const ValueKey('sentry_close_button'), - onPressed: () { - Navigator.pop(context); - }, - child: const Text('Close'), - ) - ], - ); - } - - // ignore: deprecated_member_use - Future _submitUserFeedback(SentryUserFeedback feedback) { - // ignore: deprecated_member_use - return (widget.hub ?? HubAdapter()).captureUserFeedback(feedback); - } -} - -class _PoweredBySentryMessage extends StatelessWidget { - const _PoweredBySentryMessage(); - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Crash reports powered by'), - const SizedBox(width: 8), - SizedBox( - height: 30, - child: _SentryLogo(), - ), - ], - ), - ); - } -} - -class _SentryLogo extends StatelessWidget { - @override - Widget build(BuildContext context) { - var color = Colors.white; - final brightenss = Theme.of(context).brightness; - if (brightenss == Brightness.light) { - color = const Color(0xff362d59); - } - - return FittedBox( - fit: BoxFit.contain, - child: CustomPaint( - size: const Size(222, 66), - painter: _SentryLogoCustomPainter(color), - ), - ); - } -} - -/// Created with https://fluttershapemaker.com/ -/// Sentry Logo comes from https://sentry.io/branding/ -class _SentryLogoCustomPainter extends CustomPainter { - final Color color; - - _SentryLogoCustomPainter(this.color); - - @override - void paint(Canvas canvas, Size size) { - final path_0 = Path(); - path_0.moveTo(size.width * 0.1306306, size.height * 0.03424242); - path_0.arcToPoint(Offset(size.width * 0.09459459, size.height * 0.03424242), - radius: Radius.elliptical( - size.width * 0.02103604, size.height * 0.07075758), - rotation: 0, - largeArc: false, - clockwise: false); - path_0.lineTo(size.width * 0.06495495, size.height * 0.2050000); - path_0.arcToPoint(Offset(size.width * 0.1449099, size.height * 0.6089394), - radius: - Radius.elliptical(size.width * 0.1450901, size.height * 0.4880303), - rotation: 0, - largeArc: false, - clockwise: true); - path_0.lineTo(size.width * 0.1240991, size.height * 0.6089394); - path_0.arcToPoint(Offset(size.width * 0.05445946, size.height * 0.2646970), - radius: - Radius.elliptical(size.width * 0.1246847, size.height * 0.4193939), - rotation: 0, - largeArc: false, - clockwise: false); - path_0.lineTo(size.width * 0.02702703, size.height * 0.4242424); - path_0.arcToPoint(Offset(size.width * 0.06860360, size.height * 0.6086364), - radius: - Radius.elliptical(size.width * 0.07171171, size.height * 0.2412121), - rotation: 0, - largeArc: false, - clockwise: true); - path_0.lineTo(size.width * 0.02081081, size.height * 0.6086364); - path_0.arcToPoint(Offset(size.width * 0.01801802, size.height * 0.5918182), - radius: Radius.elliptical( - size.width * 0.003423423, size.height * 0.01151515), - rotation: 0, - largeArc: false, - clockwise: true); - path_0.lineTo(size.width * 0.03126126, size.height * 0.5160606); - path_0.arcToPoint(Offset(size.width * 0.01612613, size.height * 0.4872727), - radius: - Radius.elliptical(size.width * 0.04837838, size.height * 0.1627273), - rotation: 0, - largeArc: false, - clockwise: false); - path_0.lineTo(size.width * 0.003018018, size.height * 0.5630303); - path_0.arcToPoint(Offset(size.width * 0.01063063, size.height * 0.6575758), - radius: Radius.elliptical( - size.width * 0.02045045, size.height * 0.06878788), - rotation: 0, - largeArc: false, - clockwise: false); - path_0.arcToPoint(Offset(size.width * 0.02081081, size.height * 0.6666667), - radius: Radius.elliptical( - size.width * 0.02099099, size.height * 0.07060606), - rotation: 0, - largeArc: false, - clockwise: false); - path_0.lineTo(size.width * 0.08626126, size.height * 0.6666667); - path_0.arcToPoint(Offset(size.width * 0.05022523, size.height * 0.4043939), - radius: - Radius.elliptical(size.width * 0.08738739, size.height * 0.2939394), - rotation: 0, - largeArc: false, - clockwise: false); - path_0.lineTo(size.width * 0.06063063, size.height * 0.3437879); - path_0.arcToPoint(Offset(size.width * 0.1070270, size.height * 0.6666667), - radius: - Radius.elliptical(size.width * 0.1075225, size.height * 0.3616667), - rotation: 0, - largeArc: false, - clockwise: true); - path_0.lineTo(size.width * 0.1624775, size.height * 0.6666667); - path_0.arcToPoint(Offset(size.width * 0.08855856, size.height * 0.1848485), - radius: - Radius.elliptical(size.width * 0.1616216, size.height * 0.5436364), - rotation: 0, - largeArc: false, - clockwise: false); - path_0.lineTo(size.width * 0.1095946, size.height * 0.06363636); - path_0.arcToPoint(Offset(size.width * 0.1143243, size.height * 0.05954545), - radius: Radius.elliptical( - size.width * 0.003468468, size.height * 0.01166667), - rotation: 0, - largeArc: false, - clockwise: true); - path_0.cubicTo( - size.width * 0.1167117, - size.height * 0.06393939, - size.width * 0.2057207, - size.height * 0.5863636, - size.width * 0.2073874, - size.height * 0.5924242); - path_0.arcToPoint(Offset(size.width * 0.2043243, size.height * 0.6095455), - radius: Radius.elliptical( - size.width * 0.003423423, size.height * 0.01151515), - rotation: 0, - largeArc: false, - clockwise: true); - path_0.lineTo(size.width * 0.1828829, size.height * 0.6095455); - path_0.quadraticBezierTo(size.width * 0.1832883, size.height * 0.6384848, - size.width * 0.1828829, size.height * 0.6672727); - path_0.lineTo(size.width * 0.2044144, size.height * 0.6672727); - path_0.arcToPoint(Offset(size.width * 0.2252252, size.height * 0.5974242), - radius: Radius.elliptical( - size.width * 0.02067568, size.height * 0.06954545), - rotation: 0, - largeArc: false, - clockwise: false); - path_0.arcToPoint(Offset(size.width * 0.2224324, size.height * 0.5628788), - radius: Radius.elliptical( - size.width * 0.02022523, size.height * 0.06803030), - rotation: 0, - largeArc: false, - clockwise: false); - path_0.close(); - path_0.moveTo(size.width * 0.5600000, size.height * 0.4284848); - path_0.lineTo(size.width * 0.4935135, size.height * 0.1396970); - path_0.lineTo(size.width * 0.4769369, size.height * 0.1396970); - path_0.lineTo(size.width * 0.4769369, size.height * 0.5268182); - path_0.lineTo(size.width * 0.4937387, size.height * 0.5268182); - path_0.lineTo(size.width * 0.4937387, size.height * 0.2301515); - path_0.lineTo(size.width * 0.5621171, size.height * 0.5268182); - path_0.lineTo(size.width * 0.5768018, size.height * 0.5268182); - path_0.lineTo(size.width * 0.5768018, size.height * 0.1396970); - path_0.lineTo(size.width * 0.5600000, size.height * 0.1396970); - path_0.close(); - path_0.moveTo(size.width * 0.3925676, size.height * 0.3566667); - path_0.lineTo(size.width * 0.4521622, size.height * 0.3566667); - path_0.lineTo(size.width * 0.4521622, size.height * 0.3063636); - path_0.lineTo(size.width * 0.3925225, size.height * 0.3063636); - path_0.lineTo(size.width * 0.3925225, size.height * 0.1898485); - path_0.lineTo(size.width * 0.4597748, size.height * 0.1898485); - path_0.lineTo(size.width * 0.4597748, size.height * 0.1395455); - path_0.lineTo(size.width * 0.3754054, size.height * 0.1395455); - path_0.lineTo(size.width * 0.3754054, size.height * 0.5268182); - path_0.lineTo(size.width * 0.4606306, size.height * 0.5268182); - path_0.lineTo(size.width * 0.4606306, size.height * 0.4765152); - path_0.lineTo(size.width * 0.3925225, size.height * 0.4765152); - path_0.close(); - path_0.moveTo(size.width * 0.3224775, size.height * 0.3075758); - path_0.lineTo(size.width * 0.3224775, size.height * 0.3075758); - path_0.cubicTo( - size.width * 0.2992793, - size.height * 0.2887879, - size.width * 0.2927928, - size.height * 0.2739394, - size.width * 0.2927928, - size.height * 0.2378788); - path_0.cubicTo( - size.width * 0.2927928, - size.height * 0.2054545, - size.width * 0.3013063, - size.height * 0.1834848, - size.width * 0.3140090, - size.height * 0.1834848); - path_0.arcToPoint(Offset(size.width * 0.3458559, size.height * 0.2221212), - radius: - Radius.elliptical(size.width * 0.05432432, size.height * 0.1827273), - rotation: 0, - largeArc: false, - clockwise: true); - path_0.lineTo(size.width * 0.3548649, size.height * 0.1792424); - path_0.arcToPoint(Offset(size.width * 0.3143243, size.height * 0.1337879), - radius: - Radius.elliptical(size.width * 0.06351351, size.height * 0.2136364), - rotation: 0, - largeArc: false, - clockwise: false); - path_0.cubicTo( - size.width * 0.2915315, - size.height * 0.1337879, - size.width * 0.2756306, - size.height * 0.1792424, - size.width * 0.2756306, - size.height * 0.2439394); - path_0.cubicTo( - size.width * 0.2756306, - size.height * 0.3136364, - size.width * 0.2891441, - size.height * 0.3377273, - size.width * 0.3137387, - size.height * 0.3578788); - path_0.cubicTo( - size.width * 0.3356306, - size.height * 0.3748485, - size.width * 0.3423423, - size.height * 0.3906061, - size.width * 0.3423423, - size.height * 0.4259091); - path_0.cubicTo( - size.width * 0.3423423, - size.height * 0.4612121, - size.width * 0.3333333, - size.height * 0.4830303, - size.width * 0.3194144, - size.height * 0.4830303); - path_0.arcToPoint(Offset(size.width * 0.2820270, size.height * 0.4336364), - radius: - Radius.elliptical(size.width * 0.05558559, size.height * 0.1869697), - rotation: 0, - largeArc: false, - clockwise: true); - path_0.lineTo(size.width * 0.2718919, size.height * 0.4743939); - path_0.arcToPoint(Offset(size.width * 0.3188288, size.height * 0.5327273), - radius: - Radius.elliptical(size.width * 0.07180180, size.height * 0.2415152), - rotation: 0, - largeArc: false, - clockwise: false); - path_0.cubicTo( - size.width * 0.3435135, - size.height * 0.5327273, - size.width * 0.3593694, - size.height * 0.4880303, - size.width * 0.3593694, - size.height * 0.4189394); - path_0.cubicTo( - size.width * 0.3592342, - size.height * 0.3604545, - size.width * 0.3489640, - size.height * 0.3290909, - size.width * 0.3224775, - size.height * 0.3075758); - path_0.close(); - path_0.moveTo(size.width * 0.8815315, size.height * 0.1396970); - path_0.lineTo(size.width * 0.8468919, size.height * 0.3215152); - path_0.lineTo(size.width * 0.8124775, size.height * 0.1396970); - path_0.lineTo(size.width * 0.7923874, size.height * 0.1396970); - path_0.lineTo(size.width * 0.8378378, size.height * 0.3737879); - path_0.lineTo(size.width * 0.8378378, size.height * 0.5269697); - path_0.lineTo(size.width * 0.8551351, size.height * 0.5269697); - path_0.lineTo(size.width * 0.8551351, size.height * 0.3719697); - path_0.lineTo(size.width * 0.9009009, size.height * 0.1396970); - path_0.close(); - path_0.moveTo(size.width * 0.5904054, size.height * 0.1921212); - path_0.lineTo(size.width * 0.6281081, size.height * 0.1921212); - path_0.lineTo(size.width * 0.6281081, size.height * 0.5269697); - path_0.lineTo(size.width * 0.6454054, size.height * 0.5269697); - path_0.lineTo(size.width * 0.6454054, size.height * 0.1921212); - path_0.lineTo(size.width * 0.6831081, size.height * 0.1921212); - path_0.lineTo(size.width * 0.6831081, size.height * 0.1396970); - path_0.lineTo(size.width * 0.5904505, size.height * 0.1396970); - path_0.close(); - path_0.moveTo(size.width * 0.7631081, size.height * 0.3757576); - path_0.cubicTo( - size.width * 0.7804955, - size.height * 0.3595455, - size.width * 0.7901351, - size.height * 0.3186364, - size.width * 0.7901351, - size.height * 0.2601515); - path_0.cubicTo( - size.width * 0.7901351, - size.height * 0.1857576, - size.width * 0.7739640, - size.height * 0.1389394, - size.width * 0.7478829, - size.height * 0.1389394); - path_0.lineTo(size.width * 0.6967117, size.height * 0.1389394); - path_0.lineTo(size.width * 0.6967117, size.height * 0.5266667); - path_0.lineTo(size.width * 0.7138288, size.height * 0.5266667); - path_0.lineTo(size.width * 0.7138288, size.height * 0.3875758); - path_0.lineTo(size.width * 0.7428829, size.height * 0.3875758); - path_0.lineTo(size.width * 0.7720721, size.height * 0.5269697); - path_0.lineTo(size.width * 0.7920721, size.height * 0.5269697); - path_0.lineTo(size.width * 0.7605405, size.height * 0.3781818); - path_0.close(); - path_0.moveTo(size.width * 0.7137838, size.height * 0.3378788); - path_0.lineTo(size.width * 0.7137838, size.height * 0.1909091); - path_0.lineTo(size.width * 0.7460811, size.height * 0.1909091); - path_0.cubicTo( - size.width * 0.7629279, - size.height * 0.1909091, - size.width * 0.7725676, - size.height * 0.2177273, - size.width * 0.7725676, - size.height * 0.2642424); - path_0.cubicTo( - size.width * 0.7725676, - size.height * 0.3107576, - size.width * 0.7622523, - size.height * 0.3378788, - size.width * 0.7462613, - size.height * 0.3378788); - path_0.close(); - - final paint0Fill = Paint()..style = PaintingStyle.fill; - paint0Fill.color = color; - canvas.drawPath(path_0, paint0Fill); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) { - return true; - } -} diff --git a/flutter/lib/sentry_flutter.dart b/flutter/lib/sentry_flutter.dart index c74013e81e..d1ee9c080c 100644 --- a/flutter/lib/sentry_flutter.dart +++ b/flutter/lib/sentry_flutter.dart @@ -18,3 +18,4 @@ export 'src/user_interaction/sentry_user_interaction_widget.dart'; export 'src/binding_wrapper.dart'; export 'src/sentry_widget.dart'; export 'src/navigation/sentry_display_widget.dart'; +export 'src/feedback/sentry_feedback_widget.dart'; diff --git a/flutter/lib/src/feedback/sentry_feedback_widget.dart b/flutter/lib/src/feedback/sentry_feedback_widget.dart new file mode 100644 index 0000000000..3112bb8cee --- /dev/null +++ b/flutter/lib/src/feedback/sentry_feedback_widget.dart @@ -0,0 +1,252 @@ +// ignore_for_file: library_private_types_in_public_api + +import 'package:flutter/material.dart'; +import '../../sentry_flutter.dart'; + +class SentryFeedbackWidget extends StatefulWidget { + SentryFeedbackWidget({ + super.key, + this.associatedEventId, + Hub? hub, + this.title = 'Report a Bug', + this.nameLabel = 'Name', + this.namePlaceholder = 'Your Name', + this.emailLabel = 'Email', + this.emailPlaceholder = 'your.email@example.org', + this.messageLabel = 'Description', + this.messagePlaceholder = 'What\'s the bug? What did you expect?', + this.submitButtonLabel = 'Send Bug Report', + this.cancelButtonLabel = 'Cancel', + this.validationErrorLabel = 'Can\'t be empty', + this.isRequiredLabel = '(required)', + this.isNameRequired = false, + this.isEmailRequired = false, + }) : assert(associatedEventId != const SentryId.empty()), + _hub = hub ?? HubAdapter(); + + final SentryId? associatedEventId; + final Hub _hub; + + final String title; + + final String nameLabel; + final String namePlaceholder; + final String emailLabel; + final String emailPlaceholder; + final String messageLabel; + final String messagePlaceholder; + + final String submitButtonLabel; + final String cancelButtonLabel; + final String validationErrorLabel; + + final String isRequiredLabel; + + final bool isNameRequired; + final bool isEmailRequired; + + @override + _SentryFeedbackWidgetState createState() => _SentryFeedbackWidgetState(); +} + +class _SentryFeedbackWidgetState extends State { + final TextEditingController _nameController = TextEditingController(); + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _messageController = TextEditingController(); + + final GlobalKey _formKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Text( + key: const ValueKey('sentry_feedback_name_label'), + widget.nameLabel, + style: Theme.of(context).textTheme.labelMedium, + ), + const SizedBox(width: 4), + if (widget.isNameRequired) + Text( + key: const ValueKey( + 'sentry_feedback_name_required_label'), + widget.isRequiredLabel, + style: Theme.of(context).textTheme.labelMedium, + ), + ], + ), + const SizedBox(height: 4), + TextFormField( + key: const ValueKey('sentry_feedback_name_textfield'), + style: Theme.of(context).textTheme.bodyLarge, + controller: _nameController, + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: widget.namePlaceholder, + ), + keyboardType: TextInputType.text, + validator: (String? value) { + return _errorText(value, widget.isNameRequired); + }, + autovalidateMode: AutovalidateMode.onUserInteraction, + ), + const SizedBox(height: 16), + Row( + children: [ + Text( + key: const ValueKey('sentry_feedback_email_label'), + widget.emailLabel, + style: Theme.of(context).textTheme.labelMedium, + ), + const SizedBox(width: 4), + if (widget.isEmailRequired) + Text( + key: const ValueKey( + 'sentry_feedback_email_required_label'), + widget.isRequiredLabel, + style: Theme.of(context).textTheme.labelMedium, + ), + ], + ), + const SizedBox(height: 4), + TextFormField( + key: const ValueKey('sentry_feedback_email_textfield'), + controller: _emailController, + style: Theme.of(context).textTheme.bodyLarge, + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: widget.emailPlaceholder, + ), + keyboardType: TextInputType.emailAddress, + validator: (String? value) { + return _errorText(value, widget.isEmailRequired); + }, + autovalidateMode: AutovalidateMode.onUserInteraction, + ), + const SizedBox(height: 16), + Row( + children: [ + Text( + key: + const ValueKey('sentry_feedback_message_label'), + widget.messageLabel, + style: Theme.of(context).textTheme.labelMedium, + ), + const SizedBox(width: 4), + Text( + key: const ValueKey( + 'sentry_feedback_message_required_label'), + widget.isRequiredLabel, + style: Theme.of(context).textTheme.labelMedium, + ), + ], + ), + const SizedBox(height: 4), + TextFormField( + key: + const ValueKey('sentry_feedback_message_textfield'), + controller: _messageController, + style: Theme.of(context).textTheme.bodyLarge, + minLines: 5, + maxLines: null, + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: widget.messagePlaceholder, + ), + keyboardType: TextInputType.multiline, + validator: (String? value) { + return _errorText(value, true); + }, + autovalidateMode: AutovalidateMode.onUserInteraction, + ), + ], + ), + ), + ), + ), + const SizedBox(height: 8), + Column( + children: [ + SizedBox( + width: double.infinity, + child: ElevatedButton( + key: const ValueKey('sentry_feedback_submit_button'), + onPressed: () async { + if (!_formKey.currentState!.validate()) { + return; + } + final feedback = SentryFeedback( + message: _messageController.text, + contactEmail: _emailController.text, + name: _nameController.text, + associatedEventId: widget.associatedEventId, + ); + await _captureFeedback(feedback); + + bool mounted; + try { + mounted = (this as dynamic).mounted as bool; + } on NoSuchMethodError catch (_) { + mounted = false; + } + if (mounted) { + // ignore: use_build_context_synchronously + await Navigator.maybePop(context); + } + }, + child: Text(widget.submitButtonLabel), + ), + ), + SizedBox( + width: double.infinity, + child: TextButton( + key: const ValueKey('sentry_feedback_close_button'), + onPressed: () { + Navigator.pop(context); + }, + child: Text(widget.cancelButtonLabel), + ), + ), + ], + ), + ], + ), + ), + ); + } + + @override + void dispose() { + _nameController.dispose(); + _emailController.dispose(); + _messageController.dispose(); + super.dispose(); + } + + String? _errorText(String? value, bool isRequired) { + if (isRequired && (value == null || value.isEmpty)) { + return widget.validationErrorLabel; + } + return null; + } + + Future _captureFeedback(SentryFeedback feedback) { + return widget._hub.captureFeedback(feedback); + } +} diff --git a/flutter/test/feedback/sentry_feedback_widget_test.dart b/flutter/test/feedback/sentry_feedback_widget_test.dart new file mode 100644 index 0000000000..668b4e247e --- /dev/null +++ b/flutter/test/feedback/sentry_feedback_widget_test.dart @@ -0,0 +1,205 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +import '../mocks.mocks.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('$SentryFeedbackWidget validation', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + testWidgets('does not call hub on submit if not valid', (tester) async { + await fixture.pumpFeedbackWidget( + tester, + (hub) => SentryFeedbackWidget(hub: hub), + ); + + await tester.tap(find.text('Send Bug Report')); + await tester.pumpAndSettle(); + + verifyNever( + fixture.hub.captureFeedback( + captureAny, + hint: anyNamed('hint'), + withScope: anyNamed('withScope'), + ), + ); + }); + + testWidgets('shows error on submit if message not valid', (tester) async { + await fixture.pumpFeedbackWidget( + tester, + (hub) => SentryFeedbackWidget(hub: hub), + ); + + await tester.tap(find.text('Send Bug Report')); + await tester.pumpAndSettle(); + + expect(find.text('Can\'t be empty'), findsOne); + expect(find.text('(required)'), findsOne); + }); + + testWidgets('shows error on submit if name not valid', (tester) async { + await fixture.pumpFeedbackWidget( + tester, + (hub) => SentryFeedbackWidget( + hub: hub, + isNameRequired: true, + ), + ); + + await tester.tap(find.text('Send Bug Report')); + await tester.pumpAndSettle(); + + expect(find.text('Can\'t be empty'), findsExactly(2)); + expect(find.text('(required)'), findsExactly(2)); + }); + + testWidgets('shows error on submit if email not valid', (tester) async { + await fixture.pumpFeedbackWidget( + tester, + (hub) => SentryFeedbackWidget( + hub: hub, + isEmailRequired: true, + ), + ); + + await tester.tap(find.text('Send Bug Report')); + await tester.pumpAndSettle(); + + expect(find.text('Can\'t be empty'), findsExactly(2)); + expect(find.text('(required)'), findsExactly(2)); + }); + + testWidgets('shows error on submit if name and email not valid', + (tester) async { + await fixture.pumpFeedbackWidget( + tester, + (hub) => SentryFeedbackWidget( + hub: hub, + isNameRequired: true, + isEmailRequired: true, + ), + ); + + await tester.tap(find.text('Send Bug Report')); + await tester.pumpAndSettle(); + + expect(find.text('Can\'t be empty'), findsExactly(3)); + expect(find.text('(required)'), findsExactly(3)); + }); + }); + + group('$SentryFeedbackWidget submit', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + testWidgets('does call hub captureFeedback on submit', (tester) async { + await fixture.pumpFeedbackWidget( + tester, + (hub) => SentryFeedbackWidget( + hub: hub, + associatedEventId: + SentryId.fromId('1988bb1b6f0d4c509e232f0cb9aaeaea'), + ), + ); + + when(fixture.hub.captureFeedback( + any, + hint: anyNamed('hint'), + withScope: anyNamed('withScope'), + )).thenAnswer( + (_) async => SentryId.fromId('1988bb1b6f0d4c509e232f0cb9aaeaea')); + + await tester.enterText( + find.byKey(ValueKey('sentry_feedback_name_textfield')), + "fixture-name"); + await tester.enterText( + find.byKey(ValueKey('sentry_feedback_email_textfield')), + "fixture-email"); + await tester.enterText( + find.byKey(ValueKey('sentry_feedback_message_textfield')), + "fixture-message"); + await tester.tap(find.text('Send Bug Report')); + await tester.pumpAndSettle(); + + verify(fixture.hub.captureFeedback( + argThat(predicate((feedback) => + feedback.name == 'fixture-name' && + feedback.contactEmail == 'fixture-email' && + feedback.message == 'fixture-message' && + feedback.associatedEventId == + SentryId.fromId('1988bb1b6f0d4c509e232f0cb9aaeaea'))), + hint: anyNamed('hint'), + withScope: anyNamed('withScope'), + )).called(1); + }); + }); + + group('$SentryFeedbackWidget localization', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + testWidgets('sets labels and hints from parameters', (tester) async { + await fixture.pumpFeedbackWidget( + tester, + (hub) => SentryFeedbackWidget( + hub: hub, + title: 'fixture-title', + nameLabel: 'fixture-nameLabel', + namePlaceholder: 'fixture-namePlaceholder', + emailLabel: 'fixture-emailLabel', + emailPlaceholder: 'fixture-emailPlaceholder', + messageLabel: 'fixture-messageLabel', + messagePlaceholder: 'fixture-messagePlaceholder', + submitButtonLabel: 'fixture-submitButtonLabel', + cancelButtonLabel: 'fixture-cancelButtonLabel', + isRequiredLabel: 'fixture-isRequiredLabel', + validationErrorLabel: 'fixture-validationErrorLabel', + ), + ); + + expect(find.text('fixture-title'), findsOne); + expect(find.text('fixture-nameLabel'), findsOne); + expect(find.text('fixture-namePlaceholder'), findsOne); + expect(find.text('fixture-emailLabel'), findsOne); + expect(find.text('fixture-emailPlaceholder'), findsOne); + expect(find.text('fixture-messageLabel'), findsOne); + expect(find.text('fixture-messagePlaceholder'), findsOne); + expect(find.text('fixture-submitButtonLabel'), findsOne); + expect(find.text('fixture-cancelButtonLabel'), findsOne); + expect(find.text('fixture-isRequiredLabel'), findsOne); + + await tester.tap(find.text('fixture-submitButtonLabel')); + await tester.pumpAndSettle(); + + expect(find.text('fixture-validationErrorLabel'), findsOne); + }); + }); +} + +class Fixture { + var hub = MockHub(); + + Future pumpFeedbackWidget( + WidgetTester tester, Widget Function(Hub) builder) async { + await tester.pumpWidget( + MaterialApp( + home: builder(hub), + ), + ); + } +} diff --git a/flutter/test/mocks.mocks.dart b/flutter/test/mocks.mocks.dart index 3f94ca0274..f2cf885b18 100644 --- a/flutter/test/mocks.mocks.dart +++ b/flutter/test/mocks.mocks.dart @@ -1172,6 +1172,34 @@ class MockSentryClient extends _i1.Mock implements _i2.SentryClient { returnValueForMissingStub: _i7.Future.value(), ) as _i7.Future); + @override + _i7.Future<_i2.SentryId> captureFeedback( + _i2.SentryFeedback? feedback, { + _i2.Scope? scope, + _i2.Hint? hint, + }) => + (super.noSuchMethod( + Invocation.method( + #captureFeedback, + [feedback], + { + #scope: scope, + #hint: hint, + }, + ), + returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( + this, + Invocation.method( + #captureFeedback, + [feedback], + { + #scope: scope, + #hint: hint, + }, + ), + )), + ) as _i7.Future<_i2.SentryId>); + @override _i7.Future<_i2.SentryId> captureMetrics( Map>? metricsBuckets) => @@ -1759,6 +1787,34 @@ class MockHub extends _i1.Mock implements _i2.Hub { returnValueForMissingStub: _i7.Future.value(), ) as _i7.Future); + @override + _i7.Future<_i2.SentryId> captureFeedback( + _i2.SentryFeedback? feedback, { + _i2.Hint? hint, + _i2.ScopeCallback? withScope, + }) => + (super.noSuchMethod( + Invocation.method( + #captureFeedback, + [feedback], + { + #hint: hint, + #withScope: withScope, + }, + ), + returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( + this, + Invocation.method( + #captureFeedback, + [feedback], + { + #hint: hint, + #withScope: withScope, + }, + ), + )), + ) as _i7.Future<_i2.SentryId>); + @override _i7.Future addBreadcrumb( _i2.Breadcrumb? crumb, {