From 1696c636e532761b505c8e81dc622bee62db53de Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 22 May 2025 16:32:34 +0100 Subject: [PATCH 1/4] feat: Add EngagementContentTemplate model - Defines static content for prompts - Includes type, title, description, CTA - Uses JsonSerializable for conversion --- .../engagement_content_template.dart | 42 +++++++++++++++++++ .../engagement_content_template.g.dart | 35 ++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 lib/src/models/feed_extras/engagement_content_template.dart create mode 100644 lib/src/models/feed_extras/engagement_content_template.g.dart diff --git a/lib/src/models/feed_extras/engagement_content_template.dart b/lib/src/models/feed_extras/engagement_content_template.dart new file mode 100644 index 00000000..b870dc60 --- /dev/null +++ b/lib/src/models/feed_extras/engagement_content_template.dart @@ -0,0 +1,42 @@ +import 'package:equatable/equatable.dart'; +import 'package:ht_shared/src/models/feed_extras/feed_template_types.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'engagement_content_template.g.dart'; + +/// {@template engagement_content_template} +/// Defines the static content for an engagement prompt. +/// The 'type' of an instance should match an [EngagementTemplateType] value. +/// {@endtemplate} +@JsonSerializable(explicitToJson: true) +class EngagementContentTemplate extends Equatable { + /// {@macro engagement_content_template} + const EngagementContentTemplate({ + required this.type, + required this.title, + this.description, + this.callToActionText, + }); + + /// Creates an [EngagementContentTemplate] from JSON data. + factory EngagementContentTemplate.fromJson(Map json) => + _$EngagementContentTemplateFromJson(json); + + /// The type of engagement template, matching an [EngagementTemplateType] value. + final EngagementTemplateType type; + + /// The main title or heading for the engagement content. + final String title; + + /// An optional description providing more details. + final String? description; + + /// The text for the call-to-action button or link. + final String? callToActionText; + + /// Converts this [EngagementContentTemplate] instance to JSON data. + Map toJson() => _$EngagementContentTemplateToJson(this); + + @override + List get props => [type, title, description, callToActionText]; +} diff --git a/lib/src/models/feed_extras/engagement_content_template.g.dart b/lib/src/models/feed_extras/engagement_content_template.g.dart new file mode 100644 index 00000000..0c1e4522 --- /dev/null +++ b/lib/src/models/feed_extras/engagement_content_template.g.dart @@ -0,0 +1,35 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'engagement_content_template.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +EngagementContentTemplate _$EngagementContentTemplateFromJson( + Map json, +) => + EngagementContentTemplate( + type: $enumDecode(_$EngagementTemplateTypeEnumMap, json['type']), + title: json['title'] as String, + description: json['description'] as String?, + callToActionText: json['callToActionText'] as String?, + ); + +Map _$EngagementContentTemplateToJson( + EngagementContentTemplate instance, +) => + { + 'type': _$EngagementTemplateTypeEnumMap[instance.type]!, + 'title': instance.title, + 'description': instance.description, + 'callToActionText': instance.callToActionText, + }; + +const _$EngagementTemplateTypeEnumMap = { + EngagementTemplateType.rateApp: 'rate-app', + EngagementTemplateType.linkAccount: 'link-account', + EngagementTemplateType.upgradeToPremium: 'upgrade-to-premium', + EngagementTemplateType.completeProfile: 'complete-profile', + EngagementTemplateType.exploreNewFeature: 'explore-new-feature', +}; From 16b008e7bfc5b8e4ceda0420f2e4821ea9e295b5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 22 May 2025 16:32:46 +0100 Subject: [PATCH 2/4] feat: add SuggestedContentTemplate model - Defines suggestion block config - Includes type, display, content - Supports title and description --- .../suggested_content_template.dart | 66 +++++++++++++++++++ .../suggested_content_template.g.dart | 60 +++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 lib/src/models/feed_extras/suggested_content_template.dart create mode 100644 lib/src/models/feed_extras/suggested_content_template.g.dart diff --git a/lib/src/models/feed_extras/suggested_content_template.dart b/lib/src/models/feed_extras/suggested_content_template.dart new file mode 100644 index 00000000..a9eb6699 --- /dev/null +++ b/lib/src/models/feed_extras/suggested_content_template.dart @@ -0,0 +1,66 @@ +import 'package:equatable/equatable.dart'; +import 'package:ht_shared/src/models/core/content_type.dart'; +import 'package:ht_shared/src/models/feed_decorators/suggested_content_display_type.dart'; +import 'package:ht_shared/src/models/feed_extras/feed_template_types.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'suggested_content_template.g.dart'; + +/// {@template suggested_content_template} +/// Defines the static content and configuration for a suggestion block. +/// The 'type' of an instance should match a [SuggestionTemplateType] value. +/// {@endtemplate} +@JsonSerializable(explicitToJson: true) +class SuggestedContentTemplate extends Equatable { + /// {@macro suggested_content_template} + const SuggestedContentTemplate({ + required this.type, + required this.displayType, + required this.suggestedContentType, + this.title, + this.description, + this.maxItemsToDisplay, + this.fetchCriteria, + }); + + /// Creates a [SuggestedContentTemplate] from JSON data. + factory SuggestedContentTemplate.fromJson(Map json) => + _$SuggestedContentTemplateFromJson(json); + + /// The type of suggestion template, matching a [SuggestionTemplateType] value. + final SuggestionTemplateType type; + + /// An optional title for the suggestion block (e.g., "You might like..."). + final String? title; + + /// An optional description for the suggestion block. + final String? description; + + /// The visual presentation or layout style for this suggestion block. + final SuggestedContentDisplayType displayType; + + /// Defines what kind of primary content this suggestion block will contain + /// (e.g., if suggesting categories, this would be [ContentType.category]). + final ContentType suggestedContentType; + + /// Maximum number of items to display within this suggestion block. + final int? maxItemsToDisplay; + + /// Criteria for fetching dynamic items, e.g., "popular", "newest". + /// This is a simple string; the decorator will interpret it. + final String? fetchCriteria; + + /// Converts this [SuggestedContentTemplate] instance to JSON data. + Map toJson() => _$SuggestedContentTemplateToJson(this); + + @override + List get props => [ + type, + title, + description, + displayType, + suggestedContentType, + maxItemsToDisplay, + fetchCriteria, + ]; +} diff --git a/lib/src/models/feed_extras/suggested_content_template.g.dart b/lib/src/models/feed_extras/suggested_content_template.g.dart new file mode 100644 index 00000000..6fcd9d83 --- /dev/null +++ b/lib/src/models/feed_extras/suggested_content_template.g.dart @@ -0,0 +1,60 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'suggested_content_template.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SuggestedContentTemplate _$SuggestedContentTemplateFromJson( + Map json, +) => + SuggestedContentTemplate( + type: $enumDecode(_$SuggestionTemplateTypeEnumMap, json['type']), + displayType: $enumDecode( + _$SuggestedContentDisplayTypeEnumMap, + json['displayType'], + ), + suggestedContentType: + $enumDecode(_$ContentTypeEnumMap, json['suggestedContentType']), + title: json['title'] as String?, + description: json['description'] as String?, + maxItemsToDisplay: (json['maxItemsToDisplay'] as num?)?.toInt(), + fetchCriteria: json['fetchCriteria'] as String?, + ); + +Map _$SuggestedContentTemplateToJson( + SuggestedContentTemplate instance, +) => + { + 'type': _$SuggestionTemplateTypeEnumMap[instance.type]!, + 'title': instance.title, + 'description': instance.description, + 'displayType': + _$SuggestedContentDisplayTypeEnumMap[instance.displayType]!, + 'suggestedContentType': + _$ContentTypeEnumMap[instance.suggestedContentType]!, + 'maxItemsToDisplay': instance.maxItemsToDisplay, + 'fetchCriteria': instance.fetchCriteria, + }; + +const _$SuggestionTemplateTypeEnumMap = { + SuggestionTemplateType.categoriesToFollow: 'categories-to-follow', + SuggestionTemplateType.sourcesToFollow: 'sources-to-follow', + SuggestionTemplateType.countriesToFollow: 'countries-to-follow', +}; + +const _$SuggestedContentDisplayTypeEnumMap = { + SuggestedContentDisplayType.horizontalCardList: 'horizontal_card_list', + SuggestedContentDisplayType.verticalCardList: 'vertical_card_list', + SuggestedContentDisplayType.grid: 'grid', + SuggestedContentDisplayType.singlePromotionalCard: 'single_promotional_card', + SuggestedContentDisplayType.textList: 'text_list', +}; + +const _$ContentTypeEnumMap = { + ContentType.headline: 'headline', + ContentType.category: 'category', + ContentType.source: 'source', + ContentType.country: 'country', +}; From 3e1abf61a43a0c2ba1b06dcaa249a8f7d30b4c0b Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 22 May 2025 16:33:01 +0100 Subject: [PATCH 3/4] feat: add feed rules models - Add PlacementCriteria model - Add EngagementRule model - Add SuggestionRule model --- lib/src/models/remote_config/feed_rules.dart | 128 ++++++++++++++++++ .../models/remote_config/feed_rules.g.dart | 94 +++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 lib/src/models/remote_config/feed_rules.dart create mode 100644 lib/src/models/remote_config/feed_rules.g.dart diff --git a/lib/src/models/remote_config/feed_rules.dart b/lib/src/models/remote_config/feed_rules.dart new file mode 100644 index 00000000..bc5c1981 --- /dev/null +++ b/lib/src/models/remote_config/feed_rules.dart @@ -0,0 +1,128 @@ +import 'package:equatable/equatable.dart'; +import 'package:ht_shared/src/models/auth/user_role.dart'; +import 'package:ht_shared/src/models/feed_extras/feed_template_types.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'feed_rules.g.dart'; + +/// {@template placement_criteria} +/// Defines where and how often an injected item should appear in a feed. +/// {@endtemplate} +@JsonSerializable(explicitToJson: true) +class PlacementCriteria extends Equatable { + /// {@macro placement_criteria} + const PlacementCriteria({ + this.afterPrimaryItemIndex, + this.relativePosition, + this.minPrimaryItemsRequired, + }); + + /// Creates a [PlacementCriteria] from JSON data. + factory PlacementCriteria.fromJson(Map json) => + _$PlacementCriteriaFromJson(json); + + /// Inject after this many primary feed items (0-indexed). + /// If null, no specific index is preferred. + final int? afterPrimaryItemIndex; + + /// A string indicating a general position, e.g., "middle", "end_quarter". + /// The decorator will interpret this. + final String? relativePosition; + + /// Minimum number of primary items required on the page for this injection + /// to be considered. Prevents injecting into very short lists. + final int? minPrimaryItemsRequired; + + /// Converts this [PlacementCriteria] instance to JSON data. + Map toJson() => _$PlacementCriteriaToJson(this); + + @override + List get props => [ + afterPrimaryItemIndex, + relativePosition, + minPrimaryItemsRequired, + ]; +} + +/// {@template engagement_rule} +/// Defines the rules for triggering a specific engagement prompt. +/// {@endtemplate} +@JsonSerializable(explicitToJson: true) +class EngagementRule extends Equatable { + /// {@macro engagement_rule} + const EngagementRule({ + required this.templateType, + required this.userRoles, + this.minDaysSinceAccountCreation, + this.maxTimesToShow, + this.minDaysSinceLastShown, + this.placement, + }); + + /// Creates an [EngagementRule] from JSON data. + factory EngagementRule.fromJson(Map json) => + _$EngagementRuleFromJson(json); + + /// Type of engagement template to use. + final EngagementTemplateType templateType; + + /// Roles this rule applies to. + final List userRoles; + + /// Minimum days since user account was created for this rule to apply. + final int? minDaysSinceAccountCreation; + + /// Overall maximum number of times this specific engagement can be shown to a user. + final int? maxTimesToShow; + + /// Minimum days since this specific engagement was last shown to the user. + final int? minDaysSinceLastShown; + + /// How to place this in the feed. + final PlacementCriteria? placement; + + /// Converts this [EngagementRule] instance to JSON data. + Map toJson() => _$EngagementRuleToJson(this); + + @override + List get props => [ + templateType, + userRoles, + minDaysSinceAccountCreation, + maxTimesToShow, + minDaysSinceLastShown, + placement, + ]; +} + +/// {@template suggestion_rule} +/// Defines the rules for triggering a specific content suggestion block. +/// {@endtemplate} +@JsonSerializable(explicitToJson: true) +class SuggestionRule extends Equatable { + /// {@macro suggestion_rule} + const SuggestionRule({ + required this.templateType, + required this.userRoles, + this.placement, + }); + + /// Creates a [SuggestionRule] from JSON data. + factory SuggestionRule.fromJson(Map json) => + _$SuggestionRuleFromJson(json); + + /// Type of suggestion template to use. + final SuggestionTemplateType templateType; + + /// Roles this rule applies to. + final List userRoles; + + /// How to place this in the feed. + final PlacementCriteria? placement; + + /// Converts this [SuggestionRule] instance to JSON data. + Map toJson() => _$SuggestionRuleToJson(this); + + @override + List get props => [templateType, userRoles, placement]; +} diff --git a/lib/src/models/remote_config/feed_rules.g.dart b/lib/src/models/remote_config/feed_rules.g.dart new file mode 100644 index 00000000..2f28594f --- /dev/null +++ b/lib/src/models/remote_config/feed_rules.g.dart @@ -0,0 +1,94 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'feed_rules.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlacementCriteria _$PlacementCriteriaFromJson(Map json) => + PlacementCriteria( + afterPrimaryItemIndex: (json['afterPrimaryItemIndex'] as num?)?.toInt(), + relativePosition: json['relativePosition'] as String?, + minPrimaryItemsRequired: + (json['minPrimaryItemsRequired'] as num?)?.toInt(), + ); + +Map _$PlacementCriteriaToJson(PlacementCriteria instance) => + { + 'afterPrimaryItemIndex': instance.afterPrimaryItemIndex, + 'relativePosition': instance.relativePosition, + 'minPrimaryItemsRequired': instance.minPrimaryItemsRequired, + }; + +EngagementRule _$EngagementRuleFromJson(Map json) => + EngagementRule( + templateType: + $enumDecode(_$EngagementTemplateTypeEnumMap, json['templateType']), + userRoles: (json['userRoles'] as List) + .map((e) => $enumDecode(_$UserRoleEnumMap, e)) + .toList(), + minDaysSinceAccountCreation: + (json['minDaysSinceAccountCreation'] as num?)?.toInt(), + maxTimesToShow: (json['maxTimesToShow'] as num?)?.toInt(), + minDaysSinceLastShown: (json['minDaysSinceLastShown'] as num?)?.toInt(), + placement: json['placement'] == null + ? null + : PlacementCriteria.fromJson( + json['placement'] as Map, + ), + ); + +Map _$EngagementRuleToJson(EngagementRule instance) => + { + 'templateType': _$EngagementTemplateTypeEnumMap[instance.templateType]!, + 'userRoles': + instance.userRoles.map((e) => _$UserRoleEnumMap[e]!).toList(), + 'minDaysSinceAccountCreation': instance.minDaysSinceAccountCreation, + 'maxTimesToShow': instance.maxTimesToShow, + 'minDaysSinceLastShown': instance.minDaysSinceLastShown, + 'placement': instance.placement?.toJson(), + }; + +const _$EngagementTemplateTypeEnumMap = { + EngagementTemplateType.rateApp: 'rate-app', + EngagementTemplateType.linkAccount: 'link-account', + EngagementTemplateType.upgradeToPremium: 'upgrade-to-premium', + EngagementTemplateType.completeProfile: 'complete-profile', + EngagementTemplateType.exploreNewFeature: 'explore-new-feature', +}; + +const _$UserRoleEnumMap = { + UserRole.admin: 'admin', + UserRole.premiumUser: 'premium_user', + UserRole.standardUser: 'standard_user', + UserRole.guestUser: 'guest_user', +}; + +SuggestionRule _$SuggestionRuleFromJson(Map json) => + SuggestionRule( + templateType: + $enumDecode(_$SuggestionTemplateTypeEnumMap, json['templateType']), + userRoles: (json['userRoles'] as List) + .map((e) => $enumDecode(_$UserRoleEnumMap, e)) + .toList(), + placement: json['placement'] == null + ? null + : PlacementCriteria.fromJson( + json['placement'] as Map, + ), + ); + +Map _$SuggestionRuleToJson(SuggestionRule instance) => + { + 'templateType': _$SuggestionTemplateTypeEnumMap[instance.templateType]!, + 'userRoles': + instance.userRoles.map((e) => _$UserRoleEnumMap[e]!).toList(), + 'placement': instance.placement?.toJson(), + }; + +const _$SuggestionTemplateTypeEnumMap = { + SuggestionTemplateType.categoriesToFollow: 'categories-to-follow', + SuggestionTemplateType.sourcesToFollow: 'sources-to-follow', + SuggestionTemplateType.countriesToFollow: 'countries-to-follow', +}; From b6b199b4c29cc4dd40fcea73cc2451ee89536f48 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 22 May 2025 16:33:25 +0100 Subject: [PATCH 4/4] chore: misc --- README.md | 2 +- lib/src/models/auth/user.dart | 43 ++++++++++++++-- lib/src/models/auth/user.g.dart | 3 ++ lib/src/models/feed_extras/feed_extras.dart | 3 ++ .../feed_extras/feed_template_types.dart | 37 ++++++++++++++ lib/src/models/models.dart | 1 + lib/src/models/remote_config/app_config.dart | 21 +++++++- .../models/remote_config/app_config.g.dart | 10 ++++ .../models/remote_config/remote_config.dart | 1 + .../user_settings/user_app_settings.dart | 50 ++++++++++++++++++- .../user_settings/user_app_settings.g.dart | 11 ++++ test/src/models/auth/user_test.dart | 16 ++++-- .../user_settings/user_app_settings_test.dart | 4 ++ 13 files changed, 191 insertions(+), 11 deletions(-) create mode 100644 lib/src/models/feed_extras/feed_extras.dart create mode 100644 lib/src/models/feed_extras/feed_template_types.dart diff --git a/README.md b/README.md index 3126bb20..4ce8294b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🛠️ ht_shared -![coverage: percentage](https://img.shields.io/badge/coverage-96-green) +![coverage: percentage](https://img.shields.io/badge/coverage-81-green) [![style: very good analysis](https://img.shields.io/badge/style-very_good_analysis-B22C89.svg)](https://pub.dev/packages/very_good_analysis) [![License: PolyForm Free Trial](https://img.shields.io/badge/License-PolyForm%20Free%20Trial-blue)](https://polyformproject.org/licenses/free-trial/1.0.0) diff --git a/lib/src/models/auth/user.dart b/lib/src/models/auth/user.dart index 976e385c..d72cc649 100644 --- a/lib/src/models/auth/user.dart +++ b/lib/src/models/auth/user.dart @@ -15,7 +15,12 @@ class User extends Equatable { /// Requires a unique [id] and a [role]. /// The [email] is optional and typically present only for users /// who have verified their email address. - const User({required this.id, required this.role, this.email}); + const User({ + required this.id, + required this.role, + this.email, + this.createdAt, + }); /// Creates a User from JSON data. factory User.fromJson(Map json) => _$UserFromJson(json); @@ -31,14 +36,46 @@ class User extends Equatable { /// The role of the user. final UserRole role; + /// The date and time the user account was created. + /// This is typically set on the backend upon user creation. + @JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson) + final DateTime? createdAt; + /// Converts this User instance to JSON data. Map toJson() => _$UserToJson(this); @override - List get props => [id, email, role]; + List get props => [id, email, role, createdAt]; @override String toString() { - return 'User(id: $id, email: $email, role: $role)'; + return 'User(id: $id, email: $email, role: $role, createdAt: $createdAt)'; + } + + /// Creates a copy of this [User] but with the given fields replaced with + /// the new values. + User copyWith({ + String? id, + String? email, + UserRole? role, + DateTime? createdAt, + }) { + return User( + id: id ?? this.id, + email: email ?? this.email, + role: role ?? this.role, + createdAt: createdAt ?? this.createdAt, + ); } } + +// Helper function for parsing DateTime, returning null on error +DateTime? _dateTimeFromJson(String? dateString) { + if (dateString == null) return null; + return DateTime.tryParse(dateString); +} + +// Helper function for serializing DateTime to ISO 8601 string +String? _dateTimeToJson(DateTime? dateTime) { + return dateTime?.toIso8601String(); +} diff --git a/lib/src/models/auth/user.g.dart b/lib/src/models/auth/user.g.dart index c91e9e90..e0ac79f7 100644 --- a/lib/src/models/auth/user.g.dart +++ b/lib/src/models/auth/user.g.dart @@ -10,16 +10,19 @@ User _$UserFromJson(Map json) => User( id: json['id'] as String, role: $enumDecode(_$UserRoleEnumMap, json['role']), email: json['email'] as String?, + createdAt: _dateTimeFromJson(json['createdAt'] as String?), ); Map _$UserToJson(User instance) => { 'id': instance.id, 'email': instance.email, 'role': _$UserRoleEnumMap[instance.role]!, + 'createdAt': _dateTimeToJson(instance.createdAt), }; const _$UserRoleEnumMap = { UserRole.admin: 'admin', + UserRole.premiumUser: 'premium_user', UserRole.standardUser: 'standard_user', UserRole.guestUser: 'guest_user', }; diff --git a/lib/src/models/feed_extras/feed_extras.dart b/lib/src/models/feed_extras/feed_extras.dart new file mode 100644 index 00000000..8cb89a38 --- /dev/null +++ b/lib/src/models/feed_extras/feed_extras.dart @@ -0,0 +1,3 @@ +export 'engagement_content_template.dart'; +export 'feed_template_types.dart'; +export 'suggested_content_template.dart'; diff --git a/lib/src/models/feed_extras/feed_template_types.dart b/lib/src/models/feed_extras/feed_template_types.dart new file mode 100644 index 00000000..bc38a704 --- /dev/null +++ b/lib/src/models/feed_extras/feed_template_types.dart @@ -0,0 +1,37 @@ +import 'package:json_annotation/json_annotation.dart'; + +/// Defines the types of engagement content templates available. +/// The string value of the enum (e.g., 'rate-app') will be used as the ID +/// to link rules in AppConfig to specific EngagementContentTemplate instances. +@JsonEnum(fieldRename: FieldRename.kebab) +enum EngagementTemplateType { + /// Prompt to rate the application. + rateApp, + + /// Prompt for guest users to create/link an account. + linkAccount, + + /// Prompt for standard users to upgrade to premium. + upgradeToPremium, + + /// Prompt to complete user profile (e.g., select content preferences). + completeProfile, + + /// Prompt to explore a new feature. + exploreNewFeature, +} + +/// Defines the types of suggested content templates available. +/// The string value of the enum (e.g., 'categories-to-follow') will be used as the ID +/// to link rules in AppConfig to specific SuggestedContentTemplate instances. +@JsonEnum(fieldRename: FieldRename.kebab) +enum SuggestionTemplateType { + /// Suggest categories to follow. + categoriesToFollow, + + /// Suggest sources to follow. + sourcesToFollow, + + /// Suggest countries to follow for news. + countriesToFollow, +} diff --git a/lib/src/models/models.dart b/lib/src/models/models.dart index d5900c84..1df847d2 100644 --- a/lib/src/models/models.dart +++ b/lib/src/models/models.dart @@ -2,6 +2,7 @@ export 'auth/auth.dart'; export 'core/core.dart'; export 'entities/entities.dart'; export 'feed_decorators/feed_decorators.dart'; +export 'feed_extras/feed_extras.dart'; export 'remote_config/remote_config.dart'; export 'responses/responses.dart'; export 'user_preferences/user_preferences.dart'; diff --git a/lib/src/models/remote_config/app_config.dart b/lib/src/models/remote_config/app_config.dart index 5762fdd5..da0a1c17 100644 --- a/lib/src/models/remote_config/app_config.dart +++ b/lib/src/models/remote_config/app_config.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:ht_shared/src/models/remote_config/ad_config.dart'; +import 'package:ht_shared/src/models/remote_config/feed_rules.dart'; import 'package:ht_shared/src/models/remote_config/user_preference_limits.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:meta/meta.dart'; @@ -31,6 +32,8 @@ class AppConfig extends Equatable { required this.id, UserPreferenceLimits? userPreferenceLimits, AdConfig? adConfig, + List? engagementRules, + List? suggestionRules, }) : userPreferenceLimits = userPreferenceLimits ?? const UserPreferenceLimits( guestFollowedItemsLimit: 5, @@ -48,7 +51,9 @@ class AppConfig extends Equatable { authenticatedAdPlacementInterval: 5, premiumAdFrequency: 0, // No ads for premium users by default premiumAdPlacementInterval: 0, - ); // Default ad config + ), // Default ad config + engagementRules = engagementRules ?? const [], + suggestionRules = suggestionRules ?? const []; /// Factory method to create an [AppConfig] instance from a JSON map. factory AppConfig.fromJson(Map json) => @@ -66,11 +71,23 @@ class AppConfig extends Equatable { /// tiered by user role. final AdConfig adConfig; + /// Defines rules for triggering engagement prompts. + final List engagementRules; + + /// Defines rules for triggering content suggestion blocks. + final List suggestionRules; + /// Converts this [AppConfig] instance to a JSON map. Map toJson() => _$AppConfigToJson(this); @override - List get props => [id, userPreferenceLimits, adConfig]; + List get props => [ + id, + userPreferenceLimits, + adConfig, + engagementRules, + suggestionRules, + ]; @override bool get stringify => true; diff --git a/lib/src/models/remote_config/app_config.g.dart b/lib/src/models/remote_config/app_config.g.dart index abec54b4..7144a338 100644 --- a/lib/src/models/remote_config/app_config.g.dart +++ b/lib/src/models/remote_config/app_config.g.dart @@ -16,10 +16,20 @@ AppConfig _$AppConfigFromJson(Map json) => AppConfig( adConfig: json['adConfig'] == null ? null : AdConfig.fromJson(json['adConfig'] as Map), + engagementRules: (json['engagementRules'] as List?) + ?.map((e) => EngagementRule.fromJson(e as Map)) + .toList(), + suggestionRules: (json['suggestionRules'] as List?) + ?.map((e) => SuggestionRule.fromJson(e as Map)) + .toList(), ); Map _$AppConfigToJson(AppConfig instance) => { 'id': instance.id, 'userPreferenceLimits': instance.userPreferenceLimits.toJson(), 'adConfig': instance.adConfig.toJson(), + 'engagementRules': + instance.engagementRules.map((e) => e.toJson()).toList(), + 'suggestionRules': + instance.suggestionRules.map((e) => e.toJson()).toList(), }; diff --git a/lib/src/models/remote_config/remote_config.dart b/lib/src/models/remote_config/remote_config.dart index 67d24b2b..c0786ef5 100644 --- a/lib/src/models/remote_config/remote_config.dart +++ b/lib/src/models/remote_config/remote_config.dart @@ -1,4 +1,5 @@ export 'ad_config.dart'; export 'app_config.dart'; +export 'feed_rules.dart'; export 'remote_config.dart'; export 'user_preference_limits.dart'; diff --git a/lib/src/models/user_settings/user_app_settings.dart b/lib/src/models/user_settings/user_app_settings.dart index aadef987..5dd38c77 100644 --- a/lib/src/models/user_settings/user_app_settings.dart +++ b/lib/src/models/user_settings/user_app_settings.dart @@ -26,9 +26,14 @@ class UserAppSettings extends Equatable { DisplaySettings? displaySettings, AppLanguage? language, FeedDisplayPreferences? feedPreferences, + Map? engagementShownCounts, + Map? engagementLastShownTimestamps, }) : displaySettings = displaySettings ?? const DisplaySettings(), language = language ?? 'en', // Default language is English - feedPreferences = feedPreferences ?? const FeedDisplayPreferences(); + feedPreferences = feedPreferences ?? const FeedDisplayPreferences(), + engagementShownCounts = engagementShownCounts ?? const {}, + engagementLastShownTimestamps = + engagementLastShownTimestamps ?? const {}; /// Factory method to create a [UserAppSettings] instance from a JSON map. factory UserAppSettings.fromJson(Map json) => @@ -46,11 +51,30 @@ class UserAppSettings extends Equatable { /// User-configurable settings for how content feeds are displayed. final FeedDisplayPreferences feedPreferences; + /// A map tracking how many times each engagement type has been shown to the user. + /// Key: EngagementTemplateType.name (string), Value: count. + final Map engagementShownCounts; + + /// A map tracking the last time each engagement type was shown to the user. + /// Key: EngagementTemplateType.name (string), Value: timestamp. + @JsonKey( + fromJson: _engagementLastShownTimestampsFromJson, + toJson: _engagementLastShownTimestampsToJson, + ) + final Map engagementLastShownTimestamps; + /// Converts this [UserAppSettings] instance to a JSON map. Map toJson() => _$UserAppSettingsToJson(this); @override - List get props => [id, displaySettings, language, feedPreferences]; + List get props => [ + id, + displaySettings, + language, + feedPreferences, + engagementShownCounts, + engagementLastShownTimestamps, + ]; /// Creates a copy of this [UserAppSettings] but with the given fields /// replaced with the new values. @@ -59,12 +83,34 @@ class UserAppSettings extends Equatable { DisplaySettings? displaySettings, AppLanguage? language, FeedDisplayPreferences? feedPreferences, + Map? engagementShownCounts, + Map? engagementLastShownTimestamps, }) { return UserAppSettings( id: id ?? this.id, displaySettings: displaySettings ?? this.displaySettings, language: language ?? this.language, feedPreferences: feedPreferences ?? this.feedPreferences, + engagementShownCounts: + engagementShownCounts ?? this.engagementShownCounts, + engagementLastShownTimestamps: + engagementLastShownTimestamps ?? this.engagementLastShownTimestamps, ); } } + +// Helper function for parsing Map from JSON +Map _engagementLastShownTimestampsFromJson( + Map? json, +) { + if (json == null) return const {}; + return json + .map((key, value) => MapEntry(key, DateTime.parse(value as String))); +} + +// Helper function for serializing Map to JSON +Map _engagementLastShownTimestampsToJson( + Map map, +) { + return map.map((key, value) => MapEntry(key, value.toIso8601String())); +} diff --git a/lib/src/models/user_settings/user_app_settings.g.dart b/lib/src/models/user_settings/user_app_settings.g.dart index 82deaa13..27e2e8f8 100644 --- a/lib/src/models/user_settings/user_app_settings.g.dart +++ b/lib/src/models/user_settings/user_app_settings.g.dart @@ -20,6 +20,13 @@ UserAppSettings _$UserAppSettingsFromJson(Map json) => : FeedDisplayPreferences.fromJson( json['feedPreferences'] as Map, ), + engagementShownCounts: + (json['engagementShownCounts'] as Map?)?.map( + (k, e) => MapEntry(k, (e as num).toInt()), + ), + engagementLastShownTimestamps: _engagementLastShownTimestampsFromJson( + json['engagementLastShownTimestamps'] as Map?, + ), ); Map _$UserAppSettingsToJson(UserAppSettings instance) => @@ -28,4 +35,8 @@ Map _$UserAppSettingsToJson(UserAppSettings instance) => 'displaySettings': instance.displaySettings.toJson(), 'language': instance.language, 'feedPreferences': instance.feedPreferences.toJson(), + 'engagementShownCounts': instance.engagementShownCounts, + 'engagementLastShownTimestamps': _engagementLastShownTimestampsToJson( + instance.engagementLastShownTimestamps, + ), }; diff --git a/test/src/models/auth/user_test.dart b/test/src/models/auth/user_test.dart index dd91bbb5..69fe07e0 100644 --- a/test/src/models/auth/user_test.dart +++ b/test/src/models/auth/user_test.dart @@ -42,15 +42,25 @@ void main() { test('has correct toString', () { expect( const User(id: id, email: email, role: standardRole).toString(), - equals('User(id: $id, email: $email, role: $standardRole)'), + equals( + 'User(id: $id, email: $email, role: $standardRole, createdAt: null)', + ), ); expect( const User(id: id, role: guestRole).toString(), - equals('User(id: $id, email: null, role: $guestRole)'), + equals('User(id: $id, email: null, role: $guestRole, createdAt: null)'), ); expect( const User(id: id, role: adminRole).toString(), - equals('User(id: $id, email: null, role: $adminRole)'), + equals('User(id: $id, email: null, role: $adminRole, createdAt: null)'), + ); + final now = DateTime.now(); + expect( + User(id: id, email: email, role: standardRole, createdAt: now) + .toString(), + equals( + 'User(id: $id, email: $email, role: $standardRole, createdAt: $now)', + ), ); }); diff --git a/test/src/models/user_settings/user_app_settings_test.dart b/test/src/models/user_settings/user_app_settings_test.dart index 62c2019a..6eff681c 100644 --- a/test/src/models/user_settings/user_app_settings_test.dart +++ b/test/src/models/user_settings/user_app_settings_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: inference_failure_on_collection_literal + import 'package:ht_shared/ht_shared.dart'; import 'package:test/test.dart'; @@ -70,6 +72,8 @@ void main() { customDisplaySettings, customLanguage, defaultFeedPreferences, + const {}, // engagementShownCounts + const {}, // engagementLastShownTimestamps ]), ); });