diff --git a/lib/src/enums/enums.dart b/lib/src/enums/enums.dart index e3cb9ec6..4a6b09eb 100644 --- a/lib/src/enums/enums.dart +++ b/lib/src/enums/enums.dart @@ -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'; diff --git a/lib/src/enums/positive_interaction_type.dart b/lib/src/enums/positive_interaction_type.dart new file mode 100644 index 00000000..318bb828 --- /dev/null +++ b/lib/src/enums/positive_interaction_type.dart @@ -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, +} diff --git a/lib/src/enums/reportable_entity.dart b/lib/src/enums/reportable_entity.dart index 5417e589..ebd2739e 100644 --- a/lib/src/enums/reportable_entity.dart +++ b/lib/src/enums/reportable_entity.dart @@ -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, } diff --git a/lib/src/fixtures/engagements.dart b/lib/src/fixtures/engagements.dart index 55d1c4f3..87092b71 100644 --- a/lib/src/fixtures/engagements.dart +++ b/lib/src/fixtures/engagements.dart @@ -27,8 +27,23 @@ List 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( @@ -36,8 +51,8 @@ List getEngagementsFixturesData({ 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)), ), diff --git a/lib/src/fixtures/remote_configs.dart b/lib/src/fixtures/remote_configs.dart index 973274f5..d2080489 100644 --- a/lib/src/fixtures/remote_configs.dart +++ b/lib/src/fixtures/remote_configs.dart @@ -228,9 +228,13 @@ final remoteConfigsFixturesData = [ ), 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, diff --git a/lib/src/fixtures/reports.dart b/lib/src/fixtures/reports.dart index ea467ac0..ed506bb2 100644 --- a/lib/src/fixtures/reports.dart +++ b/lib/src/fixtures/reports.dart @@ -76,7 +76,7 @@ List 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.', diff --git a/lib/src/models/config/app_review_config.dart b/lib/src/models/config/app_review_config.dart index 65306b79..b081d762 100644 --- a/lib/src/models/config/app_review_config.dart +++ b/lib/src/models/config/app_review_config.dart @@ -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'; @@ -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 @@ -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, }); @@ -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 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; @@ -87,8 +94,9 @@ class AppReviewConfig extends Equatable { @override List get props => [ enabled, - positiveInteractionThreshold, + interactionCycleThreshold, initialPromptCooldownDays, + eligiblePositiveInteractions, isNegativeFeedbackFollowUpEnabled, isPositiveFeedbackFollowUpEnabled, ]; @@ -97,17 +105,20 @@ class AppReviewConfig extends Equatable { /// replaced with the new values. AppReviewConfig copyWith({ bool? enabled, - int? positiveInteractionThreshold, + int? interactionCycleThreshold, int? initialPromptCooldownDays, + List? 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, diff --git a/lib/src/models/config/app_review_config.g.dart b/lib/src/models/config/app_review_config.g.dart index 49c7c1fe..9d7f26a2 100644 --- a/lib/src/models/config/app_review_config.g.dart +++ b/lib/src/models/config/app_review_config.g.dart @@ -10,14 +10,20 @@ AppReviewConfig _$AppReviewConfigFromJson(Map json) => $checkedCreate('AppReviewConfig', json, ($checkedConvert) { final val = AppReviewConfig( enabled: $checkedConvert('enabled', (v) => v as bool), - positiveInteractionThreshold: $checkedConvert( - 'positiveInteractionThreshold', + interactionCycleThreshold: $checkedConvert( + 'interactionCycleThreshold', (v) => (v as num).toInt(), ), initialPromptCooldownDays: $checkedConvert( 'initialPromptCooldownDays', (v) => (v as num).toInt(), ), + eligiblePositiveInteractions: $checkedConvert( + 'eligiblePositiveInteractions', + (v) => (v as List) + .map((e) => $enumDecode(_$PositiveInteractionTypeEnumMap, e)) + .toList(), + ), isNegativeFeedbackFollowUpEnabled: $checkedConvert( 'isNegativeFeedbackFollowUpEnabled', (v) => v as bool, @@ -33,10 +39,20 @@ AppReviewConfig _$AppReviewConfigFromJson(Map json) => Map _$AppReviewConfigToJson(AppReviewConfig instance) => { 'enabled': instance.enabled, - 'positiveInteractionThreshold': instance.positiveInteractionThreshold, + 'interactionCycleThreshold': instance.interactionCycleThreshold, 'initialPromptCooldownDays': instance.initialPromptCooldownDays, + 'eligiblePositiveInteractions': instance.eligiblePositiveInteractions + .map((e) => _$PositiveInteractionTypeEnumMap[e]!) + .toList(), 'isNegativeFeedbackFollowUpEnabled': instance.isNegativeFeedbackFollowUpEnabled, 'isPositiveFeedbackFollowUpEnabled': instance.isPositiveFeedbackFollowUpEnabled, }; + +const _$PositiveInteractionTypeEnumMap = { + PositiveInteractionType.saveItem: 'saveItem', + PositiveInteractionType.followItem: 'followItem', + PositiveInteractionType.shareContent: 'shareContent', + PositiveInteractionType.saveFilter: 'saveFilter', +}; diff --git a/lib/src/models/user_generated_content/engagement.dart b/lib/src/models/user_generated_content/engagement.dart index cb99bc10..744f156b 100644 --- a/lib/src/models/user_generated_content/engagement.dart +++ b/lib/src/models/user_generated_content/engagement.dart @@ -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) @@ -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 json) => @@ -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. @@ -77,7 +81,7 @@ class Engagement extends Equatable { String? userId, String? entityId, EngageableType? entityType, - Reaction? reaction, + ValueWrapper? reaction, ValueWrapper? comment, DateTime? createdAt, }) { @@ -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(), diff --git a/lib/src/models/user_generated_content/engagement.g.dart b/lib/src/models/user_generated_content/engagement.g.dart index 2c5c7755..bb8f3a54 100644 --- a/lib/src/models/user_generated_content/engagement.g.dart +++ b/lib/src/models/user_generated_content/engagement.g.dart @@ -16,10 +16,6 @@ Engagement _$EngagementFromJson(Map json) => 'entityType', (v) => $enumDecode(_$EngageableTypeEnumMap, v), ), - reaction: $checkedConvert( - 'reaction', - (v) => Reaction.fromJson(v as Map), - ), createdAt: $checkedConvert( 'createdAt', (v) => dateTimeFromJson(v as String?), @@ -28,6 +24,11 @@ Engagement _$EngagementFromJson(Map json) => 'updatedAt', (v) => dateTimeFromJson(v as String?), ), + reaction: $checkedConvert( + 'reaction', + (v) => + v == null ? null : Reaction.fromJson(v as Map), + ), comment: $checkedConvert( 'comment', (v) => v == null ? null : Comment.fromJson(v as Map), @@ -42,7 +43,7 @@ Map _$EngagementToJson(Engagement instance) => 'userId': instance.userId, 'entityId': instance.entityId, 'entityType': _$EngageableTypeEnumMap[instance.entityType]!, - 'reaction': instance.reaction.toJson(), + 'reaction': instance.reaction?.toJson(), 'comment': instance.comment?.toJson(), 'createdAt': dateTimeToJson(instance.createdAt), 'updatedAt': dateTimeToJson(instance.updatedAt), diff --git a/lib/src/models/user_generated_content/report.g.dart b/lib/src/models/user_generated_content/report.g.dart index e3daac72..73df680e 100644 --- a/lib/src/models/user_generated_content/report.g.dart +++ b/lib/src/models/user_generated_content/report.g.dart @@ -47,7 +47,7 @@ Map _$ReportToJson(Report instance) => { const _$ReportableEntityEnumMap = { ReportableEntity.headline: 'headline', ReportableEntity.source: 'source', - ReportableEntity.engagement: 'engagement', + ReportableEntity.comment: 'comment', }; const _$ModerationStatusEnumMap = { diff --git a/test/src/enums/positive_interaction_type_test.dart b/test/src/enums/positive_interaction_type_test.dart new file mode 100644 index 00000000..fe46dce2 --- /dev/null +++ b/test/src/enums/positive_interaction_type_test.dart @@ -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()), + ); + }); + }); + }); +} diff --git a/test/src/enums/reportable_entity_test.dart b/test/src/enums/reportable_entity_test.dart index 1ab6dbdc..ce9e261c 100644 --- a/test/src/enums/reportable_entity_test.dart +++ b/test/src/enums/reportable_entity_test.dart @@ -9,7 +9,7 @@ void main() { containsAll([ ReportableEntity.headline, ReportableEntity.source, - ReportableEntity.engagement, + ReportableEntity.comment, ]), ); }); @@ -17,7 +17,7 @@ void main() { test('has correct string values', () { expect(ReportableEntity.headline.name, 'headline'); expect(ReportableEntity.source.name, 'source'); - expect(ReportableEntity.engagement.name, 'engagement'); + expect(ReportableEntity.comment.name, 'comment'); }); test('can be created from string values', () { @@ -27,8 +27,8 @@ void main() { ); expect(ReportableEntity.values.byName('source'), ReportableEntity.source); expect( - ReportableEntity.values.byName('engagement'), - ReportableEntity.engagement, + ReportableEntity.values.byName('comment'), + ReportableEntity.comment, ); }); }); diff --git a/test/src/models/config/app_review_config_test.dart b/test/src/models/config/app_review_config_test.dart index 2e66e17c..6afeab18 100644 --- a/test/src/models/config/app_review_config_test.dart +++ b/test/src/models/config/app_review_config_test.dart @@ -12,6 +12,7 @@ void main() { expect(appReviewConfigFixture.enabled, isTrue); expect(appReviewConfigFixture.isNegativeFeedbackFollowUpEnabled, isTrue); expect(appReviewConfigFixture.isPositiveFeedbackFollowUpEnabled, isTrue); + expect(appReviewConfigFixture.eligiblePositiveInteractions, isNotEmpty); }); test('supports value equality', () { @@ -33,13 +34,18 @@ void main() { test('copyWith creates a copy with updated values', () { final updatedConfig = appReviewConfigFixture.copyWith( enabled: false, - positiveInteractionThreshold: 10, + interactionCycleThreshold: 10, + eligiblePositiveInteractions: const [PositiveInteractionType.saveItem], isNegativeFeedbackFollowUpEnabled: false, isPositiveFeedbackFollowUpEnabled: false, ); expect(updatedConfig.enabled, isFalse); - expect(updatedConfig.positiveInteractionThreshold, 10); + expect(updatedConfig.interactionCycleThreshold, 10); + expect( + updatedConfig.eligiblePositiveInteractions, + equals([PositiveInteractionType.saveItem]), + ); expect(updatedConfig.isNegativeFeedbackFollowUpEnabled, isFalse); expect(updatedConfig.isPositiveFeedbackFollowUpEnabled, isFalse); expect(updatedConfig, isNot(equals(appReviewConfigFixture))); diff --git a/test/src/models/user_generated_content/engagement_test.dart b/test/src/models/user_generated_content/engagement_test.dart index fdd36f68..ed01c6fb 100644 --- a/test/src/models/user_generated_content/engagement_test.dart +++ b/test/src/models/user_generated_content/engagement_test.dart @@ -15,17 +15,28 @@ void main() { expect(engagementFixture, isA()); }); - test('returns correct instance with populated comment', () { - // The first fixture item should have a comment + test('returns correct instance with comment and reaction', () { + // The first fixture item should have a comment and a reaction expect(engagementFixture.comment, isNotNull); + expect(engagementFixture.reaction, isNotNull); }); - test('returns correct instance with null comment', () { - // The second fixture item should have a null comment + test('returns correct instance with reaction only', () { + // The second fixture item should have a reaction but no comment final engagementWithoutComment = getEngagementsFixturesData( now: now, )[1]; expect(engagementWithoutComment.comment, isNull); + expect(engagementWithoutComment.reaction, isNotNull); + }); + + test('returns correct instance with comment only', () { + // The third fixture item should have a comment but no reaction + final engagementWithoutReaction = getEngagementsFixturesData( + now: now, + )[2]; + expect(engagementWithoutReaction.comment, isNotNull); + expect(engagementWithoutReaction.reaction, isNull); }); }); @@ -36,7 +47,7 @@ void main() { expect(result, equals(engagementFixture)); }); - test('round trip with null comment', () { + test('round trip with null comment (reaction only)', () { final engagementWithoutComment = getEngagementsFixturesData( now: now, )[1]; @@ -44,6 +55,15 @@ void main() { final result = Engagement.fromJson(json); expect(result, equals(engagementWithoutComment)); }); + + test('round trip with null reaction (comment only)', () { + final engagementWithCommentOnly = getEngagementsFixturesData( + now: now, + )[2]; + final json = engagementWithCommentOnly.toJson(); + final result = Engagement.fromJson(json); + expect(result, equals(engagementWithCommentOnly)); + }); }); group('copyWith', () { @@ -51,7 +71,7 @@ void main() { final newReaction = reactionsFixturesData[2]; final updatedEngagement = engagementFixture.copyWith( - reaction: newReaction, + reaction: ValueWrapper(newReaction), ); expect(updatedEngagement.reaction, newReaction); @@ -68,6 +88,17 @@ void main() { ); }); + test('returns a new instance with a null reaction', () { + // Start with a fixture that has a reaction. + final updatedEngagement = engagementFixture.copyWith( + reaction: const ValueWrapper(null), + ); + + expect(updatedEngagement.reaction, isNull); + // Verify other fields remain unchanged + expect(updatedEngagement.id, engagementFixture.id); + }); + test('returns a new instance with an updated comment', () { final newComment = Comment( language: languagesFixturesData.first,