From 830b8a08b2ca8929846e103f98929692fe58fdca Mon Sep 17 00:00:00 2001 From: Tommy-Lee Pigeon Date: Wed, 12 Jun 2024 16:15:48 -0400 Subject: [PATCH] feat: Port code to dart --- {build => .azuredevops}/azure-pipelines.yml | 0 {build => .azuredevops}/gitversion-config.yml | 0 {build => .azuredevops}/variables.yml | 0 .gitignore | 6 +- CHANGELOG.md | 1 + README.md | 320 ++++++++++++++++-- lib/review_service.dart | 4 - lib/src/review_service.dart | 7 - .../asynchronous_review_condition.dart | 15 + .../logging_review_prompter.dart | 16 + .../memory_review_settings_source.dart | 19 ++ .../review_condition_builder.dart | 35 ++ .../review_condition_interface.dart | 10 + .../review_conditions_builder.extensions.dart | 64 ++++ lib/src/review_service/review_prompter.dart | 35 ++ lib/src/review_service/review_service.dart | 104 ++++++ .../review_service.extensions.dart | 34 ++ lib/src/review_service/review_settings.dart | 47 +++ .../review_settings_source.dart | 10 + .../synchronous_review_condition.dart | 15 + pubspec.yaml | 4 + .../memory_review_settings_source_test.dart | 33 ++ test/source/review_service_test.dart | 81 +++++ test/src/review_service_test.dart | 10 - 24 files changed, 827 insertions(+), 43 deletions(-) rename {build => .azuredevops}/azure-pipelines.yml (100%) rename {build => .azuredevops}/gitversion-config.yml (100%) rename {build => .azuredevops}/variables.yml (100%) delete mode 100644 lib/review_service.dart delete mode 100644 lib/src/review_service.dart create mode 100644 lib/src/review_service/asynchronous_review_condition.dart create mode 100644 lib/src/review_service/logging_review_prompter.dart create mode 100644 lib/src/review_service/memory_review_settings_source.dart create mode 100644 lib/src/review_service/review_condition_builder.dart create mode 100644 lib/src/review_service/review_condition_interface.dart create mode 100644 lib/src/review_service/review_conditions_builder.extensions.dart create mode 100644 lib/src/review_service/review_prompter.dart create mode 100644 lib/src/review_service/review_service.dart create mode 100644 lib/src/review_service/review_service.extensions.dart create mode 100644 lib/src/review_service/review_settings.dart create mode 100644 lib/src/review_service/review_settings_source.dart create mode 100644 lib/src/review_service/synchronous_review_condition.dart create mode 100644 test/source/memory_review_settings_source_test.dart create mode 100644 test/source/review_service_test.dart delete mode 100644 test/src/review_service_test.dart diff --git a/build/azure-pipelines.yml b/.azuredevops/azure-pipelines.yml similarity index 100% rename from build/azure-pipelines.yml rename to .azuredevops/azure-pipelines.yml diff --git a/build/gitversion-config.yml b/.azuredevops/gitversion-config.yml similarity index 100% rename from build/gitversion-config.yml rename to .azuredevops/gitversion-config.yml diff --git a/build/variables.yml b/.azuredevops/variables.yml similarity index 100% rename from build/variables.yml rename to .azuredevops/variables.yml diff --git a/.gitignore b/.gitignore index f5b6f95..d104d44 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ migrate_working_dir/ .packages .pub-cache/ .pub/ +build/ pubspec.lock # Web related @@ -40,4 +41,7 @@ app.*.symbols app.*.map.json # Test related -coverage \ No newline at end of file +coverage + +# Mockito generated files. +*.mocks.dart \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 08ac413..7c0387f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,3 +10,4 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) ## 0.3.0 - Migrated the pipeline from the .NET version of the package. +- Ported the code from the .NET version of the package to dart. \ No newline at end of file diff --git a/README.md b/README.md index 842ec7d..7628152 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,317 @@ -# Open Source Project Template +# Review Service -This repository contains a template to seed a repository for an Open Source -project. +This repository introduces abstractions around native review capabilities to ease code sharing and testability. +It also introduces business logic to quickly configure conditions and state tracking to prompt for reviews at the right moment. -## How to use this template +To be replaced by the actual pub.dev tags once merged. +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) ![Version]() ![Downloads]() -1. Check out this repository -2. Delete the `.git` folder -3. Git init this repository and start working on your project! -4. Prior to submitting your request for publication, make sure to review the - [Open Source guidelines for publications](https://nventive.visualstudio.com/Internal/_wiki/wikis/Internal_wiki?wikiVersion=GBwikiMaster&pagePath=%2FOpen%20Source%2FPublishing&pageId=7120). +## Before Getting Started -## Features (to keep as-is, configure or remove) -- [Mergify](https://mergify.io/) is configured. You can edit or remove [.mergify.yml](/.mergify.yml). +Before getting started, please read the [Android](https://developer.android.com/guide/playcore/in-app-review) and [iOS](https://developer.apple.com/design/human-interface-guidelines/ratings-and-reviews) application review documentation. -The following is the template for the final README.md file: +## Getting Started ---- +1. Add the `ReviewService` package to your project. -# Project Title +2. Create an instance of `ReviewService`. We'll cover dependency injection in details later on in this documentation. + ```dart + var reviewConditionsBuilder = + ReviewConditionsBuilder.empty().minimumPrimaryActionsCompleted(1); -{Project tag line} + var reviewPrompter = LoggingReviewPrompter(); + var reviewSettingsSource = MemoryReviewSettingsSource(); -{Small description of the purpose of the project} + var reviewService = ReviewService( + logger: Logger(), + reviewPrompter: reviewPrompter, + reviewSettingsSource: reviewSettingsSource, + reviewConditionsBuilder: reviewConditionsBuilder); -[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) + ``` -## Getting Started +3. Use the service. + - Update the review settings based on application events. + ```dart + late ReviewService _reviewService; + + Future doPrimaryAction() async { + // Do Primary Action. + + // Track this action. + await _reviewService.trackPrimaryActionCompleted(); + } + + ``` + - Use the service to request review. + ```dart + late ReviewService _reviewService; + + Future onCompletedImportantFlow() async { + // Do Meaningful Task. + + // Check if all conditions are satisfied and prompt for review if they are. + await _reviewService.tryRequestReview(); + } + ``` + +## Next Steps + +### Persisting Review Settings + +`MemoryReviewSettingsSource` is great for automated testing but should not be the implementation of choice for real use-cases. Instead, you should create your own implementation that persists data on the device (so that review settings don't reset when you kill the app). + +```dart +/// Storage implementation of . +sealed class StorageReviewSettingsSource extends ReviewSettings + implements IReviewSettingsSource { + @override + Future read() { + // TODO: Return stored review settings. + return Future.value(ReviewSettings()); + } + + @override + Future write(ReviewSettings reviewSettings) { + // TODO: Update stored review settings. + return Future.value(); + } +} +``` + +### Using Dependency Injection -{Instructions to quickly get started using the project: pre-requisites, packages -to install, sample code, etc.} +Here is a simple code that does dependency injection using `get_it`. + +```dart + GetIt getIt = GetIt.instance; + + // Register the ReviewPrompter implementation with GetIt + getIt.registerSingleton(ReviewPrompter()); +``` + +> 💡 We recommend that you define your own interface that wraps `ReviewService` to make the usage code leaner and ease any potential refactorings. +> ```dart +> /// This interface wraps IReviewService so that you don't have to repeat the generic parameter everywhere that you would use the review service. +> /// In other words, you should use this interface in the app instead of IReviewService because it's leaner. +> +> /// If you would change ReviewSettings for a custom type, using this interface allows you to minimize any refactoring effort by limiting it to this interface and the associated adapter. +> interface IReviewService extends IReviewService +> { +> } +> ``` +> Here's a full example. +> ```dart +> class ReviewConfiguration +> { +> static addReviewServices() +> { +> GetIt.I.registerFactory(() => +> ReviewConditionsBuilder().minimumPrimaryActionsCompleted(3)); +> var reviewPrompter = GetIt.I.registerSingleton(LoggingReviewPrompter()); +> var reviewSettingsSource = GetIt.I.registerSingleton>(MemoryReviewSettingsSource()); +> GetIt.I.registerSingleton>(ReviewService( +> logger: Logger(), +> reviewPrompter: reviewPrompter, +> reviewSettingsSource: reviewSettingsSource, +> reviewConditionsBuilder: GetIt.I.get() +> )); +> GetIt.I.registerSingleton(ReviewServiceAdapter( +> GetIt.instance.get>())); +> } +> } +> +> class ReviewServiceAdapter implements ReviewService +> { +> late ReviewService _reviewService; +> +> ReviewServiceAdapter(ReviewService reviewService) +> { +> _reviewService = reviewService; +> } +> +> @override +> Future getAreConditionsSatisfied() => _reviewService.getAreConditionsSatisfied(); +> +> @override +> Future tryRequestReview() => _reviewService.tryRequestReview(); +> +> @override +> Future updateReviewSettings(ReviewSettings Function(ReviewSettings reviewSettings) updateFunction) => _reviewService.updateReviewSettings(updateFunction); +> } +> ``` ## Features -{More details/listing of features of the project} +Now that everything is setup, Let's see what else we can do! + +### Tack Application Events + +To track the provided review settings you can use the following `IReviewService` extensions. +> 💡 The review request count and the last review request are automatically tracked by the service. + +TODO: Change the github links to the dart package once merged +- [TrackApplicationLaunched](https://github.com/nventive/ReviewService/blob/a78e37c7e9b6fbe07ba1fec5d0f2b3b2f31bf356/src/ReviewService.Abstractions/ReviewService.Extensions.cs#L20) : Tracks that the application was launched (Also tracks if it's the first launch). +- [TrackPrimaryActionCompleted](https://github.com/nventive/ReviewService/blob/a78e37c7e9b6fbe07ba1fec5d0f2b3b2f31bf356/src/ReviewService.Abstractions/ReviewService.Extensions.cs#L45) : Tracks that a primary action was completed. +- [TrackSecondaryActionCompleted](https://github.com/nventive/ReviewService/blob/a78e37c7e9b6fbe07ba1fec5d0f2b3b2f31bf356/src/ReviewService.Abstractions/ReviewService.Extensions.cs#L61) : Tracks that a secondary action was completed. + +### Customize Tracking Data + +If you need custom conditions for your application, you have to create another record that inherits from `ReviewSettings`. + + ```dart +final class ReviewSettingsCustom extends ReviewSettings { + /// Gets or sets if the application onboarding has been completed. + final bool hasCompletedOnboarding; + + const ReviewSettingsCustom({ + this.hasCompletedOnboarding = false, + super.primaryActionCompletedCount = 0, + super.secondaryActionCompletedCount = 0, + super.applicationLaunchCount = 0, + super.firstApplicationLaunch, + super.requestCount = 0, + super.lastRequest, + }); + + @override + ReviewSettingsCustom copyWith({ + int? primaryActionCompletedCount, + int? secondaryActionCompletedCount, + int? applicationLaunchCount, + DateTime? firstApplicationLaunch, + int? requestCount, + DateTime? lastRequest, + bool? hasCompletedOnboarding, + }) { + return ReviewSettingsCustom( + primaryActionCompletedCount: + primaryActionCompletedCount ?? this.primaryActionCompletedCount, + secondaryActionCompletedCount: + secondaryActionCompletedCount ?? this.secondaryActionCompletedCount, + applicationLaunchCount: + applicationLaunchCount ?? this.applicationLaunchCount, + firstApplicationLaunch: + firstApplicationLaunch ?? this.firstApplicationLaunch, + requestCount: requestCount ?? this.requestCount, + lastRequest: lastRequest ?? this.lastRequest, + ); + } +} +``` + +### Add Tracking for Custom Application Events + +To track your custom review settings, you can create extensions for `IReviewService` and be sure to make them generic so they are usable with custom review settings. + +```dart +/// Extensions of IReviewService. +extension ReviewServiceExtensions on ReviewService { + /// Tracks that the application onboarding has been completed. + Future trackOnboardingCompleted() async { + await updateReviewSettings((reviewSettings) { + return reviewSettings.copyWith( + hasCompletedOnboarding: reviewSettings.hasCompletedOnboarding); + }); + } +} +``` + +#### Built-in Tracking Data +TODO: Change the github links to the dart package once merged +- [PrimaryActionCompletedCount](https://github.com/nventive/ReviewService/blob/1484f946c60e2bf1cb86b27faa60c148a1e56d45/src/ReviewService.Abstractions/ReviewSettings.cs#L13) : The number of primary actions completed. +- [SecondaryActionCompletedCount](https://github.com/nventive/ReviewService/blob/1484f946c60e2bf1cb86b27faa60c148a1e56d45/src/ReviewService.Abstractions/ReviewSettings.cs#L18) : The number of secondary actions completed. +- [ApplicationLaunchCount](https://github.com/nventive/ReviewService/blob/1484f946c60e2bf1cb86b27faa60c148a1e56d45/src/ReviewService.Abstractions/ReviewSettings.cs#L23) : The number of times the application has been launched. +- [FirstApplicationLaunch](https://github.com/nventive/ReviewService/blob/1484f946c60e2bf1cb86b27faa60c148a1e56d45/src/ReviewService.Abstractions/ReviewSettings.cs#L28) : When the application first started. +- [RequestCount](https://github.com/nventive/ReviewService/blob/1484f946c60e2bf1cb86b27faa60c148a1e56d45/src/ReviewService.Abstractions/ReviewSettings.cs#L33) : The number of review requested. +- [LastRequest](https://github.com/nventive/ReviewService/blob/1484f946c60e2bf1cb86b27faa60c148a1e56d45/src/ReviewService.Abstractions/ReviewSettings.cs#L38) : When the last review was requested. + +### Configure Conditions + +If you want to use our default review conditions, you can use `ReviewConditionsBuilder.defaultBuilder()` and pass it to the `ReviewService` constructor, or register it as a transient dependency when using dependency injection. Please note that our review conditions are also generic, so they can be used with custom review settings too. + +The `ReviewConditionsBuilder.Default()` extension method uses the following conditions. +- **3** application launches required. +- **2** completed primary actions. +- **5** days since the first application launch. +- **15** days since the last review request. + +#### Built-in Conditions +TODO: Change the github links to the dart package once merged +- [MinimumPrimaryActionsCompleted](https://github.com/nventive/ReviewService/blob/a78e37c7e9b6fbe07ba1fec5d0f2b3b2f31bf356/src/ReviewService.Abstractions/ReviewConditionsBuilder.Extensions.cs#L17) : Make sure that it prompts for review only if the number of completed primary actions meets the minimum. +- [MinimumSecondaryActionsCompleted](https://github.com/nventive/ReviewService/blob/a78e37c7e9b6fbe07ba1fec5d0f2b3b2f31bf356/src/ReviewService.Abstractions/ReviewConditionsBuilder.Extensions.cs#L33) : Make sure that it prompts for review only if the number of completed secondary actions meets the minimum. +- [MinimumApplicationLaunchCount](https://github.com/nventive/ReviewService/blob/a78e37c7e9b6fbe07ba1fec5d0f2b3b2f31bf356/src/ReviewService.Abstractions/ReviewConditionsBuilder.Extensions.cs#L49) : Make sure that it prompts for review only if the number of times the application has been launched meets the required minimum. +- [MinimumElapsedTimeSinceApplicationFirstLaunch](https://github.com/nventive/ReviewService/blob/a78e37c7e9b6fbe07ba1fec5d0f2b3b2f31bf356/src/ReviewService.Abstractions/ReviewConditionsBuilder.Extensions.cs#L65) : Make sure that it prompts for review only if the elapsed time since the first application launch meets the required minimum. +- [MinimumElapsedTimeSinceLastReviewRequest](https://github.com/nventive/ReviewService/blob/1484f946c60e2bf1cb86b27faa60c148a1e56d45/src/ReviewService.Abstractions/ReviewConditionsBuilder.Extensions.cs#L83) : Make sure that it prompts for review only if the elapsed time since the last review request meets the required minimum. +- [Custom](https://github.com/nventive/ReviewService/blob/1484f946c60e2bf1cb86b27faa60c148a1e56d45/src/ReviewService.Abstractions/ReviewConditionsBuilder.Extensions.cs#L99) : Custom condition made with a synchronous lambda function. +- [CustomAsync](https://github.com/nventive/ReviewService/blob/1484f946c60e2bf1cb86b27faa60c148a1e56d45/src/ReviewService.Abstractions/ReviewConditionsBuilder.Extensions.cs#L113) : Custom asynchronous condition made with an asynchronous lambda function. + +### Add Custom Conditions + +To create custom review conditions, you have to use `ReviewConditionsBuilder.custom` and `ReviewConditionsBuilder.customAsync` and provide them with a function directly instead of a condition. Also you can create extensions for `IReviewConditionsBuilder` and add a new condition to the builder. To create a review condition, you can use both `SynchronousReviewCondition` and `AsynchronousReviewCondition` you need to provide them with a function. + +```dart +/// Extensions for IReviewConditionsBuilder. +extension ReviewConditionsBuilderExtensions + on ReviewConditionsBuilder { + /// The application onboarding must be completed. + ReviewConditionsBuilder applicationOnboardingCompleted( + ReviewConditionsBuilder builder) { + builder.conditions.add(SynchronousReviewCondition((reviewSettings, currentDateTime) => reviewSettings.hasCompletedOnboarding == true)); + return builder; + } +} +``` + +Here is a simple code that uses the builder extensions for review conditions. + + ```dart + var reviewConditionsBuilder = ReviewConditionsBuilder.empty() + .minimumPrimaryActionsCompleted(1) + .minimumSecondaryActionsCompleted(1) + .minimumApplicationLaunchCount(1) + .minimumElapsedTimeSinceApplicationFirstLaunch(Duration(days: 1)) + .custom((reviewSettings, currentDateTime) { + return reviewSettings.primaryActionCompletedCount + + reviewSettings.secondaryActionCompletedCount >= + 2; + }); + +``` + +It's possible to customize the review conditions used by the service by using `ReviewConditionsBuilder` and passing it to the `ReviewService` constructor or by injecting it as a transient when using dependency injection. + +```dart +var reviewConditionsBuilder = + ReviewConditionsBuilder.empty() + .minimumPrimaryActionsCompleted(3) + .minimumApplicationLaunchCount(3) + .minimumElapsedTimeSinceApplicationFirstLaunch(Duration(days: 5)) + .custom((reviewSettings, currentDateTime) { + return reviewSettings.primaryActionCompletedCount + + reviewSettings.secondaryActionCompletedCount >= + 2; +}); + +``` + +## Testing + +This is what you need to know before testing and debugging this service. Please note that this may change and you should always refer to the [Apple](https://developer.apple.com/documentation/storekit/skstorereviewcontroller/3566727-requestreview#4278434) and [Android](https://developer.android.com/guide/playcore/in-app-review/test) documentation for the most up-to-date information. + +### Android TODO : check if this is still true with the in_app_review package + +- You can't test this service while debugging the application, the prompt won't show up. To test it, you need to use the [internal application sharing](https://play.google.com/console/about/internalappsharing/) or the internal testing feature in Google Play Console. See [this](https://developer.android.com/guide/playcore/in-app-review/test) for more details. +- You can't use a Google Suite account on Google Play to review an application because the prompt will not show up. + +### iOS TODO : check if this is still true with the in_app_review package + +- You can test on a real device or on a simulator. +- You can test this service only while debugging the application (It won't show up on TestFlight). + +## Acknowledgements TODO Change for in app review package + +Take a look at [in_app_review](https://github.com/britannio/in_app_review) that we use to prompt for review. ## Breaking Changes diff --git a/lib/review_service.dart b/lib/review_service.dart deleted file mode 100644 index cbecdbd..0000000 --- a/lib/review_service.dart +++ /dev/null @@ -1,4 +0,0 @@ -/// A multi-platform recipe to rate mobile apps. -library; - -export 'src/review_service.dart'; diff --git a/lib/src/review_service.dart b/lib/src/review_service.dart deleted file mode 100644 index 174b8a8..0000000 --- a/lib/src/review_service.dart +++ /dev/null @@ -1,7 +0,0 @@ -/// {@template review_service} -/// A multi-platform recipe to rate mobile apps. -/// {@endtemplate} -class ReviewService { - /// {@macro review_service} - const ReviewService(); -} diff --git a/lib/src/review_service/asynchronous_review_condition.dart b/lib/src/review_service/asynchronous_review_condition.dart new file mode 100644 index 0000000..640c966 --- /dev/null +++ b/lib/src/review_service/asynchronous_review_condition.dart @@ -0,0 +1,15 @@ +import 'review_condition_interface.dart'; +import 'review_settings.dart'; + +/// Asynchronous implementation of [ReviewCondition]. +class AsynchronousReviewCondition + implements ReviewCondition { + final Future Function(TReviewSettings, DateTime) _condition; + + AsynchronousReviewCondition(this._condition); + + @override + Future validate(TReviewSettings currentSettings, DateTime currentDateTime) { + return _condition(currentSettings, currentDateTime); + } +} \ No newline at end of file diff --git a/lib/src/review_service/logging_review_prompter.dart b/lib/src/review_service/logging_review_prompter.dart new file mode 100644 index 0000000..6b9c0b6 --- /dev/null +++ b/lib/src/review_service/logging_review_prompter.dart @@ -0,0 +1,16 @@ +import 'package:logger/logger.dart'; + +import 'review_prompter.dart'; + +/// Implementation of [ReviewPrompter] that logs information. +final class LoggingReviewPrompter implements ReviewPrompter { + final Logger logger; + + /// Initializes a new instance of the class. + LoggingReviewPrompter({Logger? logger}) : logger = logger ?? Logger(); + + @override + Future tryPrompt() async { + logger.i('TryPrompt was invoked.'); + } +} \ No newline at end of file diff --git a/lib/src/review_service/memory_review_settings_source.dart b/lib/src/review_service/memory_review_settings_source.dart new file mode 100644 index 0000000..5459840 --- /dev/null +++ b/lib/src/review_service/memory_review_settings_source.dart @@ -0,0 +1,19 @@ +import 'review_settings.dart'; +import 'review_settings_source.dart'; + +/// In-memory implementation of [ReviewSettingsSource]. +final class MemoryReviewSettingsSource implements ReviewSettingsSource { + TReviewSettings _reviewSettings = const ReviewSettings() as TReviewSettings; + + @override + Future read() { + return Future.value(_reviewSettings); + } + + @override + Future write(TReviewSettings reviewSettings) { + _reviewSettings = reviewSettings; + + return Future.value(); + } +} \ No newline at end of file diff --git a/lib/src/review_service/review_condition_builder.dart b/lib/src/review_service/review_condition_builder.dart new file mode 100644 index 0000000..de03422 --- /dev/null +++ b/lib/src/review_service/review_condition_builder.dart @@ -0,0 +1,35 @@ +import 'package:review_service/src/review_service/review_condition_interface.dart'; +import 'package:review_service/src/review_service/review_conditions_builder.extensions.dart'; +import 'package:review_service/src/review_service/review_settings.dart'; + +/// Provide a way to gather the review conditions. +abstract interface class ReviewConditionsBuilder< + TReviewSettings extends ReviewSettings> { + /// Gets the review conditions. + List> get conditions; +} + +/// Implementation of [ReviewConditionsBuilder]. +/// Provides methods to get an empty or default [ReviewConditionsBuilder]. +final class ReviewConditionsBuilderImplementation< + TReviewSettings extends ReviewSettings> + implements ReviewConditionsBuilder { + ReviewConditionsBuilderImplementation(); + + @override + List> conditions = []; + + static ReviewConditionsBuilder + empty() { + return ReviewConditionsBuilderImplementation(); + } + + static ReviewConditionsBuilder + defaultBuilder() { + return ReviewConditionsBuilderImplementation() + .minimumApplicationLaunchCount(3) + .minimumElapsedTimeSinceApplicationFirstLaunch(const Duration(days: 5)) + .minimumPrimaryActionsCompleted(2) + .minimumElapsedTimeSinceLastReviewRequest(const Duration(days: 15)); + } +} diff --git a/lib/src/review_service/review_condition_interface.dart b/lib/src/review_service/review_condition_interface.dart new file mode 100644 index 0000000..fd12955 --- /dev/null +++ b/lib/src/review_service/review_condition_interface.dart @@ -0,0 +1,10 @@ +import 'review_settings.dart'; + +/// A condition used to determine if a review should be requested based on [ReviewSettings]. +abstract interface class ReviewCondition { + /// Validates that the condition is satisfied. + Future validate( + TReviewSettings currentSettings, + DateTime currentDateTime, + ); +} \ No newline at end of file diff --git a/lib/src/review_service/review_conditions_builder.extensions.dart b/lib/src/review_service/review_conditions_builder.extensions.dart new file mode 100644 index 0000000..288c3c5 --- /dev/null +++ b/lib/src/review_service/review_conditions_builder.extensions.dart @@ -0,0 +1,64 @@ +import 'asynchronous_review_condition.dart'; +import 'review_condition_builder.dart'; +import 'review_settings.dart'; +import 'synchronous_review_condition.dart'; + +/// Extensions for [ReviewConditionsBuilder]. +extension ReviewConditionBuilderExtensions + on ReviewConditionsBuilder { + + /// The number of completed primary actions must be greater than the review settings. + ReviewConditionsBuilder minimumPrimaryActionsCompleted(int minimumActionCompleted) { + conditions.add(SynchronousReviewCondition( + (reviewSettings, currentDateTime) => reviewSettings.primaryActionCompletedCount >= minimumActionCompleted, + )); + return this; + } + + /// The number of completed secondary actions must be greater than the review settings. + ReviewConditionsBuilder minimumSecondaryActionsCompleted(int minimumActionCompleted) { + conditions.add(SynchronousReviewCondition( + (reviewSettings, currentDateTime) => reviewSettings.secondaryActionCompletedCount >= minimumActionCompleted, + )); + return this; + } + + /// The number of times the application has been launched must be greater than the review settings. + ReviewConditionsBuilder minimumApplicationLaunchCount(int minimumCount) { + conditions.add(SynchronousReviewCondition( + (reviewSettings, currentDateTime) => reviewSettings.applicationLaunchCount >= minimumCount, + )); + return this; + } + + /// The elapsed time since the first application launch must be greater than the review settings. + ReviewConditionsBuilder minimumElapsedTimeSinceApplicationFirstLaunch(Duration minimumTimeElapsed) { + conditions.add(SynchronousReviewCondition( + (reviewSettings, currentDateTime) => reviewSettings.firstApplicationLaunch != null && + reviewSettings.firstApplicationLaunch!.add(minimumTimeElapsed).isBefore(currentDateTime), + )); + return this; + } + + /// The time elapsed since the last review requested must be greater than the review settings. + ReviewConditionsBuilder minimumElapsedTimeSinceLastReviewRequest(Duration minimumTimeElapsed) { + conditions.add(SynchronousReviewCondition( + (reviewSettings, currentDateTime) => reviewSettings.lastRequest == null || + reviewSettings.lastRequest!.add(minimumTimeElapsed).isBefore(currentDateTime), + )); + return this; + } + + /// Adds a custom synchronous condition. + ReviewConditionsBuilder custom(bool Function(TReviewSettings, DateTime) condition) { + conditions.add(SynchronousReviewCondition(condition)); + return this; + } + + /// Adds a custom asynchronous condition. + ReviewConditionsBuilder customAsync( + Future Function(TReviewSettings, DateTime) condition) { + conditions.add(AsynchronousReviewCondition(condition)); + return this; + } +} \ No newline at end of file diff --git a/lib/src/review_service/review_prompter.dart b/lib/src/review_service/review_prompter.dart new file mode 100644 index 0000000..993ec70 --- /dev/null +++ b/lib/src/review_service/review_prompter.dart @@ -0,0 +1,35 @@ +import 'package:in_app_review/in_app_review.dart'; +import 'package:logger/logger.dart'; + +/// Provides ways to prompt user to review the current application. +abstract interface class ReviewPrompter { + factory ReviewPrompter({ + required Logger logger, + }) = _ReviewPrompter; + + /// Prompts the user to rate the current application using the platform's default application store. + Future tryPrompt(); +} + +/// Implementation of [ReviewPrompter]. +final class _ReviewPrompter implements ReviewPrompter { + final Logger _logger; + + _ReviewPrompter({ + required Logger logger, + }) : _logger = logger; + + @override + Future tryPrompt() async { + _logger.d('Trying to prompt user to review the current application.'); + + if (await InAppReview.instance.isAvailable()) { + InAppReview.instance.requestReview(); + + _logger.i('Prompted user to review the current application.'); + } else { + _logger.i( + 'Failed to prompt user to review the current application because the platform does not support it.'); + } + } +} diff --git a/lib/src/review_service/review_service.dart b/lib/src/review_service/review_service.dart new file mode 100644 index 0000000..565db64 --- /dev/null +++ b/lib/src/review_service/review_service.dart @@ -0,0 +1,104 @@ +import 'package:logger/logger.dart'; + +import 'review_condition_builder.dart'; +import 'review_condition_interface.dart'; +import 'review_prompter.dart'; +import 'review_settings.dart'; +import 'review_settings_source.dart'; + +/// Provides ways to validate review prompt conditions using [TReviewSettings] and +/// to prompt user to review the current application using [ReviewPrompter]. +abstract interface class ReviewService { + factory ReviewService({ + required Logger logger, + required ReviewPrompter reviewPrompter, + required ReviewSettingsSource reviewSettingsSource, + required ReviewConditionsBuilder reviewConditionsBuilder, + }) = _ReviewService; + + /// Checks if all review prompt conditions are satisfied and then prompt user to review the current application. + Future tryRequestReview(); + + /// Gets if all conditions are satisfied which means that we can prompt user to review the current application. + Future getAreConditionsSatisfied(); + + /// Updates the persisted [TReviewSettings]. + Future updateReviewSettings( + TReviewSettings Function(TReviewSettings) updateFunction, + ); +} + +/// Implementation of [IReviewService]. +final class _ReviewService + implements ReviewService { + final Logger _logger; + final ReviewPrompter _reviewPrompter; + final ReviewSettingsSource _reviewSettingsSource; + final List> _reviewConditions; + + _ReviewService({ + required Logger logger, + required ReviewPrompter reviewPrompter, + required ReviewSettingsSource reviewSettingsSource, + required ReviewConditionsBuilder reviewConditionsBuilder, + }) : _logger = logger, + _reviewPrompter = reviewPrompter, + _reviewSettingsSource = reviewSettingsSource, + _reviewConditions = reviewConditionsBuilder.conditions; + + /// Tracks that a review was requested. + Future _trackReviewRequested() async { + await updateReviewSettings((reviewSettings) { + return reviewSettings.copyWith( + lastRequest: DateTime.now(), + requestCount: reviewSettings.requestCount + 1, + ) as TReviewSettings; + }); + } + + @override + Future tryRequestReview() async { + _logger.d('Trying to request a review.'); + + if (await getAreConditionsSatisfied()) { + await _reviewPrompter.tryPrompt(); + await _trackReviewRequested(); + + _logger.i('Review requested.'); + } else { + _logger.i('Failed to request a review because one or more conditions were not satisfied.'); + } + } + + @override + Future getAreConditionsSatisfied() async { + _logger.d('Evaluating conditions.'); + + final currentSettings = await _reviewSettingsSource.read(); + final reviewConditionTasks = _reviewConditions.map((condition) => condition.validate(currentSettings, DateTime.now())); + final result = (await Future.wait(reviewConditionTasks)).every((x) => x); + + if (result) { + _logger.i('Evaluated conditions and all conditions are satisfied.'); + } else { + _logger.i('Evaluted conditions and one or more conditions were not satisfied.'); + } + + return result; + } + + @override + Future updateReviewSettings(TReviewSettings Function(TReviewSettings) updateFunction) async { + _logger.d('Updating review settings.'); + + final currentSettings = await _reviewSettingsSource.read(); + + try { + await _reviewSettingsSource.write(updateFunction(currentSettings)); + + _logger.i('Updated review settings.'); + } catch (ex) { + _logger.e('Failed to update review settings.', error: ex); + } + } +} diff --git a/lib/src/review_service/review_service.extensions.dart b/lib/src/review_service/review_service.extensions.dart new file mode 100644 index 0000000..a819ae3 --- /dev/null +++ b/lib/src/review_service/review_service.extensions.dart @@ -0,0 +1,34 @@ +import 'review_service.dart'; +import 'review_settings.dart'; + +/// Extensions of [IReviewService]. +extension ReviewServiceExtensions on ReviewService { + + /// Tracks that the application was launched. + Future trackApplicationLaunched() async { + await updateReviewSettings((reviewSettings) { + return reviewSettings.firstApplicationLaunch == null + ? reviewSettings.copyWith( + firstApplicationLaunch: DateTime.now(), + applicationLaunchCount: reviewSettings.applicationLaunchCount + 1, + ) as TReviewSettings + : reviewSettings.copyWith( + applicationLaunchCount: reviewSettings.applicationLaunchCount + 1, + ) as TReviewSettings; + }); + } + + /// Tracks that a primary action was completed. + Future trackPrimaryActionCompleted() async { + await updateReviewSettings( + (reviewSettings) => reviewSettings.copyWith(primaryActionCompletedCount: reviewSettings.primaryActionCompletedCount + 1) as TReviewSettings, + ); + } + + /// Tracks that a secondary action was completed. + Future trackSecondaryActionCompleted() async { + await updateReviewSettings( + (reviewSettings) => reviewSettings.copyWith(secondaryActionCompletedCount: reviewSettings.secondaryActionCompletedCount + 1) as TReviewSettings, + ); + } +} \ No newline at end of file diff --git a/lib/src/review_service/review_settings.dart b/lib/src/review_service/review_settings.dart new file mode 100644 index 0000000..96c02a4 --- /dev/null +++ b/lib/src/review_service/review_settings.dart @@ -0,0 +1,47 @@ +/// The review prompt settings used for prompt conditions. +class ReviewSettings { + /// The number of primary actions completed. + final int primaryActionCompletedCount; + + /// The number of secondary actions completed. + final int secondaryActionCompletedCount; + + /// The number of times the application has been launched. + final int applicationLaunchCount; + + /// When the application first started. + final DateTime? firstApplicationLaunch; + + /// The number of review requested. + final int requestCount; + + /// When the last review was requested. + final DateTime? lastRequest; + + const ReviewSettings({ + this.primaryActionCompletedCount = 0, + this.secondaryActionCompletedCount = 0, + this.applicationLaunchCount = 0, + this.firstApplicationLaunch, + this.requestCount = 0, + this.lastRequest, + }); + + ReviewSettings copyWith({ + int? primaryActionCompletedCount, + int? secondaryActionCompletedCount, + int? applicationLaunchCount, + DateTime? firstApplicationLaunch, + int? requestCount, + DateTime? lastRequest, + }) { + return ReviewSettings( + primaryActionCompletedCount: primaryActionCompletedCount ?? this.primaryActionCompletedCount, + secondaryActionCompletedCount: secondaryActionCompletedCount ?? this.secondaryActionCompletedCount, + applicationLaunchCount: applicationLaunchCount ?? this.applicationLaunchCount, + firstApplicationLaunch: firstApplicationLaunch ?? this.firstApplicationLaunch, + requestCount: requestCount ?? this.requestCount, + lastRequest: lastRequest ?? this.lastRequest, + ); + } +} \ No newline at end of file diff --git a/lib/src/review_service/review_settings_source.dart b/lib/src/review_service/review_settings_source.dart new file mode 100644 index 0000000..e171702 --- /dev/null +++ b/lib/src/review_service/review_settings_source.dart @@ -0,0 +1,10 @@ +import 'review_settings.dart'; + +/// Holds the review prompt settings used for prompt conditions. +abstract interface class ReviewSettingsSource { + /// Gets the current [ReviewSettings]. + Future read(); + + /// Updates the review prompt settings. + Future write(TReviewSettings reviewSettings); +} diff --git a/lib/src/review_service/synchronous_review_condition.dart b/lib/src/review_service/synchronous_review_condition.dart new file mode 100644 index 0000000..40b5484 --- /dev/null +++ b/lib/src/review_service/synchronous_review_condition.dart @@ -0,0 +1,15 @@ +import 'review_condition_interface.dart'; +import 'review_settings.dart'; + +/// Synchronous implementation of [ReviewCondition]. +class SynchronousReviewCondition + implements ReviewCondition { + final bool Function(TReviewSettings, DateTime) _condition; + + SynchronousReviewCondition(this._condition); + + @override + Future validate(TReviewSettings currentSettings, DateTime currentDateTime) { + return Future.value(_condition(currentSettings, currentDateTime)); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 73e42ad..30f6ce1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,8 +10,12 @@ environment: dependencies: flutter: sdk: flutter + in_app_review: ^2.0.9 + logger: ^2.3.0 dev_dependencies: flutter_test: sdk: flutter lints: ^3.0.0 + mockito: ^5.4.4 + build_runner: ^2.4.8 diff --git a/test/source/memory_review_settings_source_test.dart b/test/source/memory_review_settings_source_test.dart new file mode 100644 index 0000000..91ffbd2 --- /dev/null +++ b/test/source/memory_review_settings_source_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:review_service/src/review_service/memory_review_settings_source.dart'; +import 'package:review_service/src/review_service/review_settings.dart'; + +void main() { + test( + 'MemoryReviewSettingSource should not return null when reading from a new instance', + () async { + // Arrange + var source = MemoryReviewSettingsSource(); + + // Act + var result = await source.read(); + + // Assert + expect(result, isNotNull); + }); + test( + 'MemoryReviewSettingSource should return the same value that was written when reading', + () async { + // Arrange + var source = MemoryReviewSettingsSource(); + var value = ReviewSettings(requestCount: 10); + + await source.write(value); + + // Act + var result = await source.read(); + + // Assert + expect(result, value); + }); +} diff --git a/test/source/review_service_test.dart b/test/source/review_service_test.dart new file mode 100644 index 0000000..50ba430 --- /dev/null +++ b/test/source/review_service_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:review_service/src/review_service/memory_review_settings_source.dart'; +import 'package:review_service/src/review_service/review_condition_builder.dart'; + +import 'package:review_service/src/review_service/review_prompter.dart'; +import 'package:review_service/src/review_service/review_service.dart'; +import 'package:review_service/src/review_service/review_settings.dart'; + +import 'review_service_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + /// We need this because we are using maxInteger in the test cases. + const int maxInteger = 0x7FFFFFFFFFFFFFFF; + + late MockReviewPrompter reviewPrompterMock; + + var testCases = [ + { + 'reviewSettings': ReviewSettings(), + 'areConditionsSatisfied': false, + }, + { + 'reviewSettings': ReviewSettings( + firstApplicationLaunch: DateTime.fromMillisecondsSinceEpoch(0), + applicationLaunchCount: maxInteger, + primaryActionCompletedCount: maxInteger, + requestCount: 1, + lastRequest: DateTime.now(), + ), + 'areConditionsSatisfied': false, + }, + { + 'reviewSettings': ReviewSettings( + firstApplicationLaunch: DateTime.fromMillisecondsSinceEpoch(0), + applicationLaunchCount: maxInteger, + primaryActionCompletedCount: maxInteger, + ), + 'areConditionsSatisfied': true, + }, + ]; + + setUp(() { + reviewPrompterMock = MockReviewPrompter(); + + when(reviewPrompterMock.tryPrompt()).thenAnswer((_) async {}); + }); + + for (var testCase in testCases) { + test('Prompt Review When Conditions Are Satisfied', () async { + // Arrange. + var reviewConditionsBuilder = ReviewConditionsBuilderImplementation.defaultBuilder(); + var reviewSettingsSource = MemoryReviewSettingsSource(); + + await reviewSettingsSource.write(testCase['reviewSettings'] as ReviewSettings); + + var reviewService = ReviewService( + logger: Logger(), + reviewPrompter: reviewPrompterMock, + reviewSettingsSource: reviewSettingsSource, + reviewConditionsBuilder: reviewConditionsBuilder + ); + + // Act. + await reviewService.tryRequestReview(); + + // Assert. + var areConditionsSatisfied = testCase['areConditionsSatisfied'] as bool; + + if (areConditionsSatisfied) { + verify(reviewPrompterMock.tryPrompt()).called(1); + } else { + verifyNever(reviewPrompterMock.tryPrompt()); + } + }); + } + +} diff --git a/test/src/review_service_test.dart b/test/src/review_service_test.dart deleted file mode 100644 index 37e5108..0000000 --- a/test/src/review_service_test.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:review_service/review_service.dart'; - -void main() { - group('ReviewService', () { - test('can be instantiated', () { - expect(ReviewService(), isNotNull); - }); - }); -}