Skip to content

Commit a3f1629

Browse files
authored
Merge pull request #86 from flutter-news-app-full-source-code/feat/app-review-required-data
Feat/app review required data
2 parents 4ffe883 + d7d1b03 commit a3f1629

24 files changed

+724
-34
lines changed

lib/src/enums/enums.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export 'feed_item_click_behavior.dart';
2020
export 'feed_item_density.dart';
2121
export 'feed_item_image_style.dart';
2222
export 'headline_report_reason.dart';
23+
export 'initial_app_review_feedback.dart';
2324
export 'push_notification_provider.dart';
2425
export 'push_notification_subscription_delivery_type.dart';
2526
export 'reaction_type.dart';
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import 'package:json_annotation/json_annotation.dart';
2+
3+
/// {@template initial_app_review_feedback}
4+
/// Represents the user's response to the initial, private app review prompt
5+
/// (e.g., "Are you enjoying the app?").
6+
/// {@endtemplate}
7+
@JsonEnum()
8+
enum InitialAppReviewFeedback {
9+
/// The user indicated a positive experience.
10+
@JsonValue('positive')
11+
positive,
12+
13+
/// The user indicated a negative experience.
14+
@JsonValue('negative')
15+
negative,
16+
}

lib/src/fixtures/app_reviews.dart

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import 'package:core/core.dart';
2+
3+
/// Generates a list of predefined app reviews for fixture data.
4+
List<AppReview> getAppReviewsFixturesData({
5+
String languageCode = 'en',
6+
DateTime? now,
7+
}) {
8+
final appReviews = <AppReview>[];
9+
final users = usersFixturesData;
10+
final referenceTime = now ?? DateTime.now();
11+
12+
final reasonsByLang = <String, List<String>>{
13+
'en': [
14+
'The app is a bit slow when loading the main feed.',
15+
'I did not like the old layout.',
16+
'Still experiencing some lag on the search page.',
17+
'Crashes sometimes when I open a notification.',
18+
],
19+
'ar': [
20+
'التطبيق بطيء بعض الشيء عند تحميل الواجهة الرئيسية.',
21+
'لم يعجبني التصميم القديم.',
22+
'لا أزال أواجه بعض البطء في صفحة البحث.',
23+
'يتوقف أحيانًا عند فتح إشعار.',
24+
],
25+
};
26+
27+
final resolvedLanguageCode = ['en', 'ar'].contains(languageCode)
28+
? languageCode
29+
: 'en';
30+
final reasons = reasonsByLang[resolvedLanguageCode]!;
31+
32+
for (var i = 0; i < users.length; i++) {
33+
final user = users[i];
34+
final createdAt = referenceTime.subtract(Duration(days: 30 - i));
35+
36+
// Every 3rd user gives a positive review and is sent to the store.
37+
if (i % 3 == 0) {
38+
appReviews.add(
39+
AppReview(
40+
id: 'ar-pos-$i',
41+
userId: user.id,
42+
initialFeedback: InitialAppReviewFeedback.positive,
43+
createdAt: createdAt,
44+
updatedAt: createdAt.add(const Duration(minutes: 1)),
45+
wasStoreReviewRequested: true,
46+
),
47+
);
48+
}
49+
// Every 5th user gives a negative review with a reason.
50+
else if (i % 5 == 0) {
51+
appReviews.add(
52+
AppReview(
53+
id: 'ar-neg-reason-$i',
54+
userId: user.id,
55+
initialFeedback: InitialAppReviewFeedback.negative,
56+
createdAt: createdAt,
57+
updatedAt: createdAt,
58+
negativeFeedbackHistory: [
59+
NegativeFeedback(
60+
providedAt: createdAt,
61+
reason: reasons[i % reasons.length],
62+
),
63+
],
64+
),
65+
);
66+
}
67+
// Other users give a negative review without a reason.
68+
else {
69+
appReviews.add(
70+
AppReview(
71+
id: 'ar-neg-$i',
72+
userId: user.id,
73+
initialFeedback: InitialAppReviewFeedback.negative,
74+
createdAt: createdAt,
75+
updatedAt: createdAt,
76+
),
77+
);
78+
}
79+
}
80+
81+
// Add a case where a user gave negative feedback, then was prompted again
82+
// later and gave positive feedback.
83+
final multiStageUser = users[1];
84+
final firstReviewTime = referenceTime.subtract(const Duration(days: 45));
85+
final secondReviewTime = referenceTime.subtract(const Duration(days: 5));
86+
87+
// This would be an update to an existing record, but for fixtures we can
88+
// just show the final state.
89+
appReviews.add(
90+
AppReview(
91+
id: 'ar-multistage-final',
92+
userId: multiStageUser.id,
93+
initialFeedback: InitialAppReviewFeedback.positive,
94+
// createdAt would be from the first interaction
95+
createdAt: firstReviewTime,
96+
// updatedAt is from the most recent interaction
97+
updatedAt: secondReviewTime,
98+
// The reason from the first negative review might be cleared or kept,
99+
// depending on business logic. Here we assume it's cleared on positive.
100+
wasStoreReviewRequested: true,
101+
// The history might be kept for analytics, even after a positive review.
102+
negativeFeedbackHistory: [
103+
NegativeFeedback(providedAt: firstReviewTime, reason: reasons[1]),
104+
],
105+
),
106+
);
107+
108+
// Add a case for a user who gave negative feedback multiple times.
109+
final persistentNegativeUser = users[2];
110+
final firstNegativeTime = referenceTime.subtract(const Duration(days: 60));
111+
final secondNegativeTime = referenceTime.subtract(const Duration(days: 20));
112+
appReviews.add(
113+
AppReview(
114+
id: 'ar-multi-neg',
115+
userId: persistentNegativeUser.id,
116+
initialFeedback: InitialAppReviewFeedback.negative,
117+
createdAt: firstNegativeTime,
118+
updatedAt: secondNegativeTime,
119+
negativeFeedbackHistory: [
120+
NegativeFeedback(providedAt: firstNegativeTime, reason: reasons[2]),
121+
NegativeFeedback(providedAt: secondNegativeTime, reason: reasons[3]),
122+
],
123+
),
124+
);
125+
126+
return appReviews;
127+
}

