diff --git a/CHANGELOG.md b/CHANGELOG.md index 149a454b70..7eae082f6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Unreleased * Feat: Add support for attachments (#505) +* Feat: Add support for User Feedback (#506) # 6.0.0-beta.1 diff --git a/dart/lib/sentry.dart b/dart/lib/sentry.dart index 439bf21587..a408f0b101 100644 --- a/dart/lib/sentry.dart +++ b/dart/lib/sentry.dart @@ -23,3 +23,4 @@ export 'src/integration.dart'; export 'src/event_processor.dart'; export 'src/http_client/sentry_http_client.dart'; export 'src/sentry_attachment/sentry_attachment.dart'; +export 'src/sentry_user_feedback.dart'; diff --git a/dart/lib/src/hub.dart b/dart/lib/src/hub.dart index ce649ab168..73f4919673 100644 --- a/dart/lib/src/hub.dart +++ b/dart/lib/src/hub.dart @@ -5,6 +5,7 @@ import 'protocol.dart'; import 'scope.dart'; import 'sentry_client.dart'; import 'sentry_options.dart'; +import 'sentry_user_feedback.dart'; /// Configures the scope through the callback. typedef ScopeCallback = void Function(Scope); @@ -182,6 +183,35 @@ class Hub { return sentryId; } + Future captureUserFeedback(SentryUserFeedback userFeedback) async { + if (!_isEnabled) { + _options.logger( + SentryLevel.warning, + "Instance is disabled and this 'captureUserFeedback' call is a no-op.", + ); + return; + } + if (userFeedback.eventId == SentryId.empty()) { + _options.logger( + SentryLevel.warning, + 'Captured UserFeedback with empty id, dropping the feedback', + ); + return; + } + try { + final item = _peek(); + + await item.client.captureUserFeedback(userFeedback); + } catch (exception, stacktrace) { + _options.logger( + SentryLevel.error, + 'Error while capturing user feedback for ${userFeedback.eventId}', + exception: exception, + stackTrace: stacktrace, + ); + } + } + Scope _cloneAndRunWithScope(Scope scope, ScopeCallback? withScope) { if (withScope != null) { scope = scope.clone(); diff --git a/dart/lib/src/hub_adapter.dart b/dart/lib/src/hub_adapter.dart index ddfdc3f054..3626097205 100644 --- a/dart/lib/src/hub_adapter.dart +++ b/dart/lib/src/hub_adapter.dart @@ -1,13 +1,13 @@ import 'dart:async'; - import 'hub.dart'; import 'protocol.dart'; import 'sentry.dart'; import 'sentry_client.dart'; +import 'sentry_user_feedback.dart'; /// Hub adapter to make Integrations testable class HubAdapter implements Hub { - HubAdapter._(); + const HubAdapter._(); static final HubAdapter _instance = HubAdapter._(); @@ -82,4 +82,8 @@ class HubAdapter implements Hub { @override SentryId get lastEventId => Sentry.lastEventId; + + @override + Future captureUserFeedback(SentryUserFeedback userFeedback) => + Sentry.captureUserFeedback(userFeedback); } diff --git a/dart/lib/src/noop_hub.dart b/dart/lib/src/noop_hub.dart index b1520f672d..6c0bf0d3aa 100644 --- a/dart/lib/src/noop_hub.dart +++ b/dart/lib/src/noop_hub.dart @@ -1,8 +1,8 @@ import 'dart:async'; - import 'hub.dart'; import 'protocol.dart'; import 'sentry_client.dart'; +import 'sentry_user_feedback.dart'; class NoOpHub implements Hub { NoOpHub._(); @@ -62,4 +62,9 @@ class NoOpHub implements Hub { @override void addBreadcrumb(Breadcrumb crumb, {dynamic hint}) {} + + @override + Future captureUserFeedback(SentryUserFeedback userFeedback) async { + return SentryId.empty(); + } } diff --git a/dart/lib/src/noop_sentry_client.dart b/dart/lib/src/noop_sentry_client.dart index 40eb864597..e8d70bb9e9 100644 --- a/dart/lib/src/noop_sentry_client.dart +++ b/dart/lib/src/noop_sentry_client.dart @@ -4,6 +4,7 @@ import 'protocol.dart'; import 'scope.dart'; import 'sentry_client.dart'; import 'sentry_envelope.dart'; +import 'sentry_user_feedback.dart'; class NoOpSentryClient implements SentryClient { NoOpSentryClient._(); @@ -47,6 +48,10 @@ class NoOpSentryClient implements SentryClient { Future captureEnvelope(SentryEnvelope envelope) => Future.value(SentryId.empty()); + @override + Future captureUserFeedback(SentryUserFeedback userFeedback) => + Future.value(SentryId.empty()); + @override Future close() async { return; diff --git a/dart/lib/src/sentry.dart b/dart/lib/src/sentry.dart index 1522ec9bf4..4cd269183a 100644 --- a/dart/lib/src/sentry.dart +++ b/dart/lib/src/sentry.dart @@ -13,6 +13,7 @@ import 'protocol.dart'; import 'sentry_client.dart'; import 'sentry_options.dart'; import 'integration.dart'; +import 'sentry_user_feedback.dart'; /// Configuration options callback typedef OptionsConfiguration = FutureOr Function(SentryOptions); @@ -176,6 +177,9 @@ class Sentry { withScope: withScope, ); + static Future captureUserFeedback(SentryUserFeedback userFeedback) => + _hub.captureUserFeedback(userFeedback); + /// Close the client SDK static Future close() async { final hub = _hub; diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index a901ebc8a4..9b7d168e63 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:math'; - import 'event_processor.dart'; +import 'sentry_user_feedback.dart'; import 'transport/rate_limiter.dart'; import 'protocol.dart'; import 'scope.dart'; @@ -211,6 +211,15 @@ class SentryClient { return _options.transport.send(envelope); } + /// Reports the [userFeedback] to Sentry.io. + Future captureUserFeedback(SentryUserFeedback userFeedback) { + final envelope = SentryEnvelope.fromUserFeedback( + userFeedback, + _options.sdk, + ); + return _options.transport.send(envelope); + } + void close() => _options.httpClient.close(); Future _processEvent( diff --git a/dart/lib/src/sentry_envelope.dart b/dart/lib/src/sentry_envelope.dart index 6de2f69a4b..d93e2116e8 100644 --- a/dart/lib/src/sentry_envelope.dart +++ b/dart/lib/src/sentry_envelope.dart @@ -5,6 +5,7 @@ import 'sentry_envelope_header.dart'; import 'sentry_envelope_item.dart'; import 'protocol/sentry_event.dart'; import 'protocol/sdk_version.dart'; +import 'sentry_user_feedback.dart'; /// Class representation of `Envelope` file. class SentryEnvelope { @@ -32,6 +33,16 @@ class SentryEnvelope { ); } + factory SentryEnvelope.fromUserFeedback( + SentryUserFeedback feedback, + SdkVersion sdkVersion, + ) { + return SentryEnvelope( + SentryEnvelopeHeader(feedback.eventId, sdkVersion), + [SentryEnvelopeItem.fromUserFeedback(feedback)], + ); + } + /// Stream binary data representation of `Envelope` file encoded. Stream> envelopeStream() async* { yield utf8.encode(jsonEncode(header.toJson())); diff --git a/dart/lib/src/sentry_envelope_item.dart b/dart/lib/src/sentry_envelope_item.dart index f33ac682c1..9ce077b220 100644 --- a/dart/lib/src/sentry_envelope_item.dart +++ b/dart/lib/src/sentry_envelope_item.dart @@ -4,6 +4,7 @@ import 'sentry_attachment/sentry_attachment.dart'; import 'sentry_item_type.dart'; import 'protocol/sentry_event.dart'; import 'sentry_envelope_item_header.dart'; +import 'sentry_user_feedback.dart'; /// Item holding header information and JSON encoded data. class SentryEnvelopeItem { @@ -26,6 +27,25 @@ class SentryEnvelopeItem { return SentryEnvelopeItem(header, cachedItem.getData); } + /// Create an [SentryEnvelopeItem] which sends [SentryUserFeedback]. + factory SentryEnvelopeItem.fromUserFeedback(SentryUserFeedback feedback) { + final cachedItem = _CachedItem(() async { + final jsonEncoded = jsonEncode(feedback.toJson()); + return utf8.encode(jsonEncoded); + }); + + final getLength = () async { + return (await cachedItem.getData()).length; + }; + + final header = SentryEnvelopeItemHeader( + SentryItemType.userFeedback, + getLength, + contentType: 'application/json', + ); + return SentryEnvelopeItem(header, cachedItem.getData); + } + /// Create an [SentryEnvelopeItem] which holds the [SentryEvent] data. factory SentryEnvelopeItem.fromEvent(SentryEvent event) { final cachedItem = _CachedItem(() async { diff --git a/dart/lib/src/sentry_user_feedback.dart b/dart/lib/src/sentry_user_feedback.dart new file mode 100644 index 0000000000..55074bde3e --- /dev/null +++ b/dart/lib/src/sentry_user_feedback.dart @@ -0,0 +1,54 @@ +import 'protocol.dart'; + +class SentryUserFeedback { + SentryUserFeedback({ + required this.eventId, + this.name, + this.email, + this.comments, + }) : assert(eventId != SentryId.empty()); + + factory SentryUserFeedback.fromJson(Map json) { + return SentryUserFeedback( + eventId: SentryId.fromId(json['event_id']), + name: json['name'], + email: json['email'], + comments: json['comments'], + ); + } + + /// The eventId of the event to which the user feedback is associated. + final SentryId eventId; + + /// Recommended: The name of the user. + final String? name; + + /// Recommended: The name of the user. + final String? email; + + /// Recommended: Comments of the user about what happened. + final String? comments; + + Map toJson() { + return { + 'event_id': eventId.toString(), + if (name != null) 'name': name, + if (email != null) 'email': email, + if (comments != null) 'comments': comments, + }; + } + + SentryUserFeedback copyWith({ + SentryId? eventId, + String? name, + String? email, + String? comments, + }) { + return SentryUserFeedback( + eventId: eventId ?? this.eventId, + name: name ?? this.name, + email: email ?? this.email, + comments: comments ?? this.comments, + ); + } +} diff --git a/dart/test/mocks/mock_hub.dart b/dart/test/mocks/mock_hub.dart index ae6148c023..068843281e 100644 --- a/dart/test/mocks/mock_hub.dart +++ b/dart/test/mocks/mock_hub.dart @@ -1,4 +1,5 @@ import 'package:sentry/sentry.dart'; +import 'package:sentry/src/sentry_user_feedback.dart'; class MockHub implements Hub { List captureEventCalls = []; @@ -6,6 +7,7 @@ class MockHub implements Hub { List captureMessageCalls = []; List addBreadcrumbCalls = []; List bindClientCalls = []; + List userFeedbackCalls = []; int closeCalls = 0; bool _isEnabled = true; @@ -102,6 +104,12 @@ class MockHub implements Hub { @override // TODO: implement lastEventId SentryId get lastEventId => throw UnimplementedError(); + + @override + Future captureUserFeedback(SentryUserFeedback userFeedback) async { + userFeedbackCalls.add(userFeedback); + return SentryId.empty(); + } } class CaptureEventCall { diff --git a/dart/test/mocks/mock_sentry_client.dart b/dart/test/mocks/mock_sentry_client.dart index 9b6f4175fe..6f3ba3eb4f 100644 --- a/dart/test/mocks/mock_sentry_client.dart +++ b/dart/test/mocks/mock_sentry_client.dart @@ -6,6 +6,7 @@ class MockSentryClient implements SentryClient { List captureExceptionCalls = []; List captureMessageCalls = []; List captureEnvelopeCalls = []; + List userFeedbackCalls = []; int closeCalls = 0; @override @@ -66,6 +67,12 @@ class MockSentryClient implements SentryClient { return SentryId.newId(); } + @override + Future captureUserFeedback(SentryUserFeedback userFeedback) async { + userFeedbackCalls.add(userFeedback); + return SentryId.newId(); + } + @override void close() { closeCalls = closeCalls + 1; diff --git a/dart/test/mocks/mock_transport.dart b/dart/test/mocks/mock_transport.dart index 64ed6902c5..b633639c30 100644 --- a/dart/test/mocks/mock_transport.dart +++ b/dart/test/mocks/mock_transport.dart @@ -13,3 +13,10 @@ class MockTransport implements Transport { return envelope.header.eventId ?? SentryId.empty(); } } + +class ThrowingTransport implements Transport { + @override + Future send(SentryEnvelope envelope) async { + throw Exception('foo bar'); + } +} diff --git a/dart/test/sentry_user_feedback_test.dart b/dart/test/sentry_user_feedback_test.dart new file mode 100644 index 0000000000..700e94e35d --- /dev/null +++ b/dart/test/sentry_user_feedback_test.dart @@ -0,0 +1,209 @@ +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/sentry_item_type.dart'; +import 'package:test/test.dart'; + +import 'mocks.dart'; +import 'mocks/mock_transport.dart'; + +void main() { + group('$SentryUserFeedback', () { + test('toJson', () { + final id = SentryId.newId(); + final feedback = SentryUserFeedback( + eventId: id, + comments: 'this is awesome', + email: 'sentry@example.com', + name: 'Rockstar Developer', + ); + expect(feedback.toJson(), { + 'event_id': id.toString(), + 'comments': 'this is awesome', + 'email': 'sentry@example.com', + 'name': 'Rockstar Developer', + }); + }); + + test('fromJson', () { + final id = SentryId.newId(); + final feedback = SentryUserFeedback.fromJson({ + 'event_id': id.toString(), + 'comments': 'this is awesome', + 'email': 'sentry@example.com', + 'name': 'Rockstar Developer', + }); + + expect(feedback.eventId.toString(), id.toString()); + expect(feedback.comments, 'this is awesome'); + expect(feedback.email, 'sentry@example.com'); + expect(feedback.name, 'Rockstar Developer'); + }); + + test('copyWith', () { + final id = SentryId.newId(); + final feedback = SentryUserFeedback( + eventId: id, + comments: 'this is awesome', + email: 'sentry@example.com', + name: 'Rockstar Developer', + ); + + final copyId = SentryId.newId(); + final copy = feedback.copyWith( + eventId: copyId, + comments: 'actually it is not', + email: 'example@example.com', + name: '10x developer', + ); + + expect(copy.eventId.toString(), copyId.toString()); + expect(copy.comments, 'actually it is not'); + expect(copy.email, 'example@example.com'); + expect(copy.name, '10x developer'); + }); + + test('disallow empty id', () { + final id = SentryId.empty(); + expect(() => SentryUserFeedback(eventId: id), + throwsA(isA())); + }); + }); + + group('$SentryUserFeedback to envelops', () { + test('to envelope', () { + final feedback = SentryUserFeedback(eventId: SentryId.newId()); + final envelope = SentryEnvelope.fromUserFeedback( + feedback, + SdkVersion(name: 'a', version: 'b'), + ); + + expect(envelope.items.length, 1); + expect( + envelope.items.first.header.type, + SentryItemType.userFeedback, + ); + expect(envelope.header.eventId.toString(), feedback.eventId.toString()); + }); + }); + + test('sending $SentryUserFeedback', () async { + final fixture = Fixture(); + final sut = fixture.getSut(); + await sut + .captureUserFeedback(SentryUserFeedback(eventId: SentryId.newId())); + + expect(fixture.transport.envelopes.length, 1); + }); + + test('cannot create $SentryUserFeedback with empty id', () async { + expect( + () => SentryUserFeedback(eventId: const SentryId.empty()), + throwsA(isA()), + ); + }); + + test('do not send $SentryUserFeedback when disabled', () async { + final fixture = Fixture(); + final sut = fixture.getSut(); + await sut.close(); + await sut.captureUserFeedback( + SentryUserFeedback(eventId: SentryId.newId()), + ); + + expect(fixture.transport.envelopes.length, 0); + }); + + test('do not send $SentryUserFeedback with empty id', () async { + final fixture = Fixture(); + final sut = fixture.getSut(); + await sut.close(); + await sut.captureUserFeedback( + SentryUserFeedbackWithoutAssert( + eventId: SentryId.empty(), + ), + ); + + expect(fixture.transport.envelopes.length, 0); + }); + + test('captureUserFeedback does not throw', () async { + final options = SentryOptions(dsn: fakeDsn); + final transport = ThrowingTransport(); + options.transport = transport; + final sut = Hub(options); + + await expectLater(() async { + await sut.captureUserFeedback( + SentryUserFeedback(eventId: SentryId.newId()), + ); + }, returnsNormally); + }); +} + +class Fixture { + late MockTransport transport; + + Hub getSut() { + final options = SentryOptions(dsn: fakeDsn); + transport = MockTransport(); + options.transport = transport; + return Hub(options); + } +} + +// You cannot create an instance of SentryUserFeedback with an empty id. +// In order to test that UserFeedback with an empty id is not sent +// we need to implement it and remove the assert. +class SentryUserFeedbackWithoutAssert implements SentryUserFeedback { + SentryUserFeedbackWithoutAssert({ + required this.eventId, + this.name, + this.email, + this.comments, + }); + + factory SentryUserFeedbackWithoutAssert.fromJson(Map json) { + return SentryUserFeedbackWithoutAssert( + eventId: SentryId.fromId(json['event_id']), + name: json['name'], + email: json['email'], + comments: json['comments'], + ); + } + + @override + final SentryId eventId; + + @override + final String? name; + + @override + final String? email; + + @override + final String? comments; + + @override + Map toJson() { + return { + 'event_id': eventId.toString(), + if (name != null) 'name': name, + if (email != null) 'email': email, + if (comments != null) 'comments': comments, + }; + } + + @override + SentryUserFeedback copyWith({ + SentryId? eventId, + String? name, + String? email, + String? comments, + }) { + return SentryUserFeedback( + eventId: eventId ?? this.eventId, + name: name ?? this.name, + email: email ?? this.email, + comments: comments ?? this.comments, + ); + } +} diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index daf855ef0d..8abb7580c1 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -7,6 +7,8 @@ import 'package:flutter/services.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:universal_platform/universal_platform.dart'; import 'package:feedback/feedback.dart' as feedback; +import 'package:provider/provider.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 = @@ -36,11 +38,17 @@ class _MyAppState extends State { @override Widget build(BuildContext context) { return feedback.BetterFeedback( - child: MaterialApp( - navigatorObservers: [ - SentryNavigatorObserver(), - ], - home: const MainScaffold(), + child: ChangeNotifierProvider( + create: (_) => ThemeProvider(), + child: Builder( + builder: (context) => MaterialApp( + navigatorObservers: [ + SentryNavigatorObserver(), + ], + theme: Provider.of(context).theme, + home: const MainScaffold(), + ), + ), ), ); } @@ -53,8 +61,37 @@ class MainScaffold extends StatelessWidget { @override Widget build(BuildContext context) { + final themeProvider = Provider.of(context); + var icon = Icons.light_mode; + var theme = ThemeData.light(); + if (themeProvider.theme.brightness == Brightness.light) { + icon = Icons.dark_mode; + theme = ThemeData.dark(); + } return Scaffold( - appBar: AppBar(title: const Text('Sentry Flutter Example')), + appBar: AppBar( + title: const Text('Sentry Flutter Example'), + actions: [ + IconButton( + onPressed: () { + themeProvider.theme = theme; + }, + icon: Icon(icon), + ), + IconButton( + onPressed: () { + themeProvider.updatePrimatryColor(Colors.orange); + }, + icon: Icon(Icons.circle, color: Colors.orange), + ), + IconButton( + onPressed: () { + themeProvider.updatePrimatryColor(Colors.green); + }, + icon: Icon(Icons.circle, color: Colors.lime), + ), + ], + ), body: SingleChildScrollView( child: Column( children: [ @@ -176,6 +213,29 @@ class MainScaffold extends StatelessWidget { }); }, ), + RaisedButton( + child: const Text('Capture User Feedback'), + onPressed: () async { + final id = await Sentry.captureMessage('UserFeedback'); + await showDialog( + context: context, + builder: (context) { + return UserFeedbackDialog(eventId: id); + }, + ); + }, + ), + RaisedButton( + child: const Text('Show UserFeedback Dialog without event'), + onPressed: () async { + await showDialog( + context: context, + builder: (context) { + return UserFeedbackDialog(eventId: SentryId.newId()); + }, + ); + }, + ), if (UniversalPlatform.isIOS || UniversalPlatform.isMacOS) const CocoaExample(), if (UniversalPlatform.isAndroid) const AndroidExample(), @@ -375,3 +435,22 @@ Future makeWebRequest(BuildContext context) async { }, ); } + +class ThemeProvider extends ChangeNotifier { + ThemeData _theme = ThemeData.light(); + + ThemeData get theme => _theme; + + set theme(ThemeData theme) { + _theme = theme; + notifyListeners(); + } + + void updatePrimatryColor(MaterialColor color) { + if (theme.brightness == Brightness.light) { + theme = ThemeData(primarySwatch: color, brightness: theme.brightness); + } else { + theme = ThemeData(primarySwatch: color, brightness: theme.brightness); + } + } +} diff --git a/flutter/example/lib/user_feedback_dialog.dart b/flutter/example/lib/user_feedback_dialog.dart new file mode 100644 index 0000000000..6781f3d886 --- /dev/null +++ b/flutter/example/lib/user_feedback_dialog.dart @@ -0,0 +1,461 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import 'package:sentry_flutter/sentry_flutter.dart'; + +class UserFeedbackDialog extends StatefulWidget { + const UserFeedbackDialog({ + Key? key, + required this.eventId, + this.hub, + }) : assert(eventId != const SentryId.empty()), + super(key: key); + + 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.headline6, + ), + 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 + .subtitle1 + ?.copyWith(color: Colors.grey), + ), + Divider(height: 24), + TextField( + key: ValueKey('sentry_name_textfield'), + decoration: InputDecoration( + border: OutlineInputBorder(), + hintText: 'Name', + ), + controller: nameController, + keyboardType: TextInputType.text, + ), + SizedBox(height: 8), + TextField( + key: ValueKey('sentry_email_textfield'), + decoration: InputDecoration( + border: OutlineInputBorder(), + hintText: 'E-Mail', + ), + controller: emailController, + keyboardType: TextInputType.emailAddress, + ), + SizedBox(height: 8), + TextField( + key: ValueKey('sentry_comment_textfield'), + minLines: 5, + maxLines: null, + decoration: InputDecoration( + border: OutlineInputBorder(), + hintText: 'What happened?', + ), + controller: commentController, + keyboardType: TextInputType.multiline, + ), + SizedBox(height: 8), + const _PoweredBySentryMessage(), + ], + ), + ), + actions: [ + ElevatedButton( + key: ValueKey('sentry_submit_feedback_button'), + onPressed: () async { + final feedback = SentryUserFeedback( + eventId: widget.eventId, + comments: commentController.text, + email: emailController.text, + name: nameController.text, + ); + await _submitUserFeedback(feedback); + Navigator.pop(context); + }, + child: Text('Submit Crash Report')), + TextButton( + key: ValueKey('sentry_close_button'), + onPressed: () { + Navigator.pop(context); + }, + child: Text('Close'), + ) + ], + ); + } + + Future _submitUserFeedback(SentryUserFeedback feedback) { + return (widget.hub ?? HubAdapter()).captureUserFeedback(feedback); + } +} + +class _PoweredBySentryMessage extends StatelessWidget { + const _PoweredBySentryMessage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Crash reports powered by'), + 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 = Color(0xff362d59).withOpacity(1.0); + } + + return FittedBox( + fit: BoxFit.contain, + child: CustomPaint( + size: 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 paint_0_fill = Paint()..style = PaintingStyle.fill; + paint_0_fill.color = color; + canvas.drawPath(path_0, paint_0_fill); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return true; + } +} diff --git a/flutter/example/pubspec.yaml b/flutter/example/pubspec.yaml index 0f7bf84ad6..ddebf187cc 100644 --- a/flutter/example/pubspec.yaml +++ b/flutter/example/pubspec.yaml @@ -13,7 +13,8 @@ dependencies: sdk: flutter sentry_flutter: any universal_platform: ^1.0.0-nullsafety - feedback: 2.0.0 + feedback: ^2.0.0 + provider: ^5.0.0 dependency_overrides: sentry: diff --git a/flutter/test/mocks.dart b/flutter/test/mocks.dart index b51611172e..a1642f9399 100644 --- a/flutter/test/mocks.dart +++ b/flutter/test/mocks.dart @@ -2,6 +2,7 @@ import 'package:mockito/annotations.dart'; import 'package:sentry/sentry.dart'; import 'package:sentry/src/platform/platform.dart'; import 'package:sentry/src/platform_checker.dart'; +import 'package:sentry/src/sentry_user_feedback.dart'; const fakeDsn = 'https://abc@def.ingest.sentry.io/1234567'; @@ -157,4 +158,7 @@ class NoOpHub implements Hub { @override SentryId get lastEventId => SentryId.empty(); + + @override + Future captureUserFeedback(SentryUserFeedback userFeedback) async {} }