Happy Review
Event-driven in-app review prompts for Flutter. Trigger reviews at moments of user satisfaction — not arbitrary launch counts.
Read the full strategy behind this library: The Art of Asking: In-App Review Strategy for Mobile Applications
The Problem
Most apps request reviews based on how many times the app was opened. This is a bad practice:
- Opening the app doesn't mean enjoying it.
- You interrupt without context — the user hasn't accomplished anything yet.
- You actively generate negative reviews from annoyed users.
- You waste limited OS-level review invocations (Apple caps at 3/year).
The Solution
Happy Review replaces the launch counter with an event-driven approach:
- Triggers fire when the user completes a happy-path action (purchase, workout, delivery).
- Prerequisites ensure baseline engagement before any trigger can activate (AND logic).
- Platform Policy enforces per-platform frequency rules aligned with Apple/Google restrictions.
- Conditions add business-level guards (days since install, cooldowns, custom logic).
- Emotional Filter shows a pre-dialog ("Are you enjoying the app?") that routes satisfied users to the OS review and captures feedback from unsatisfied users — privately.
logEvent() -> Trigger met? -> Prerequisites OK? -> Platform policy OK? -> Conditions pass?
|
Pre-dialog shown
/ | \
Positive Later Negative
| | |
OS Review Skip Feedback form
Installation
dependencies:
happy_review: ^0.2.1
Platform Support
| Android | iOS | macOS |
|---|---|---|
| ✅ | ✅ | ✅ |
Quick Start
import 'package:happy_review/happy_review.dart';
// 1. Configure once at app startup.
await HappyReview.instance.configure(
storageAdapter: MyStorageAdapter(), // You provide the implementation.
triggers: [
const HappyTrigger(eventName: 'purchase_completed', minOccurrences: 3),
],
dialogAdapter: DefaultReviewDialogAdapter(),
);
// 2. Log events after happy-path actions.
await HappyReview.instance.logEvent(context, 'purchase_completed');
That's it. After 3 purchases, the pre-dialog appears. If the user responds positively, the OS review is requested. If negatively, a feedback form is shown.
Configuration
Triggers
Define which events can activate the review flow (OR logic — any single trigger is enough):
triggers: [
const HappyTrigger(eventName: 'purchase_completed', minOccurrences: 3),
const HappyTrigger(eventName: 'streak_reached', minOccurrences: 1),
]
Prerequisites
Events that must ALL have occurred before any trigger can fire (AND logic):
prerequisites: [
// User must finish onboarding before any review flow can start.
const HappyTrigger(eventName: 'onboarding_finished', minOccurrences: 1),
// User must have used the app at least 5 times.
const HappyTrigger(eventName: 'app_session', minOccurrences: 5),
]
This is useful for ensuring baseline engagement. Triggers are OR ("any of these can activate"), prerequisites are AND ("all of these must be true first").
Conditions
Add business rules that must all pass before the flow starts:
conditions: [
// Wait at least 7 days after first launch.
const MinDaysAfterInstall(days: 7),
// Don't show more than 3 times total.
const MaxPromptsShown(maxPrompts: 3),
// Custom logic — anything you need.
CustomCondition(
name: 'no_recent_support_ticket',
evaluate: () async => !(await supportRepo.hasRecentTicket()),
),
]
Built-in conditions:
| Condition | Description |
|---|---|
MinDaysAfterInstall |
Minimum days since first library initialization |
CooldownPeriod |
Minimum days since the last prompt was shown |
MaxPromptsShown |
Maximum total prompts allowed |
CustomCondition |
Arbitrary async logic via callback |
Platform Policy
Per-platform frequency rules that act as a safety layer aligned with OS restrictions:
platformPolicy: const PlatformPolicy(
ios: PlatformRules(
cooldown: Duration(days: 120),
maxPrompts: 3,
maxPromptsPeriod: Duration(days: 365),
),
android: PlatformRules(
cooldown: Duration(days: 60),
maxPrompts: 3,
maxPromptsPeriod: Duration(days: 365),
),
macOS: PlatformRules(
cooldown: Duration(days: 120),
maxPrompts: 3,
maxPromptsPeriod: Duration(days: 365),
),
)
Sensible defaults are applied if you don't specify a policy.
Callbacks
React to every step of the review flow:
onPreDialogShown: () => analytics.log('pre_dialog_shown'),
onPreDialogPositive: () => analytics.log('user_happy'),
onPreDialogNegative: () => analytics.log('user_unhappy'),
onPreDialogRemindLater: () => analytics.log('user_remind_later'),
onPreDialogDismissed: () => analytics.log('dialog_dismissed'),
onReviewRequested: () => analytics.log('os_review_requested'),
onFeedbackSubmitted: (feedback) => sendToBackend(feedback),
Adapters
Happy Review uses adapters so you control how things look and where state is stored.
Dialog Adapter
Controls the pre-dialog and feedback UI.
Option 1: Default adapter with config
dialogAdapter: DefaultReviewDialogAdapter(
preDialogConfig: const DefaultPreDialogConfig(
title: 'Enjoying our app?',
positiveLabel: 'Love it!',
negativeLabel: 'Not really',
remindLaterLabel: 'Maybe later', // Set to null to hide this button.
),
feedbackConfig: const DefaultFeedbackDialogConfig(
title: 'What could we improve?',
categories: ['Performance', 'Design', 'Features'],
showContactOption: true,
),
)
Option 2: Fully custom UI
Implement ReviewDialogAdapter to use your own widgets:
class MyReviewDialogAdapter extends ReviewDialogAdapter {
@override
Future<PreDialogResult> showPreDialog(BuildContext context) async {
final result = await showModalBottomSheet<bool>(
context: context,
builder: (_) => MySatisfactionSheet(),
);
if (result == null) return PreDialogResult.dismissed;
return result ? PreDialogResult.positive : PreDialogResult.negative;
}
@override
Future<FeedbackResult?> showFeedbackDialog(BuildContext context) async {
return Navigator.of(context).push<FeedbackResult>(
MaterialPageRoute(builder: (_) => MyFeedbackScreen()),
);
}
}
Option 3: No adapter (direct review)
Omit dialogAdapter to skip the pre-dialog and request the OS review directly when triggers fire.
Storage Adapter
Controls where event counts and internal state are persisted. This is a required parameter — the library has zero opinion on your storage layer.
Implement ReviewStorageAdapter with your preferred backend:
class HiveStorageAdapter extends ReviewStorageAdapter {
final Box _box;
HiveStorageAdapter(this._box);
@override
Future<int> getInt(String key, {int defaultValue = 0}) async =>
_box.get(key, defaultValue: defaultValue);
@override
Future<void> setInt(String key, int value) => _box.put(key, value);
// ... implement remaining methods
}
The example app includes a SharedPreferencesStorageAdapter you can use as reference or
copy directly into your project.
Return Values
logEvent returns a ReviewFlowResult so you know exactly what happened:
| Result | Meaning |
|---|---|
disabled |
Library is disabled via kill switch |
flowAlreadyInProgress |
Another review flow is active; event was counted |
noTrigger |
No trigger matched for this event |
prerequisitesNotMet |
One or more prerequisites are not satisfied |
blockedByPlatformPolicy |
Platform frequency limit reached |
conditionsNotMet |
A condition returned false |
reviewRequested |
User was happy; OS review requested |
reviewRequestedDirect |
No dialog adapter; OS review requested directly |
feedbackSubmitted |
User was unhappy; feedback collected |
remindLater |
User chose to be reminded later |
dialogDismissed |
User dismissed without choosing |
Kill Switch
Disable the library at runtime without redeploying (e.g., via remote config):
// At configure time:
await HappyReview.instance.configure(
storageAdapter: myStorageAdapter,
enabled: false, // All logEvent calls return ReviewFlowResult.disabled.
// ...
);
// Or toggle at runtime:
HappyReview.instance.setEnabled(remoteConfig.getBool('enable_review_prompt'));
Query State
Inspect internal state without triggering the review flow:
// How many times has this event been logged?
final count = await HappyReview.instance.getEventCount('purchase_completed');
// How many times has the review prompt been shown?
final prompts = await HappyReview.instance.getPromptsShownCount();
// When was the last prompt shown?
final lastDate = await HappyReview.instance.getLastPromptDate();
Debug Mode
Enable debug mode during development to observe the full pipeline via logs:
await HappyReview.instance.configure(
storageAdapter: myStorageAdapter,
debugMode: true, // Enables detailed logging.
// ...
);
In debug mode:
- Detailed logs are printed via
debugPrintat every pipeline stage. - All checks (prerequisites, platform policy, conditions) are still enforced.
To test the dialog flow during development, use a relaxed platform policy instead:
platformPolicy: const PlatformPolicy(
android: PlatformRules(
cooldown: Duration(seconds: 10),
maxPrompts: 99,
maxPromptsPeriod: Duration(days: 365),
),
ios: PlatformRules(
cooldown: Duration(seconds: 10),
maxPrompts: 99,
maxPromptsPeriod: Duration(days: 365),
),
macOS: PlatformRules(
cooldown: Duration(seconds: 10),
maxPrompts: 99,
maxPromptsPeriod: Duration(days: 365),
),
),
Debug Panel
Embed a HappyReviewDebugPanel widget in any screen to visualize the full pipeline state at a glance:
const HappyReviewDebugPanel()
The panel shows: enabled status, triggers (with counts), prerequisites, platform policy, conditions, prompts shown, and last prompt date. It includes a refresh button and only renders in debug builds.
You can also access the raw data programmatically:
final snapshot = await HappyReview.instance.getDebugSnapshot();
print(snapshot.triggers.first.currentCount); // e.g., 2
print(snapshot.platformPolicyAllows); // true/false
Testing
Import happy_review/testing.dart to get fakes for your tests — no mocking library needed:
import 'package:happy_review/happy_review.dart';
import 'package:happy_review/testing.dart';
// In-memory storage that works like a real backend.
final storage = FakeStorageAdapter();
// Dialog adapter that returns predetermined results.
// Defaults to PreDialogResult.positive.
final adapter = FakeDialogAdapter();
// Simulate an unhappy user with feedback:
final unhappyAdapter = FakeDialogAdapter(
preDialogResult: PreDialogResult.negative,
feedbackResult: FeedbackResult(comment: 'Too slow'),
);
await HappyReview.instance.configure(
storageAdapter: storage,
triggers: [const HappyTrigger(eventName: 'purchase', minOccurrences: 1)],
dialogAdapter: adapter,
);
Use Cases
E-Commerce — Review after successful purchases
Ask for a review after the user has completed multiple purchases, ensuring they've experienced your core value proposition.
await HappyReview.instance.configure(
storageAdapter: myStorage,
triggers: [
const HappyTrigger(eventName: 'purchase_completed', minOccurrences: 3),
],
prerequisites: [
const HappyTrigger(eventName: 'onboarding_finished', minOccurrences: 1),
],
dialogAdapter: DefaultReviewDialogAdapter(),
);
// After a successful purchase:
await HappyReview.instance.logEvent(context, 'purchase_completed');
Fitness / Health — Review after achieving a streak
Trigger the review when the user has proven consistency and is most engaged.
await HappyReview.instance.configure(
storageAdapter: myStorage,
triggers: [
const HappyTrigger(eventName: 'workout_completed', minOccurrences: 10),
const HappyTrigger(eventName: 'streak_7_days', minOccurrences: 1),
],
conditions: [
const MinDaysAfterInstall(days: 14),
const CooldownPeriod(days: 90),
],
dialogAdapter: DefaultReviewDialogAdapter(
preDialogConfig: const DefaultPreDialogConfig(
title: 'Crushing your goals!',
positiveLabel: 'Rate us!',
negativeLabel: 'Could be better',
),
),
);
Delivery / Logistics — Review after a successful delivery
The user just received their order — peak satisfaction.
await HappyReview.instance.configure(
storageAdapter: myStorage,
triggers: [
const HappyTrigger(eventName: 'delivery_confirmed', minOccurrences: 2),
],
conditions: [
const MinDaysAfterInstall(days: 7),
const MaxPromptsShown(maxPrompts: 3),
CustomCondition(
name: 'no_recent_complaint',
evaluate: () async => !(await supportRepo.hasOpenTicket()),
),
],
dialogAdapter: DefaultReviewDialogAdapter(),
);
SaaS / Productivity — Review after completing a key workflow
Ask after the user has created content, exported a report, or hit a milestone.
await HappyReview.instance.configure(
storageAdapter: myStorage,
triggers: [
const HappyTrigger(eventName: 'report_exported', minOccurrences: 5),
const HappyTrigger(eventName: 'project_completed', minOccurrences: 1),
],
prerequisites: [
const HappyTrigger(eventName: 'profile_setup', minOccurrences: 1),
],
dialogAdapter: DefaultReviewDialogAdapter(
preDialogConfig: const DefaultPreDialogConfig(
title: 'How is your experience?',
positiveLabel: 'Great!',
negativeLabel: 'Not great',
remindLaterLabel: 'Ask me later',
),
),
);
Gaming — Review after winning or reaching a level
Capture the dopamine hit right when the player is most excited.
await HappyReview.instance.configure(
storageAdapter: myStorage,
triggers: [
const HappyTrigger(eventName: 'level_completed', minOccurrences: 10),
const HappyTrigger(eventName: 'boss_defeated', minOccurrences: 1),
],
conditions: [
const MinDaysAfterInstall(days: 3),
const CooldownPeriod(days: 60),
],
dialogAdapter: DefaultReviewDialogAdapter(),
);
Education — Review after completing a course module
The student just passed a test or finished a chapter — sense of accomplishment.
await HappyReview.instance.configure(
storageAdapter: myStorage,
triggers: [
const HappyTrigger(eventName: 'module_completed', minOccurrences: 3),
const HappyTrigger(eventName: 'certificate_earned', minOccurrences: 1),
],
dialogAdapter: DefaultReviewDialogAdapter(
preDialogConfig: const DefaultPreDialogConfig(
title: 'Congrats on your progress!',
positiveLabel: 'Love learning here!',
negativeLabel: 'Needs improvement',
),
),
);
Direct OS review — No emotional filter
Skip the pre-dialog entirely and request the OS review directly when triggers fire. Useful when you've already validated satisfaction through other means.
await HappyReview.instance.configure(
storageAdapter: myStorage,
triggers: [
const HappyTrigger(eventName: 'nps_score_9_or_10', minOccurrences: 1),
],
// No dialogAdapter → OS review is requested directly.
);
Simple launch count — advanced_in_app_review style
If you still prefer the launch-count approach (e.g., ask after 5 app opens), Happy Review supports it — though we recommend event-driven triggers for better results.
await HappyReview.instance.configure(
storageAdapter: myStorage,
triggers: [
const HappyTrigger(eventName: 'app_opened', minOccurrences: 5),
],
// No dialogAdapter, no conditions — just launch count + OS review.
);
// Call on every app start:
await HappyReview.instance.logEvent(context, 'app_opened');
Remote kill switch — Firebase Remote Config
Disable review prompts instantly without deploying a new version.
final remoteConfig = FirebaseRemoteConfig.instance;
await remoteConfig.fetchAndActivate();
await HappyReview.instance.configure(
storageAdapter: myStorage,
enabled: remoteConfig.getBool('enable_review_prompt'),
triggers: [
const HappyTrigger(eventName: 'purchase_completed', minOccurrences: 3),
],
dialogAdapter: DefaultReviewDialogAdapter(),
);
// Or toggle at runtime:
HappyReview.instance.setEnabled(remoteConfig.getBool('enable_review_prompt'));
Full Example
See the example app for a complete working demo that simulates an e-commerce happy flow with prerequisites, debug panel, and kill switch.
Context-First Development
This project follows the Context-First Development (CFD) methodology for AI-assisted development. The repository is structured so that AI agents can operate with persistent, accurate, and efficient context — reducing token waste and eliminating repeated explanations across sessions.
Key elements:
CLAUDE.md— Root context index referencing specialized documentsdoc/— Architecture, stack, conventions, and current project statusdoc/decisions/— Architecture Decision Records (ADRs) for every significant choice.claude/commands/— Slash commands for automated context maintenance
Contributing
Contributions are welcome! Please open an issue or submit a pull request on GitHub.
License
MIT
Libraries
- happy_review
- A strategic in-app review library for Flutter.
- testing
- Test utilities for the happy_review package.