lib/src/fixtures/fixtures.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export 'app_reviews.dart';
12
export 'app_settings.dart';
23
export 'countries.dart';
34
export 'dashboard_summary.dart';

lib/src/fixtures/remote_configs.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,20 +215,24 @@ final remoteConfigsFixturesData = <RemoteConfig>[
215215
},
216216
),
217217
community: CommunityConfig(
218+
enabled: true,
218219
engagement: EngagementConfig(
219220
enabled: true,
220221
engagementMode: EngagementMode.reactionsAndComments,
221222
),
222223
reporting: ReportingConfig(
224+
enabled: true,
223225
headlineReportingEnabled: true,
224226
sourceReportingEnabled: true,
225227
commentReportingEnabled: true,
226228
),
227229
appReview: AppReviewConfig(
230+
enabled: true,
228231
// User must perform 5 positive actions (e.g., save headline)
229232
// to become eligible for the review prompt.
230233
positiveInteractionThreshold: 5,
231-
initialPromptCooldownDays: 14,
234+
initialPromptCooldownDays: 3,
235+
isNegativeFeedbackFollowUpEnabled: true,
232236
),
233237
),
234238
),

lib/src/models/config/app_review_config.dart

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,69 +7,101 @@ part 'app_review_config.g.dart';
77
/// {@template app_review_config}
88
/// Defines the remote configuration for the two-layer App Review Funnel.
99
///
10-
/// This system strategically prompts engaged users for feedback to maximize
11-
/// positive public reviews while capturing constructive criticism privately.
10+
/// This system strategically prompts engaged users for feedback to maximize positive
11+
/// public reviews while capturing constructive criticism privately. It uses a
12+
/// combination of this configuration, the `UserFeedDecoratorStatus` model, and
13+
/// the `AppReview` model to manage the user's journey.
1214
///
13-
/// ### How It Works
15+
/// ### Architectural Workflow
1416
///
15-
/// 1. **Trigger**: A user becomes eligible to see the prompt after reaching
16-
/// the [positiveInteractionThreshold] of positive actions (e.g., saves).
17+
/// 1. **Eligibility**: A user becomes eligible to see the internal prompt after
18+
/// reaching the [positiveInteractionThreshold] of positive actions (e.g.,
19+
/// saving headlines).
1720
///
18-
/// 2. **Prompt**: The `FeedDecoratorType.rateApp` decorator asks the user
19-
/// "Are you enjoying the app?". The display logic is managed by the user's
20-
/// `UserFeedDecoratorStatus` for `rateApp`, which respects the
21-
/// [initialPromptCooldownDays].
21+
/// 2. **Display Logic**: The `FeedDecoratorType.rateApp` decorator's visibility
22+
/// is controlled by the user's `UserFeedDecoratorStatus` for `rateApp`. The
23+
/// decorator is only shown if `isCompleted` is `false` and the cooldown
24+
/// period (defined here as [initialPromptCooldownDays]) has passed since
25+
/// `lastShownAt`.
2226
///
23-
/// 3. **Action**:
24-
/// - **On "Yes"**: The client sets `isCompleted` to `true` on the user's
25-
/// `UserFeedDecoratorStatus` for `rateApp` and immediately triggers the
26-
/// native OS in-app review dialog if applicable ie the app is hosted in
27-
/// google play or apple store. The prompt will not be shown again.
28-
/// - **On "No"**: The client only updates the `lastShownAt` timestamp on
29-
/// the status object. The prompt will not be shown again until the
30-
/// cooldown period has passed. No public review is requested.
27+
/// 3. **User Interaction & State Change**:
28+
/// - **On "Yes" (Positive Feedback)**:
29+
/// - An `AppReview` record is created/updated with `initialFeedback: positive`
30+
/// and `wasStoreReviewRequested` is set to `true`.
31+
/// - The native OS in-app review dialog is immediately triggered. This is a
32+
/// "fire-and-forget" action; the OS controls if the dialog appears and
33+
/// provides no feedback to the app.
34+
/// - The `UserFeedDecoratorStatus` for `rateApp` has its `isCompleted` flag
35+
/// set to `true`, **permanently preventing the internal prompt from
36+
/// appearing again for this user.**
37+
///
38+
/// - **On "No" (Negative Feedback)**:
39+
/// - An `AppReview` record is created/updated with `initialFeedback: negative`.
40+
/// The app may optionally collect a reason, which is stored in the
41+
/// `negativeFeedbackHistory`.
42+
/// - The `UserFeedDecoratorStatus` for `rateApp` only has its `lastShownAt`
43+
/// timestamp updated. `isCompleted` remains `false`.
44+
/// - The prompt will not be shown again until the cooldown period has
45+
/// passed, at which point the user may be asked again.
3146
/// {@endtemplate}
3247
@immutable
3348
@JsonSerializable(explicitToJson: true, includeIfNull: true, checked: true)
3449
class AppReviewConfig extends Equatable {
3550
/// {@macro app_review_config}
3651
const AppReviewConfig({
52+
required this.enabled,
3753
required this.positiveInteractionThreshold,
3854
required this.initialPromptCooldownDays,
55+
required this.isNegativeFeedbackFollowUpEnabled,
3956
});
4057

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

62+
/// A master switch to enable or disable the entire app review funnel.
63+
final bool enabled;
64+
4565
/// The number of positive interactions (e.g., saving a headline) required
4666
/// to trigger the initial review prompt.
4767
final int positiveInteractionThreshold;
4868

4969
/// The number of days to wait before showing the initial prompt again if the
50-
/// user dismisses it.
70+
/// user provides negative feedback.
5171
final int initialPromptCooldownDays;
5272

73+
/// A switch to enable or disable the follow-up prompt that asks for a
74+
/// text reason after a user provides negative feedback.
75+
final bool isNegativeFeedbackFollowUpEnabled;
76+
5377
/// Converts this [AppReviewConfig] instance to JSON data.
5478
Map<String, dynamic> toJson() => _$AppReviewConfigToJson(this);
5579

5680
@override
5781
List<Object> get props => [
82+
enabled,
5883
positiveInteractionThreshold,
5984
initialPromptCooldownDays,
85+
isNegativeFeedbackFollowUpEnabled,
6086
];
6187

6288
/// Creates a copy of this [AppReviewConfig] but with the given fields
6389
/// replaced with the new values.
6490
AppReviewConfig copyWith({
91+
bool? enabled,
6592
int? positiveInteractionThreshold,
6693
int? initialPromptCooldownDays,
94+
bool? isNegativeFeedbackFollowUpEnabled,
6795
}) {
6896
return AppReviewConfig(
97+
enabled: enabled ?? this.enabled,
6998
positiveInteractionThreshold:
7099
positiveInteractionThreshold ?? this.positiveInteractionThreshold,
71100
initialPromptCooldownDays:
72101
initialPromptCooldownDays ?? this.initialPromptCooldownDays,
102+
isNegativeFeedbackFollowUpEnabled:
103+
isNegativeFeedbackFollowUpEnabled ??
104+
this.isNegativeFeedbackFollowUpEnabled,
73105
);
74106
}
75107
}

