Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
aa9ce5f
docs(app_review_config): enhance documentation for clarity and comple…
fulleni Nov 30, 2025
93a0ef7
feat(enums): add InitialAppReviewAnswer enum
fulleni Nov 30, 2025
1e58fd1
feat(core): add NegativeFeedback model for tracking user's negative s…
fulleni Nov 30, 2025
7b35980
chore: barrels
fulleni Nov 30, 2025
82443a7
style: format
fulleni Nov 30, 2025
9e39540
fix(app-review): add fixture data for testing and demonstration
fulleni Nov 30, 2025
b06085d
refactor(enums): rename InitialAppReviewAnswer to InitialAppReviewFee…
fulleni Nov 30, 2025
cd7bf9d
test(core): add unit tests for InitialAppReviewFeedback enum
fulleni Nov 30, 2025
a9b9e5b
style: format
fulleni Nov 30, 2025
c62aa08
refactor(ugc): rename InitialAppReviewAnswer to InitialAppReviewFeedback
fulleni Nov 30, 2025
c8023a9
test(user_generated_content): add comprehensive tests for AppReview m…
fulleni Nov 30, 2025
0be17e7
test(user_generated_content): add NegativeFeedback model tests
fulleni Nov 30, 2025
142937f
docs(app_review): enhance architectural documentation for two-layer r…
fulleni Nov 30, 2025
3f3256a
refactor(user_generated_content): replace DateTime with bool for stor…
fulleni Nov 30, 2025
c4a363f
docs(app_review_config): update documentation for positive feedback
fulleni Nov 30, 2025
b280039
refactor(fixtures): update app review model field
fulleni Nov 30, 2025
dae5a14
test: update app review tests to check store review request status
fulleni Nov 30, 2025
1880471
build(serialization): sync
fulleni Nov 30, 2025
a1db254
refactor: update import path for utils
fulleni Nov 30, 2025
2ede413
feat(config): enhance app review configuration options
fulleni Nov 30, 2025
cb9bab6
feat(ReportingConfig): add master switch for enabling/disabling repor…
fulleni Nov 30, 2025
756be6a
feat(config): add master switch for community features
fulleni Nov 30, 2025
04543a9
fix(remote-config): update initial prompt cooldown days to 3
fulleni Nov 30, 2025
3163c40
test(config): add CommunityConfig tests
fulleni Nov 30, 2025
27181c8
test(config): improve ReportingConfig tests
fulleni Nov 30, 2025
5493229
build(serialization): sync
fulleni Nov 30, 2025
0608c51
build(serialization): sync
fulleni Nov 30, 2025
cc0d5e7
fix(fixtures): rotate negative feedback reasons in app reviews
fulleni Nov 30, 2025
d7d1b03
refactor(app_review): use helper functions for date time serialization
fulleni Nov 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/src/enums/enums.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export 'feed_item_click_behavior.dart';
export 'feed_item_density.dart';
export 'feed_item_image_style.dart';
export 'headline_report_reason.dart';
export 'initial_app_review_feedback.dart';
export 'push_notification_provider.dart';
export 'push_notification_subscription_delivery_type.dart';
export 'reaction_type.dart';
Expand Down
16 changes: 16 additions & 0 deletions lib/src/enums/initial_app_review_feedback.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import 'package:json_annotation/json_annotation.dart';

/// {@template initial_app_review_feedback}
/// Represents the user's response to the initial, private app review prompt
/// (e.g., "Are you enjoying the app?").
/// {@endtemplate}
@JsonEnum()
enum InitialAppReviewFeedback {
/// The user indicated a positive experience.
@JsonValue('positive')
positive,

/// The user indicated a negative experience.
@JsonValue('negative')
negative,
}
127 changes: 127 additions & 0 deletions lib/src/fixtures/app_reviews.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import 'package:core/core.dart';

