diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aac38ec..3c87eb29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ ## 24.11.1 * Added content configuration interface that has `setGlobalContentCallback` to get notified about content changes. * Added support for localization of content blocks. +* Added the interface `feedback` and convenience methods that presents the first available widget to user: + * presentNPS([String? nameTagOrID, FeedbackCallback? callback]) + * presentSurvey([String? nameTagOrID, FeedbackCallback? callback]) + * presentRating([String? nameTagOrID, FeedbackCallback? callback]) * Mitigated an issue where visibility could have been wrongly assigned if a view was closed while going to background. (Experimental!) * Mitigated issues where: diff --git a/android/src/main/java/ly/count/dart/countly_flutter/CountlyFlutterPlugin.java b/android/src/main/java/ly/count/dart/countly_flutter/CountlyFlutterPlugin.java index 0e0be9c0..654a2384 100644 --- a/android/src/main/java/ly/count/dart/countly_flutter/CountlyFlutterPlugin.java +++ b/android/src/main/java/ly/count/dart/countly_flutter/CountlyFlutterPlugin.java @@ -1153,7 +1153,65 @@ public void onClosed() { } }); } - } else if ("getFeedbackWidgetData".equals(call.method)) { + } else if("presentNPS".equals(call.method)){ + if (activity == null) { + log("presentNPS failed : Activity is null", LogLevel.ERROR); + methodChannel.invokeMethod("feedbackCallback_onFinished", "Activity is null"); + return; + } + String nameIDorTag = args.optString(0, ""); + + Countly.sharedInstance().feedback().presentNPS(activity, nameIDorTag, new FeedbackCallback() { + @Override + public void onFinished(String error) { + methodChannel.invokeMethod("feedbackCallback_onFinished", error); + } + + @Override + public void onClosed() { + methodChannel.invokeMethod("feedbackCallback_onClosed", null); + } + }); + } else if("presentSurvey".equals(call.method)){ + if (activity == null) { + log("presentSurvey failed : Activity is null", LogLevel.ERROR); + methodChannel.invokeMethod("feedbackCallback_onFinished", "Activity is null"); + return; + } + String nameIDorTag = args.optString(0, ""); + + Countly.sharedInstance().feedback().presentSurvey(activity, nameIDorTag, new FeedbackCallback() { + @Override + public void onFinished(String error) { + methodChannel.invokeMethod("feedbackCallback_onFinished", error); + } + + @Override + public void onClosed() { + methodChannel.invokeMethod("feedbackCallback_onClosed", null); + } + }); + } else if("presentRating".equals(call.method)){ + if (activity == null) { + log("presentRating failed : Activity is null", LogLevel.ERROR); + methodChannel.invokeMethod("feedbackCallback_onFinished", "Activity is null"); + return; + } + String nameIDorTag = args.optString(0, ""); + + Countly.sharedInstance().feedback().presentRating(activity, nameIDorTag, new FeedbackCallback() { + @Override + public void onFinished(String error) { + methodChannel.invokeMethod("feedbackCallback_onFinished", error); + } + + @Override + public void onClosed() { + methodChannel.invokeMethod("feedbackCallback_onClosed", null); + } + }); + } + else if ("getFeedbackWidgetData".equals(call.method)) { String widgetId = args.getString(0); CountlyFeedbackWidget feedbackWidget = getFeedbackWidget(widgetId); if (feedbackWidget == null) { diff --git a/example/lib/page_feedback_widgets.dart b/example/lib/page_feedback_widgets.dart index d3eeb725..c762d9fe 100644 --- a/example/lib/page_feedback_widgets.dart +++ b/example/lib/page_feedback_widgets.dart @@ -29,6 +29,7 @@ class _FeedbackWidgetsPageState extends State { super.dispose(); } + // for Countly Lite users only void askForStarRating() { Countly.askForStarRating(); } @@ -267,8 +268,38 @@ class _FeedbackWidgetsPageState extends State { }); } + void demoNPS(nameTagOrID, callback) { + if (ratingIdController.text.isNotEmpty) { + nameTagOrID = ratingIdController.text; + } + Countly.instance.feedback.presentNPS(nameTagOrID, callback); + } + + void demoSurvey(nameTagOrID, callback) { + if (ratingIdController.text.isNotEmpty) { + nameTagOrID = ratingIdController.text; + } + Countly.instance.feedback.presentSurvey(nameTagOrID, callback); + } + + void demoRating(nameTagOrID, callback) { + if (ratingIdController.text.isNotEmpty) { + nameTagOrID = ratingIdController.text; + } + Countly.instance.feedback.presentRating(nameTagOrID, callback); + } + @override Widget build(BuildContext context) { + FeedbackCallback widgetCB = FeedbackCallback(onClosed: () { + showCountlyToast(context, 'Widget Closed', Colors.green); + }, onFinished: (error) { + if (error != null) { + showCountlyToast(context, 'Error: $error', Colors.red); + } else { + showCountlyToast(context, 'Widget Finished', Colors.green); + } + }); return Scaffold( appBar: AppBar( title: Text('FeedbackWidgets'), @@ -278,13 +309,19 @@ class _FeedbackWidgetsPageState extends State { child: Center( child: Column( children: [ + MyButton(text: 'Present NPS', color: 'green', onPressed: () => demoNPS(null, null)), + MyButton(text: 'Present Survey', color: 'green', onPressed: () => demoSurvey(null, null)), + MyButton(text: 'Present Rating', color: 'green', onPressed: () => demoRating(null, null)), + MyButton(text: 'Present NPS wCallback', color: 'green', onPressed: () => demoNPS(null, widgetCB)), + MyButton(text: 'Present Survey wCallback', color: 'green', onPressed: () => demoSurvey(null, widgetCB)), + MyButton(text: 'Present Rating wCallback', color: 'green', onPressed: () => demoRating(null, widgetCB)), MyButton(text: 'Open rating modal', color: 'orange', onPressed: askForStarRating), MyButton(text: 'Open feedback modal', color: 'orange', onPressed: presentRatingWidget), TextField( controller: ratingIdController, decoration: const InputDecoration( border: OutlineInputBorder(), - hintText: 'Enter a Rating ID', + hintText: 'Rating ID, Tag or Name', ), ), MyButton(text: 'Show Rating using EditBox', color: 'orange', onPressed: ratingIdController.text.isNotEmpty ? presentRatingWidgetUsingEditBox : null), diff --git a/ios/Classes/CountlyFlutterPlugin.m b/ios/Classes/CountlyFlutterPlugin.m index b2d00a28..e71c0a80 100644 --- a/ios/Classes/CountlyFlutterPlugin.m +++ b/ios/Classes/CountlyFlutterPlugin.m @@ -1091,6 +1091,66 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result }]; } }); + } else if ([@"presentNPS" isEqualToString:call.method]) { + dispatch_async(dispatch_get_main_queue(), ^{ + NSString *nameIDorTag = @""; + + if (command.count != 0) { + nameIDorTag = [command objectAtIndex:0]; + } + + [Countly.sharedInstance.feedback presentNPS:nameIDorTag widgetCallback:^(WidgetState state) { + if (state == WIDGET_CLOSED) { + [_channel invokeMethod:@"feedbackCallback_onClosed" arguments:nil]; + result(@"[CountlyFlutterPlugin] presentNPS, dismissed"); + } else if (state = WIDGET_APPEARED) { + [_channel invokeMethod:@"feedbackCallback_Finished" arguments:nil]; + result(@"[CountlyFlutterPlugin] presentNPS, appeared"); + } + }]; + + result(@"[CountlyFlutterPlugin] presentNPS, success"); + }); + } else if ([@"presentRating" isEqualToString:call.method]) { + dispatch_async(dispatch_get_main_queue(), ^{ + NSString *nameIDorTag = @""; + + if (command.count != 0) { + nameIDorTag = [command objectAtIndex:0]; + } + + [Countly.sharedInstance.feedback presentRating:nameIDorTag widgetCallback:^(WidgetState state) { + if (state == WIDGET_CLOSED) { + [_channel invokeMethod:@"feedbackCallback_onClosed" arguments:nil]; + result(@"[CountlyFlutterPlugin] presentRating, dismissed"); + } else if (state = WIDGET_APPEARED) { + [_channel invokeMethod:@"feedbackCallback_Finished" arguments:nil]; + result(@"[CountlyFlutterPlugin] presentRating, appeared"); + } + }]; + + result(@"[CountlyFlutterPlugin] presentRating, success"); + }); + } else if ([@"presentSurvey" isEqualToString:call.method]) { + dispatch_async(dispatch_get_main_queue(), ^{ + NSString *nameIDorTag = @""; + + if (command.count != 0) { + nameIDorTag = [command objectAtIndex:0]; + } + + [Countly.sharedInstance.feedback presentSurvey:nameIDorTag widgetCallback:^(WidgetState state) { + if (state == WIDGET_CLOSED) { + [_channel invokeMethod:@"feedbackCallback_onClosed" arguments:nil]; + result(@"CountlyFlutterPlugin] presentSurvey, dismissed"); + } else if (state = WIDGET_APPEARED) { + [_channel invokeMethod:@"feedbackCallback_Finished" arguments:nil]; + result(@"CountlyFlutterPlugin] presentSurvey, appeared"); + } + }]; + + result(@"[CountlyFlutterPlugin] presentSurvey, success"); + }); } else if ([@"getFeedbackWidgetData" isEqualToString:call.method]) { dispatch_async(dispatch_get_main_queue(), ^{ NSString *widgetId = [command objectAtIndex:0]; diff --git a/lib/countly_flutter.dart b/lib/countly_flutter.dart index f3dc9717..f015a2cb 100644 --- a/lib/countly_flutter.dart +++ b/lib/countly_flutter.dart @@ -1,6 +1,7 @@ export 'src/content_builder.dart'; export 'src/countly_config.dart'; export 'src/countly_flutter.dart'; +export 'src/feedback.dart'; export 'src/remote_config.dart'; export 'src/sessions.dart'; export 'src/user_profile.dart'; diff --git a/lib/src/countly_flutter.dart b/lib/src/countly_flutter.dart index 0c129b67..c3fb7944 100644 --- a/lib/src/countly_flutter.dart +++ b/lib/src/countly_flutter.dart @@ -7,11 +7,14 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:pedantic/pedantic.dart'; +import 'content_builder.dart'; import 'content_builder_internal.dart'; import 'countly_config.dart'; import 'countly_state.dart'; import 'device_id.dart'; import 'device_id_internal.dart'; +import 'feedback.dart'; +import 'feedback_internal.dart'; import 'remote_config.dart'; import 'remote_config_internal.dart'; import 'sessions.dart'; @@ -64,6 +67,7 @@ class Countly { _viewsInternal = ViewsInternal(_countlyState); _sessionsInternal = SessionsInternal(_countlyState); _contentBuilderInternal = ContentBuilderInternal(_countlyState); + _feedbackInternal = FeedbackInternal(_countlyState); } static final instance = _instance; static final _instance = Countly._(); @@ -88,6 +92,9 @@ class Countly { late final ContentBuilderInternal _contentBuilderInternal; ContentBuilderInternal get content => _contentBuilderInternal; + late final FeedbackInternal _feedbackInternal; + Feedback get feedback => _feedbackInternal; + /// ignore: constant_identifier_names static const bool BUILDING_WITH_PUSH_DISABLED = false; static const String _pushDisabledMsg = 'In this plugin Push notification is disabled, Countly has separate plugin with push notification enabled'; @@ -212,6 +219,18 @@ class Countly { log('[FMethodCallH] $e', logLevel: LogLevel.ERROR); } break; + case 'feedbackCallback_onClosed': + if (_instance._feedbackInternal.feedbackCallback != null) { + _instance._feedbackInternal.feedbackCallback!.onClosed(); + _instance._feedbackInternal.feedbackCallback = null; + } + break; + case 'feedbackCallback_onFinished': + if (_instance._feedbackInternal.feedbackCallback != null) { + _instance._feedbackInternal.feedbackCallback!.onFinished(call.arguments); + _instance._feedbackInternal.feedbackCallback = null; + } + break; case 'contentCallback': Map argumentsMap = Map.from(call.arguments); final int contentResult = argumentsMap['contentResult']; diff --git a/lib/src/feedback.dart b/lib/src/feedback.dart new file mode 100644 index 00000000..b41e73e3 --- /dev/null +++ b/lib/src/feedback.dart @@ -0,0 +1,17 @@ +typedef OnClosedCallback = void Function(); +typedef OnFinishedCallback = void Function(String error); + +class FeedbackCallback { + final OnClosedCallback onClosed; + final OnFinishedCallback onFinished; + + FeedbackCallback({required this.onClosed, required this.onFinished}); +} + +abstract class Feedback { + Future presentNPS([String? nameIDorTag, FeedbackCallback? feedbackCallback]); + + Future presentRating([String? nameIDorTag, FeedbackCallback? feedbackCallback]); + + Future presentSurvey([String? nameIDorTag, FeedbackCallback? feedbackCallback]); +} \ No newline at end of file diff --git a/lib/src/feedback_internal.dart b/lib/src/feedback_internal.dart new file mode 100644 index 00000000..226c6d60 --- /dev/null +++ b/lib/src/feedback_internal.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; + +import 'countly_flutter.dart'; +import 'countly_state.dart'; +import 'feedback.dart'; + +class FeedbackInternal implements Feedback { + FeedbackInternal(this._countlyState); + final CountlyState _countlyState; + FeedbackCallback? _feedbackCallback; + FeedbackCallback? get feedbackCallback => _feedbackCallback; + set feedbackCallback(FeedbackCallback? callback) { + // Add any validation or custom logic here if needed + _feedbackCallback = callback; + } + + @override + Future presentNPS([String? nameIDorTag, FeedbackCallback? feedbackCallback]) async { + if (!_countlyState.isInitialized) { + Countly.log('presentNPS, "initWithConfig" must be called before "presentNPS"', logLevel: LogLevel.ERROR); + feedbackCallback?.onFinished('init must be called before presentNPS'); + return; + } + + _feedbackCallback = feedbackCallback; + Countly.log('Calling "presentNPS" with nameIDorTag: [$nameIDorTag]'); + await invokeFeedbackMethod('presentNPS', nameIDorTag); + } + + @override + Future presentRating([String? nameIDorTag, FeedbackCallback? feedbackCallback]) async { + if (!_countlyState.isInitialized) { + Countly.log('presentRating, "initWithConfig" must be called before "presentRating"', logLevel: LogLevel.ERROR); + feedbackCallback?.onFinished('init must be called before presentRating'); + return; + } + + _feedbackCallback = feedbackCallback; + Countly.log('Calling "presentRating" with nameIDorTag: [$nameIDorTag]'); + await invokeFeedbackMethod('presentRating', nameIDorTag); + } + + @override + Future presentSurvey([String? nameIDorTag, FeedbackCallback? feedbackCallback]) async { + if (!_countlyState.isInitialized) { + Countly.log('presentSurvey, "initWithConfig" must be called before "presentSurvey"', logLevel: LogLevel.ERROR); + feedbackCallback?.onFinished('init must be called before presentSurvey'); + return; + } + + _feedbackCallback = feedbackCallback; + Countly.log('Calling "presentSurvey" with nameIDorTag: [$nameIDorTag]'); + await invokeFeedbackMethod('presentSurvey', nameIDorTag); + } + + Future invokeFeedbackMethod(String methodName, String? nameIDorTag){ + final args = []; + nameIDorTag ??= ''; + args.add(nameIDorTag); + + return _countlyState.channel.invokeMethod(methodName, {'data': json.encode(args)}); + } +}