lib/src/models/config/app_review_config.g.dart

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/src/models/config/community_config.dart

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ part 'community_config.g.dart';
1818
class CommunityConfig extends Equatable {
1919
/// {@macro community_config}
2020
const CommunityConfig({
21+
required this.enabled,
2122
required this.engagement,
2223
required this.reporting,
2324
required this.appReview,
@@ -27,6 +28,9 @@ class CommunityConfig extends Equatable {
2728
factory CommunityConfig.fromJson(Map<String, dynamic> json) =>
2829
_$CommunityConfigFromJson(json);
2930

31+
/// A master switch to enable or disable all community features.
32+
final bool enabled;
33+
3034
/// Configuration for user engagement features (reactions, comments).
3135
final EngagementConfig engagement;
3236

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

4246
@override
43-
List<Object> get props => [engagement, reporting, appReview];
47+
List<Object> get props => [enabled, engagement, reporting, appReview];
4448

4549
/// Creates a copy of this [CommunityConfig] but with the given fields
4650
/// replaced with the new values.
4751
CommunityConfig copyWith({
52+
bool? enabled,
4853
EngagementConfig? engagement,
4954
ReportingConfig? reporting,
5055
AppReviewConfig? appReview,
5156
}) {
5257
return CommunityConfig(
58+
enabled: enabled ?? this.enabled,
5359
engagement: engagement ?? this.engagement,
5460
reporting: reporting ?? this.reporting,
5561
appReview: appReview ?? this.appReview,

lib/src/models/config/community_config.g.dart

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)