/// Generates a list of predefined app reviews for fixture data.
List<AppReview> getAppReviewsFixturesData({
String languageCode = 'en',
DateTime? now,
}) {
final appReviews = <AppReview>[];
final users = usersFixturesData;
final referenceTime = now ?? DateTime.now();

final reasonsByLang = <String, List<String>>{
'en': [
'The app is a bit slow when loading the main feed.',
'I did not like the old layout.',
'Still experiencing some lag on the search page.',
'Crashes sometimes when I open a notification.',
],
'ar': [
'التطبيق بطيء بعض الشيء عند تحميل الواجهة الرئيسية.',
'لم يعجبني التصميم القديم.',
'لا أزال أواجه بعض البطء في صفحة البحث.',
'يتوقف أحيانًا عند فتح إشعار.',
],
};

final resolvedLanguageCode = ['en', 'ar'].contains(languageCode)
? languageCode
: 'en';
final reasons = reasonsByLang[resolvedLanguageCode]!;

for (var i = 0; i < users.length; i++) {
final user = users[i];
final createdAt = referenceTime.subtract(Duration(days: 30 - i));

// Every 3rd user gives a positive review and is sent to the store.
if (i % 3 == 0) {
appReviews.add(
AppReview(
id: 'ar-pos-$i',
userId: user.id,
initialFeedback: InitialAppReviewFeedback.positive,
createdAt: createdAt,
updatedAt: createdAt.add(const Duration(minutes: 1)),
wasStoreReviewRequested: true,
),
);
}
// Every 5th user gives a negative review with a reason.
else if (i % 5 == 0) {
appReviews.add(
AppReview(
id: 'ar-neg-reason-$i',
userId: user.id,
initialFeedback: InitialAppReviewFeedback.negative,
createdAt: createdAt,
updatedAt: createdAt,
negativeFeedbackHistory: [
NegativeFeedback(
providedAt: createdAt,
reason: reasons[i % reasons.length],
),
],
),
);
}
// Other users give a negative review without a reason.
else {
appReviews.add(
AppReview(
id: 'ar-neg-$i',
userId: user.id,
initialFeedback: InitialAppReviewFeedback.negative,
createdAt: createdAt,
updatedAt: createdAt,
),
);
}
}

// Add a case where a user gave negative feedback, then was prompted again
// later and gave positive feedback.
final multiStageUser = users[1];
final firstReviewTime = referenceTime.subtract(const Duration(days: 45));
final secondReviewTime = referenceTime.subtract(const Duration(days: 5));

// This would be an update to an existing record, but for fixtures we can
// just show the final state.
appReviews.add(
AppReview(
id: 'ar-multistage-final',
userId: multiStageUser.id,
initialFeedback: InitialAppReviewFeedback.positive,
// createdAt would be from the first interaction
createdAt: firstReviewTime,
// updatedAt is from the most recent interaction
updatedAt: secondReviewTime,
// The reason from the first negative review might be cleared or kept,
// depending on business logic. Here we assume it's cleared on positive.
wasStoreReviewRequested: true,
// The history might be kept for analytics, even after a positive review.
negativeFeedbackHistory: [
NegativeFeedback(providedAt: firstReviewTime, reason: reasons[1]),
],
),
);

// Add a case for a user who gave negative feedback multiple times.
final persistentNegativeUser = users[2];
final firstNegativeTime = referenceTime.subtract(const Duration(days: 60));
final secondNegativeTime = referenceTime.subtract(const Duration(days: 20));
appReviews.add(
AppReview(
id: 'ar-multi-neg',
userId: persistentNegativeUser.id,
initialFeedback: InitialAppReviewFeedback.negative,
createdAt: firstNegativeTime,
updatedAt: secondNegativeTime,
negativeFeedbackHistory: [
NegativeFeedback(providedAt: firstNegativeTime, reason: reasons[2]),
NegativeFeedback(providedAt: secondNegativeTime, reason: reasons[3]),
],
),
);

return appReviews;
}
1 change: 1 addition & 0 deletions lib/src/fixtures/fixtures.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export 'app_reviews.dart';
export 'app_settings.dart';
export 'countries.dart';
export 'dashboard_summary.dart';
Expand Down
6 changes: 5 additions & 1 deletion lib/src/fixtures/remote_configs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -215,20 +215,24 @@ final remoteConfigsFixturesData = <RemoteConfig>[
},
),
community: CommunityConfig(
enabled: true,
engagement: EngagementConfig(
enabled: true,
engagementMode: EngagementMode.reactionsAndComments,
),
reporting: ReportingConfig(
enabled: true,
headlineReportingEnabled: true,
sourceReportingEnabled: true,
commentReportingEnabled: true,
),
appReview: AppReviewConfig(
enabled: true,
// User must perform 5 positive actions (e.g., save headline)
// to become eligible for the review prompt.
positiveInteractionThreshold: 5,
initialPromptCooldownDays: 14,
initialPromptCooldownDays: 3,
isNegativeFeedbackFollowUpEnabled: true,
),
),
),
Expand Down
68 changes: 50 additions & 18 deletions lib/src/models/config/app_review_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,69 +7,101 @@ part 'app_review_config.g.dart';
/// {@template app_review_config}
/// Defines the remote configuration for the two-layer App Review Funnel.
///
/// This system strategically prompts engaged users for feedback to maximize
/// positive public reviews while capturing constructive criticism privately.
/// This system strategically prompts engaged users for feedback to maximize positive
/// public reviews while capturing constructive criticism privately. It uses a
/// combination of this configuration, the `UserFeedDecoratorStatus` model, and
/// the `AppReview` model to manage the user's journey.
///
/// ### How It Works
/// ### Architectural Workflow
///
/// 1. **Trigger**: A user becomes eligible to see the prompt after reaching
/// the [positiveInteractionThreshold] of positive actions (e.g., saves).
/// 1. **Eligibility**: A user becomes eligible to see the internal prompt after
/// reaching the [positiveInteractionThreshold] of positive actions (e.g.,
/// saving headlines).
///
/// 2. **Prompt**: The `FeedDecoratorType.rateApp` decorator asks the user
/// "Are you enjoying the app?". The display logic is managed by the user's
/// `UserFeedDecoratorStatus` for `rateApp`, which respects the
/// [initialPromptCooldownDays].
/// 2. **Display Logic**: The `FeedDecoratorType.rateApp` decorator's visibility
/// is controlled by the user's `UserFeedDecoratorStatus` for `rateApp`. The
/// decorator is only shown if `isCompleted` is `false` and the cooldown
/// period (defined here as [initialPromptCooldownDays]) has passed since
/// `lastShownAt`.
///
/// 3. **Action**:
/// - **On "Yes"**: The client sets `isCompleted` to `true` on the user's
/// `UserFeedDecoratorStatus` for `rateApp` and immediately triggers the
/// native OS in-app review dialog if applicable ie the app is hosted in
/// google play or apple store. The prompt will not be shown again.
/// - **On "No"**: The client only updates the `lastShownAt` timestamp on
/// the status object. The prompt will not be shown again until the
/// cooldown period has passed. No public review is requested.
/// 3. **User Interaction & State Change**:
/// - **On "Yes" (Positive Feedback)**:
/// - An `AppReview` record is created/updated with `initialFeedback: positive`
/// and `wasStoreReviewRequested` is set to `true`.
/// - The native OS in-app review dialog is immediately triggered. This is a
/// "fire-and-forget" action; the OS controls if the dialog appears and
/// provides no feedback to the app.
/// - The `UserFeedDecoratorStatus` for `rateApp` has its `isCompleted` flag
/// set to `true`, **permanently preventing the internal prompt from
/// appearing again for this user.**
///
/// - **On "No" (Negative Feedback)**:
/// - An `AppReview` record is created/updated with `initialFeedback: negative`.
/// The app may optionally collect a reason, which is stored in the
/// `negativeFeedbackHistory`.
/// - The `UserFeedDecoratorStatus` for `rateApp` only has its `lastShownAt`
/// timestamp updated. `isCompleted` remains `false`.
/// - The prompt will not be shown again until the cooldown period has
/// passed, at which point the user may be asked again.
/// {@endtemplate}
@immutable
@JsonSerializable(explicitToJson: true, includeIfNull: true, checked: true)
class AppReviewConfig extends Equatable {
/// {@macro app_review_config}
const AppReviewConfig({
required this.enabled,
required this.positiveInteractionThreshold,
required this.initialPromptCooldownDays,
required this.isNegativeFeedbackFollowUpEnabled,
});

/// Creates a [AppReviewConfig] from JSON data.
factory AppReviewConfig.fromJson(Map<String, dynamic> json) =>
_$AppReviewConfigFromJson(json);

/// A master switch to enable or disable the entire app review funnel.
final bool enabled;

/// The number of positive interactions (e.g., saving a headline) required
/// to trigger the initial review prompt.
final int positiveInteractionThreshold;

/// The number of days to wait before showing the initial prompt again if the
/// user dismisses it.
/// user provides negative feedback.
final int initialPromptCooldownDays;

/// A switch to enable or disable the follow-up prompt that asks for a
/// text reason after a user provides negative feedback.
final bool isNegativeFeedbackFollowUpEnabled;

/// Converts this [AppReviewConfig] instance to JSON data.
Map<String, dynamic> toJson() => _$AppReviewConfigToJson(this);

@override
List<Object> get props => [
enabled,
positiveInteractionThreshold,
initialPromptCooldownDays,
isNegativeFeedbackFollowUpEnabled,
];

/// Creates a copy of this [AppReviewConfig] but with the given fields
/// replaced with the new values.
AppReviewConfig copyWith({
bool? enabled,
int? positiveInteractionThreshold,
int? initialPromptCooldownDays,
bool? isNegativeFeedbackFollowUpEnabled,
}) {
return AppReviewConfig(
enabled: enabled ?? this.enabled,
positiveInteractionThreshold:
positiveInteractionThreshold ?? this.positiveInteractionThreshold,
initialPromptCooldownDays:
initialPromptCooldownDays ?? this.initialPromptCooldownDays,
isNegativeFeedbackFollowUpEnabled:
isNegativeFeedbackFollowUpEnabled ??
this.isNegativeFeedbackFollowUpEnabled,
);
}
}
8 changes: 8 additions & 0 deletions lib/src/models/config/app_review_config.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion lib/src/models/config/community_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ part 'community_config.g.dart';
class CommunityConfig extends Equatable {
/// {@macro community_config}
const CommunityConfig({
required this.enabled,
required this.engagement,
required this.reporting,
required this.appReview,
Expand All @@ -27,6 +28,9 @@ class CommunityConfig extends Equatable {
factory CommunityConfig.fromJson(Map<String, dynamic> json) =>
_$CommunityConfigFromJson(json);

/// A master switch to enable or disable all community features.
final bool enabled;

/// Configuration for user engagement features (reactions, comments).
final EngagementConfig engagement;

Expand All @@ -40,16 +44,18 @@ class CommunityConfig extends Equatable {
Map<String, dynamic> toJson() => _$CommunityConfigToJson(this);

@override
List<Object> get props => [engagement, reporting, appReview];
List<Object> get props => [enabled, engagement, reporting, appReview];

/// Creates a copy of this [CommunityConfig] but with the given fields
/// replaced with the new values.
CommunityConfig copyWith({
bool? enabled,
EngagementConfig? engagement,
ReportingConfig? reporting,
AppReviewConfig? appReview,
}) {
return CommunityConfig(
enabled: enabled ?? this.enabled,
engagement: engagement ?? this.engagement,
reporting: reporting ?? this.reporting,
appReview: appReview ?? this.appReview,
Expand Down
2 changes: 2 additions & 0 deletions lib/src/models/config/community_config.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading