Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -21,6 +21,7 @@ export 'feed_item_density.dart';
export 'feed_item_image_style.dart';
export 'headline_report_reason.dart';
export 'moderation_status.dart';
export 'positive_interaction_type.dart';
export 'push_notification_provider.dart';
export 'push_notification_subscription_delivery_type.dart';
export 'reaction_type.dart';
Expand Down
25 changes: 25 additions & 0 deletions lib/src/enums/positive_interaction_type.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import 'package:json_annotation/json_annotation.dart';

/// {@template positive_interaction_type}
/// Defines the abstract types of user actions that can be considered positive
/// interactions, potentially contributing to triggering events like the
/// in-app review prompt.
/// {@endtemplate}
@JsonEnum()
enum PositiveInteractionType {
/// The user saved a content item (e.g., a headline).
@JsonValue('saveItem')
saveItem,

/// The user followed an entity (e.g., a topic, source, or country).
@JsonValue('followItem')
followItem,

/// The user shared a content item (e.g., a headline).
@JsonValue('shareContent')
shareContent,

/// The user created a saved filter.
@JsonValue('saveFilter')
saveFilter,
}
6 changes: 3 additions & 3 deletions lib/src/enums/reportable_entity.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ enum ReportableEntity {
@JsonValue('source')
source,

/// The report is for a user engagement (mainly for engagements with comments).
@JsonValue('engagement')
engagement,
/// The report is for a user engagement comment.
@JsonValue('comment')
comment,
}
23 changes: 19 additions & 4 deletions lib/src/fixtures/engagements.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,32 @@ List<Engagement> getEngagementsFixturesData({
final user = users[i];
final headline = headlines[index];
final reaction = reactions[index];
// Pair every other reaction with a comment for variety
final comment = index.isEven ? comments[index] : null;
final comment = comments[index];

Reaction? engagementReaction;
Comment? engagementComment;

// Create varied engagement types for realistic test data.
if (index % 3 == 0) {
// Engagement with both reaction and comment
engagementReaction = reaction;
engagementComment = comment;
} else if (index % 3 == 1) {
// Engagement with only a reaction
engagementReaction = reaction;
} else {
// Engagement with only a comment
engagementComment = comment;
}

engagements.add(
Engagement(
id: _engagementIds[index],
userId: user.id,
entityId: headline.id,
entityType: EngageableType.headline,
reaction: reaction,
comment: comment,
reaction: engagementReaction,
comment: engagementComment,
createdAt: referenceTime.subtract(Duration(days: i, hours: j)),
updatedAt: referenceTime.subtract(Duration(days: i, hours: j)),
),
Expand Down
10 changes: 7 additions & 3 deletions lib/src/fixtures/remote_configs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,13 @@ final remoteConfigsFixturesData = <RemoteConfig>[
),
appReview: AppReviewConfig(
enabled: true,
// User must perform 5 positive actions (e.g., save headline)
// to become eligible for the review prompt.
positiveInteractionThreshold: 5,
interactionCycleThreshold: 5,
eligiblePositiveInteractions: [
PositiveInteractionType.saveItem,
PositiveInteractionType.followItem,
PositiveInteractionType.shareContent,
PositiveInteractionType.saveFilter,
],
initialPromptCooldownDays: 3,
isPositiveFeedbackFollowUpEnabled: true,
isNegativeFeedbackFollowUpEnabled: true,
Expand Down
2 changes: 1 addition & 1 deletion lib/src/fixtures/reports.dart
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ List<Report> getReportsFixturesData({DateTime? now}) {
Report(
id: reportIds[i],
reporterUserId: user.id,
entityType: ReportableEntity.engagement,
entityType: ReportableEntity.comment,
entityId: engagementsWithComments[i].id,
reason: commentReasons[i % commentReasons.length].name,
additionalComments: 'This comment is spam.',
Expand Down
31 changes: 21 additions & 10 deletions lib/src/models/config/app_review_config.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:core/src/enums/positive_interaction_type.dart';
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:meta/meta.dart';
Expand All @@ -15,8 +16,9 @@ part 'app_review_config.g.dart';
/// ### Architectural Workflow
///
/// 1. **Eligibility**: A user becomes eligible to see the internal prompt after
/// reaching the [positiveInteractionThreshold] of positive actions (e.g.,
/// saving headlines).
/// performing a total number of positive actions, as defined in
/// [eligiblePositiveInteractions]. The required number of actions is set by
/// `interactionCycleThreshold`.
///
/// 2. **Display Logic**: The `FeedDecoratorType.rateApp` decorator's visibility
/// is controlled by the user's `UserFeedDecoratorStatus` for `rateApp`. The
Expand Down Expand Up @@ -50,8 +52,9 @@ class AppReviewConfig extends Equatable {
/// {@macro app_review_config}
const AppReviewConfig({
required this.enabled,
required this.positiveInteractionThreshold,
required this.interactionCycleThreshold,
required this.initialPromptCooldownDays,
required this.eligiblePositiveInteractions,
required this.isNegativeFeedbackFollowUpEnabled,
required this.isPositiveFeedbackFollowUpEnabled,
});
Expand All @@ -63,14 +66,18 @@ class AppReviewConfig extends Equatable {
/// 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 positive interactions required to trigger the review prompt.
/// the user's action counter resets after each prompt cycle.
final int interactionCycleThreshold;

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

/// A list of user actions that are considered "positive" and count towards
/// the `interactionCycleThreshold`.
final List<PositiveInteractionType> eligiblePositiveInteractions;

/// 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;
Expand All @@ -87,8 +94,9 @@ class AppReviewConfig extends Equatable {
@override
List<Object> get props => [
enabled,
positiveInteractionThreshold,
interactionCycleThreshold,
initialPromptCooldownDays,
eligiblePositiveInteractions,
isNegativeFeedbackFollowUpEnabled,
isPositiveFeedbackFollowUpEnabled,
];
Expand All @@ -97,17 +105,20 @@ class AppReviewConfig extends Equatable {
/// replaced with the new values.
AppReviewConfig copyWith({
bool? enabled,
int? positiveInteractionThreshold,
int? interactionCycleThreshold,
int? initialPromptCooldownDays,
List<PositiveInteractionType>? eligiblePositiveInteractions,
bool? isNegativeFeedbackFollowUpEnabled,
bool? isPositiveFeedbackFollowUpEnabled,
}) {
return AppReviewConfig(
enabled: enabled ?? this.enabled,
positiveInteractionThreshold:
positiveInteractionThreshold ?? this.positiveInteractionThreshold,
interactionCycleThreshold:
interactionCycleThreshold ?? this.interactionCycleThreshold,
initialPromptCooldownDays:
initialPromptCooldownDays ?? this.initialPromptCooldownDays,
eligiblePositiveInteractions:
eligiblePositiveInteractions ?? this.eligiblePositiveInteractions,
isNegativeFeedbackFollowUpEnabled:
isNegativeFeedbackFollowUpEnabled ??
this.isNegativeFeedbackFollowUpEnabled,
Expand Down
22 changes: 19 additions & 3 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.

22 changes: 13 additions & 9 deletions lib/src/models/user_generated_content/engagement.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ part 'engagement.g.dart';

/// {@template engagement}
/// Represents a user's engagement with a specific piece of content.
/// An engagement consists of a mandatory reaction and an optional comment,
/// and is stored as a single document in the database.
///
/// An engagement must contain at least a [reaction] or a [comment], or both.
/// This is enforced by an assertion in the constructor.
/// {@endtemplate}
@immutable
@JsonSerializable(explicitToJson: true, includeIfNull: true, checked: true)
Expand All @@ -20,11 +21,14 @@ class Engagement extends Equatable {
required this.userId,
required this.entityId,
required this.entityType,
required this.reaction,
required this.createdAt,
required this.updatedAt,
this.reaction,
this.comment,
});
}) : assert(
reaction != null || comment != null,
'An engagement must have at least a reaction or a comment.',
);

/// Creates an [Engagement] from JSON data.
factory Engagement.fromJson(Map<String, dynamic> json) =>
Expand All @@ -42,10 +46,10 @@ class Engagement extends Equatable {
/// The type of entity being engaged with.
final EngageableType entityType;

/// The user's reaction. This is a mandatory part of the engagement.
final Reaction reaction;
/// The user's reaction. Can be null if a comment is provided.
final Reaction? reaction;

/// The user's optional comment, provided along with the reaction.
/// The user's comment. Can be null if a reaction is provided.
final Comment? comment;

/// The timestamp when the engagement was created.
Expand Down Expand Up @@ -77,7 +81,7 @@ class Engagement extends Equatable {
String? userId,
String? entityId,
EngageableType? entityType,
Reaction? reaction,
ValueWrapper<Reaction?>? reaction,
ValueWrapper<Comment?>? comment,
DateTime? createdAt,
}) {
Expand All @@ -86,7 +90,7 @@ class Engagement extends Equatable {
userId: userId ?? this.userId,
entityId: entityId ?? this.entityId,
entityType: entityType ?? this.entityType,
reaction: reaction ?? this.reaction,
reaction: reaction != null ? reaction.value : this.reaction,
comment: comment != null ? comment.value : this.comment,
createdAt: createdAt ?? this.createdAt,
updatedAt: DateTime.now(),
Expand Down
11 changes: 6 additions & 5 deletions lib/src/models/user_generated_content/engagement.g.dart

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

2 changes: 1 addition & 1 deletion lib/src/models/user_generated_content/report.g.dart

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

49 changes: 49 additions & 0 deletions test/src/enums/positive_interaction_type_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import 'package:core/src/enums/positive_interaction_type.dart';
import 'package:test/test.dart';

void main() {
group('PositiveInteractionType', () {
test('has correct number of values', () {
expect(PositiveInteractionType.values.length, 4);
});

group('serialization', () {
test('uses correct string values for json serialization', () {
// This test verifies that the enum's string representation,
// which is used by json_serializable, matches the expected value.
expect(PositiveInteractionType.saveItem.name, 'saveItem');
expect(PositiveInteractionType.followItem.name, 'followItem');
expect(PositiveInteractionType.shareContent.name, 'shareContent');
expect(PositiveInteractionType.saveFilter.name, 'saveFilter');
});

test('can be created from string value for json deserialization', () {
// This test verifies that the enum can be created from its
// string representation, mimicking json_serializable's behavior.
expect(
PositiveInteractionType.values.byName('saveItem'),
PositiveInteractionType.saveItem,
);
expect(
PositiveInteractionType.values.byName('followItem'),
PositiveInteractionType.followItem,
);
expect(
PositiveInteractionType.values.byName('shareContent'),
PositiveInteractionType.shareContent,
);
expect(
PositiveInteractionType.values.byName('saveFilter'),
PositiveInteractionType.saveFilter,
);
});

test('throws ArgumentError for invalid string value', () {
expect(
() => PositiveInteractionType.values.byName('invalid_interaction'),
throwsA(isA<ArgumentError>()),
);
});
});
});
}
Loading
Loading