diff --git a/build/azure-pipelines.yml b/.azuredevops/azure-pipelines.yml similarity index 61% rename from build/azure-pipelines.yml rename to .azuredevops/azure-pipelines.yml index bf69651..4d94e35 100644 --- a/build/azure-pipelines.yml +++ b/.azuredevops/azure-pipelines.yml @@ -8,47 +8,53 @@ pr: - main variables: -- template: variables.yml - + - template: variables.yml + pool: - vmImage: 'ubuntu-latest' + vmImage: "ubuntu-latest" steps: - - - task: Hey24sheep.flutter.flutter-install.FlutterInstall@0 - displayName: 'Flutter Install' + - task: Hey24sheep.flutter.flutter-install.FlutterInstall@0 + displayName: "Flutter Install" - task: gitversion/setup@0 retryCountOnTaskFailure: 3 inputs: - versionSpec: '5.12.0' - displayName: 'Install GitVersion' + versionSpec: "5.12.0" + displayName: "Install GitVersion" - task: gitversion/execute@0 inputs: useConfigFile: true - configFilePath: $(Build.SourcesDirectory)/build/gitversion-config.yml - displayName: 'Calculate App Version' + configFilePath: .azuredevops/gitversion-config.yml + displayName: "Calculate App Version" - task: PowerShell@2 - displayName: 'Set Package Version' + displayName: "Set Package Version" inputs: - targetType: 'inline' + targetType: "inline" script: | $(DartToolPath)/dart pub global activate cider $(DartToolPath)/dart pub global run cider version $(GitVersion.MajorMinorPatch) - + # There is currently no other alternatives for setting the app version using gitversion. - powershell: | ((Get-Content -Path pubspec.yaml) -replace '(version:\s*)[\d\.]+', "version: $(GitVersion.MajorMinorPatch)") | Set-Content -Path pubspec.yaml - displayName: Replace version powershell command + displayName: Replace version powershell command - - script : flutter doctor - displayName : Run flutter doctor + - script: flutter doctor + displayName: Run flutter doctor - script: flutter pub get displayName: Install dependencies + - task: PowerShell@2 + displayName: "Execute Build Runner" + inputs: + targetType: "inline" + script: | + $(DartToolPath)/dart run build_runner build --delete-conflicting-outputs + - script: flutter analyze displayName: Analyze code @@ -59,21 +65,21 @@ steps: displayName: Run tests - script: flutter pub publish --dry-run - displayName: 'Run flutter pub publish --dry-run' + displayName: "Run flutter pub publish --dry-run" - task: DownloadSecureFile@1 condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'), eq(variables['IsReleaseBranch'], 'true')) inputs: - secureFile: 'flutter_publisher_gckey.json' + secureFile: "flutter_publisher_gckey.json" name: DownloadGCloudKey - - script: | + - script: | gcloud auth activate-service-account --key-file=$(DownloadGCloudKey.secureFilePath) gcloud auth print-identity-token --audiences=https://pub.dev | dart pub token add https://pub.dev dart pub publish --force condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'), eq(variables['IsReleaseBranch'], 'true')) - displayName: 'Publish to pub.dev as a nventive publisher' + displayName: "Publish to pub.dev as a nventive publisher" - task: PostBuildCleanup@3 - displayName: 'Post-Build Cleanup: Cleanup files to keep build server clean!' - condition: always() \ No newline at end of file + displayName: "Post-Build Cleanup: Cleanup files to keep build server clean!" + condition: always() 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..fdd7933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,3 +10,7 @@ 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. + +## 0.4.0 + +- 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..8f25939 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,365 @@ -# 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 `review_service` 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 = ReviewConditionsBuilderImplementation + .empty() + .minimumPrimaryActionsCompleted(1); -{Project tag line} + var reviewPrompter = LoggingReviewPrompter(); + var reviewSettingsSource = MemoryReviewSettingsSource(() => const ReviewSettings()); -{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 -{Instructions to quickly get started using the project: pre-requisites, packages -to install, sample code, etc.} +### 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 ReviewSettingsSource { + @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 + +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()); +``` ## Features -{More details/listing of features of the project} +Now that everything is setup, Let's see what else we can do! + +### Track Application Events + +To track the provided review settings you can use the following `ReviewService` 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. + +#### 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 `ReviewConditionsBuilder` 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 ReviewConditionsBuilder. +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; +}); + +``` + +### Add Custom Tracking Data + +First let's declare a new `ReviewSettings` named `CustomReviewSettings` with a `favoriteJokesCount` to track how many jokes were favorited in an hypothetical dad jokes application + +```dart + class CustomReviewSettings extends ReviewSettings { + final int favoriteJokesCount; + + const CustomReviewSettings({ + this.favoriteJokesCount = 0, + super.primaryActionCompletedCount = 0, + super.secondaryActionCompletedCount = 0, + super.applicationLaunchCount = 0, + super.firstApplicationLaunch, + super.requestCount = 0, + super.lastRequest, + }); + + @override + CustomReviewSettings copyWith({ + int? primaryActionCompletedCount, + int? secondaryActionCompletedCount, + int? applicationLaunchCount, + DateTime? firstApplicationLaunch, + int? requestCount, + DateTime? lastRequest, + }) { + return CustomReviewSettings( + primaryActionCompletedCount: + primaryActionCompletedCount ?? this.primaryActionCompletedCount, + secondaryActionCompletedCount: + secondaryActionCompletedCount ?? this.secondaryActionCompletedCount, + applicationLaunchCount: + applicationLaunchCount ?? this.applicationLaunchCount, + firstApplicationLaunch: + firstApplicationLaunch ?? this.firstApplicationLaunch, + requestCount: requestCount ?? this.requestCount, + lastRequest: lastRequest ?? this.lastRequest, + favoriteJokesCount: favoriteJokesCount, + ); + } + + CustomReviewSettings copyWithFavorite(int favoriteCount) { + return CustomReviewSettings( + primaryActionCompletedCount: super.primaryActionCompletedCount, + secondaryActionCompletedCount: super.secondaryActionCompletedCount, + applicationLaunchCount: super.applicationLaunchCount, + firstApplicationLaunch: super.firstApplicationLaunch, + requestCount: super.requestCount, + lastRequest: super.lastRequest, + favoriteJokesCount: favoriteCount, + ); + } + } +``` + +> ⚠ Notes : +> 1. It's important you define a `copyWith` that overrides the one from the `ReviewSettings` class while returning the newly added property. This ensures that the calls made to the `copyWith` method of `ReviewSettings` will be overriden by your implementation +> 2. You need to define a `copyWith` method that returns the superclass `ReviewSettings` value along with your new property passed as a parameter. This method will be used when you will track your review settings further down the line. + +Once you've defined your new custom review settings, it is recommended to add an extension to the `ReviewConditionsBuilder` for uniformity : + +```dart + extension CustomReviewConditionsBuilderExtensions + on ReviewConditionsBuilder { + ReviewConditionsBuilder minimumJokesFavorited( + int minimumJokesFavorited, + ) { + conditions.add( + SynchronousReviewCondition( + (reviewSettings, currentDateTime) => + reviewSettings.favoriteJokesCount >= minimumJokesFavorited, + ), + ); + return this; + } + } +``` + +> ⚠ Notes : +> You need to specify that the `ReviewConditionsBuilder` takes your new `CustomReviewSettings` and not the default `ReviewSettings` since you want to use your newly added property. + +Then you need to register your service with the extended `ReviewConditionsBuilder` and the new review settings like this : +```dart + var logger = Logger(); + var reviewConditionsBuilder = + ReviewConditionsBuilderImplementation() + .minimumJokesFavorited(3); + + var reviewSettingsSource = + GetIt.I.registerSingleton>( + MemoryReviewSettingsSource( + () => const CustomReviewSettings(), + ), + ); + + GetIt.I.registerSingleton( + ReviewService( + logger: logger, + reviewPrompter: ReviewPrompter(logger: logger), + reviewSettingsSource: reviewSettingsSource, + reviewConditionsBuilder: reviewConditionsBuilder, + ), + ); +``` + +> ⚠ Notes : +> 1. You need to specify again that you want the conditions builder the settings source and the service to use your `CustomReviewSettings` and not the generic. +> 2. 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 ReviewService 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 ReviewService because it's leaner. + class CustomReviewService implements ReviewService { + late ReviewService _reviewService; + + CustomReviewService(ReviewService reviewService) { + _reviewService = reviewService; + } + + @override + Future getAreConditionsSatisfied() async { + return await _reviewService.getAreConditionsSatisfied(); + } + + @override + Future tryRequestReview() async { + await _reviewService.tryRequestReview(); + } + + @override + Future updateReviewSettings( + CustomReviewSettings Function(CustomReviewSettings p1) updateFunction, + ) async { + await _reviewService.updateReviewSettings(updateFunction); + } + } + ``` + +After creating your own implementation of the ReviewService you just need to register it like this : +```dart + GetIt.I.registerSingleton( + CustomReviewService( + ReviewService( + logger: logger, + reviewPrompter: ReviewPrompter(logger: logger), + reviewSettingsSource: reviewSettingsSource, + reviewConditionsBuilder: reviewConditionsBuilder, + ), + ), + ); +``` + +And use it like that : +```dart + await GetIt.I.get().trackFavoriteJokesCount(); +``` + +Now you're all set to track how many jokes are favorited in your app and prompt for a review once your review conditions are meant (3 favorite jokes in this case). + +## 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 + +- 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 + +- 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 + +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..781c012 --- /dev/null +++ b/lib/src/review_service/asynchronous_review_condition.dart @@ -0,0 +1,15 @@ +import 'package:review_service/src/review_service/review_condition.dart'; +import 'package:review_service/src/review_service/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..a3d2a2a --- /dev/null +++ b/lib/src/review_service/logging_review_prompter.dart @@ -0,0 +1,15 @@ +import 'package:logger/logger.dart'; +import 'package:review_service/src/review_service/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..d4af62a --- /dev/null +++ b/lib/src/review_service/memory_review_settings_source.dart @@ -0,0 +1,23 @@ +import 'package:review_service/src/review_service/review_settings.dart'; +import 'package:review_service/src/review_service/review_settings_source.dart'; + +/// In-memory implementation of [ReviewSettingsSource]. +final class MemoryReviewSettingsSource implements ReviewSettingsSource { + late TReviewSettings _reviewSettings; + + MemoryReviewSettingsSource(TReviewSettings Function() defaultSettings) { + _reviewSettings = defaultSettings(); + } + + @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.dart b/lib/src/review_service/review_condition.dart new file mode 100644 index 0000000..2870ba2 --- /dev/null +++ b/lib/src/review_service/review_condition.dart @@ -0,0 +1,10 @@ +import 'package:review_service/src/review_service/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_condition_builder.dart b/lib/src/review_service/review_condition_builder.dart new file mode 100644 index 0000000..d48fd3e --- /dev/null +++ b/lib/src/review_service/review_condition_builder.dart @@ -0,0 +1,35 @@ +import 'package:review_service/src/review_service/review_condition.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_conditions_builder.extensions.dart b/lib/src/review_service/review_conditions_builder.extensions.dart new file mode 100644 index 0000000..5c3ccfc --- /dev/null +++ b/lib/src/review_service/review_conditions_builder.extensions.dart @@ -0,0 +1,65 @@ + +import 'package:review_service/src/review_service/asynchronous_review_condition.dart'; +import 'package:review_service/src/review_service/review_condition_builder.dart'; +import 'package:review_service/src/review_service/review_settings.dart'; +import 'package:review_service/src/review_service/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..1e86050 --- /dev/null +++ b/lib/src/review_service/review_service.dart @@ -0,0 +1,103 @@ +import 'package:logger/logger.dart'; +import 'package:review_service/src/review_service/review_condition.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_settings.dart'; +import 'package:review_service/src/review_service/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..c01db20 --- /dev/null +++ b/lib/src/review_service/review_service.extensions.dart @@ -0,0 +1,34 @@ +import 'package:review_service/src/review_service/review_service.dart'; +import 'package:review_service/src/review_service/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..2658a11 --- /dev/null +++ b/lib/src/review_service/review_settings_source.dart @@ -0,0 +1,10 @@ +import 'package:review_service/src/review_service/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..b74df08 --- /dev/null +++ b/lib/src/review_service/synchronous_review_condition.dart @@ -0,0 +1,15 @@ +import 'package:review_service/src/review_service/review_condition.dart'; +import 'package:review_service/src/review_service/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/src/memory_review_settings_source_test.dart b/test/src/memory_review_settings_source_test.dart new file mode 100644 index 0000000..b9c0c3b --- /dev/null +++ b/test/src/memory_review_settings_source_test.dart @@ -0,0 +1,119 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart'; +import 'package:review_service/src/review_service/logging_review_prompter.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_conditions_builder.extensions.dart'; +import 'package:review_service/src/review_service/review_service.dart'; +import 'package:review_service/src/review_service/review_service.extensions.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(() => ReviewSettings()); + + // 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(() => ReviewSettings()); + var value = ReviewSettings(requestCount: 10); + + await source.write(value); + + // Act + var result = await source.read(); + + // Assert + expect(result, value); + }); + + test( + 'MemoryReviewSettingsSource should retain updated value from CustomReviewSetings when tracking application launched', + () async { + // Arrange + var source = MemoryReviewSettingsSource(() => CustomReviewSettings()); + + var reviewConditionBuilder = ReviewConditionsBuilderImplementation().minimumApplicationLaunchCount(1); + + var reviewService = ReviewService( + logger: Logger(), + reviewSettingsSource: source, + reviewConditionsBuilder: reviewConditionBuilder, + reviewPrompter: LoggingReviewPrompter() + ); + + // Act + await reviewService.updateReviewSettings((reviewSettings) { + return reviewSettings + .copyWithFavorite(reviewSettings.favoriteJokesCount + 1); + }); + + await reviewService.trackApplicationLaunched(); + var result = await source.read(); + + // Assert + expect(result.favoriteJokesCount, 1); + expect(result.applicationLaunchCount, 1); + }); +} + + +class CustomReviewSettings extends ReviewSettings { + final int favoriteJokesCount; + + const CustomReviewSettings({ + this.favoriteJokesCount = 0, + super.primaryActionCompletedCount = 0, + super.secondaryActionCompletedCount = 0, + super.applicationLaunchCount = 0, + super.firstApplicationLaunch, + super.requestCount = 0, + super.lastRequest, + }); + + @override + CustomReviewSettings copyWith({ + int? primaryActionCompletedCount, + int? secondaryActionCompletedCount, + int? applicationLaunchCount, + DateTime? firstApplicationLaunch, + int? requestCount, + DateTime? lastRequest, + }) { + return CustomReviewSettings( + primaryActionCompletedCount: + primaryActionCompletedCount ?? this.primaryActionCompletedCount, + secondaryActionCompletedCount: + secondaryActionCompletedCount ?? this.secondaryActionCompletedCount, + applicationLaunchCount: + applicationLaunchCount ?? this.applicationLaunchCount, + firstApplicationLaunch: + firstApplicationLaunch ?? this.firstApplicationLaunch, + requestCount: requestCount ?? this.requestCount, + lastRequest: lastRequest ?? this.lastRequest, + favoriteJokesCount: favoriteJokesCount, + ); + } + + CustomReviewSettings copyWithFavorite(int favoriteCount) { + return CustomReviewSettings( + primaryActionCompletedCount: super.primaryActionCompletedCount, + secondaryActionCompletedCount: super.secondaryActionCompletedCount, + applicationLaunchCount: super.applicationLaunchCount, + firstApplicationLaunch: super.firstApplicationLaunch, + requestCount: super.requestCount, + lastRequest: super.lastRequest, + favoriteJokesCount: favoriteCount, + ); + } +} diff --git a/test/src/review_service_test.dart b/test/src/review_service_test.dart index 37e5108..7aa6a2c 100644 --- a/test/src/review_service_test.dart +++ b/test/src/review_service_test.dart @@ -1,10 +1,80 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:review_service/review_service.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() { - group('ReviewService', () { - test('can be instantiated', () { - expect(ReviewService(), isNotNull); + /// 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(() => ReviewSettings()); + + 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()); + } }); - }); + } + }