Skip to content

Commit 4ece27f

Browse files
committed
feat: Implement feed injection service
- Inject ads and account actions - Respect user roles and config - Handle guest, standard, premium users - Implement account action variants
1 parent df4643f commit 4ece27f

File tree

1 file changed

+229
-0
lines changed

1 file changed

+229
-0
lines changed
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import 'dart:math';
2+
3+
import 'package:ht_shared/ht_shared.dart';
4+
5+
/// A service responsible for injecting various types of FeedItems (like Ads
6+
/// and AccountActions) into a list of primary content items (e.g., Headlines).
7+
class FeedInjectorService {
8+
final Random _random = Random();
9+
10+
/// Processes a list of [Headline] items and injects [Ad] and
11+
/// [AccountAction] items based on the provided configurations and user state.
12+
///
13+
/// Parameters:
14+
/// - `headlines`: The list of original [Headline] items.
15+
/// - `user`: The current [User] object (nullable). This is used to determine
16+
/// user role for ad frequency and account action relevance.
17+
/// - `appConfig`: The application's configuration ([AppConfig]), which contains
18+
/// [AdConfig] for ad injection rules and [AccountActionConfig] for
19+
/// account action rules.
20+
/// - `currentFeedItemCount`: The total number of items already present in the
21+
/// feed before this batch of headlines is processed. This is crucial for
22+
/// correctly applying ad frequency and placement intervals, especially
23+
/// during pagination. Defaults to 0 for the first batch.
24+
///
25+
/// Returns a new list of [FeedItem] objects, interspersed with ads and
26+
/// account actions according to the defined logic.
27+
List<FeedItem> injectItems({
28+
required List<Headline> headlines,
29+
required User? user,
30+
required AppConfig appConfig,
31+
int currentFeedItemCount = 0,
32+
}) {
33+
final List<FeedItem> finalFeed = [];
34+
bool accountActionInjectedThisBatch = false;
35+
int headlinesInThisBatchCount = 0;
36+
final adConfig = appConfig.adConfig;
37+
final userRole = user?.role ?? UserRole.guestUser;
38+
39+
int adFrequency;
40+
int adPlacementInterval;
41+
42+
switch (userRole) {
43+
case UserRole.guestUser:
44+
adFrequency = adConfig.guestAdFrequency;
45+
adPlacementInterval = adConfig.guestAdPlacementInterval;
46+
break;
47+
case UserRole.standardUser: // Assuming 'authenticated' maps to standard
48+
adFrequency = adConfig.authenticatedAdFrequency;
49+
adPlacementInterval = adConfig.authenticatedAdPlacementInterval;
50+
break;
51+
case UserRole.premiumUser:
52+
adFrequency = adConfig.premiumAdFrequency;
53+
adPlacementInterval = adConfig.premiumAdPlacementInterval;
54+
break;
55+
default: // For any other roles, or if UserRole enum expands
56+
adFrequency = adConfig.guestAdFrequency; // Default to guest ads
57+
adPlacementInterval = adConfig.guestAdPlacementInterval;
58+
break;
59+
}
60+
61+
// Determine if an AccountAction is due before iterating
62+
final accountActionToInject = _getDueAccountActionDetails(
63+
user: user,
64+
appConfig: appConfig,
65+
);
66+
67+
for (int i = 0; i < headlines.length; i++) {
68+
final headline = headlines[i];
69+
finalFeed.add(headline);
70+
headlinesInThisBatchCount++;
71+
72+
final totalItemsSoFar = currentFeedItemCount + finalFeed.length;
73+
74+
// 1. Inject AccountAction (if due and not already injected in this batch)
75+
// Attempt to inject after the first headline of the current batch.
76+
if (i == 0 &&
77+
accountActionToInject != null &&
78+
!accountActionInjectedThisBatch) {
79+
finalFeed.add(accountActionToInject);
80+
accountActionInjectedThisBatch = true;
81+
// Note: AccountAction also counts as an item for ad placement interval
82+
}
83+
84+
// 2. Inject Ad
85+
if (adFrequency > 0 && totalItemsSoFar >= adPlacementInterval) {
86+
// Check frequency against headlines processed *in this batch* after interval met
87+
// This is a simplified local frequency. A global counter might be needed for strict global frequency.
88+
if (headlinesInThisBatchCount % adFrequency == 0) {
89+
final adToInject = _getAdToInject();
90+
if (adToInject != null) {
91+
finalFeed.add(adToInject);
92+
}
93+
}
94+
}
95+
}
96+
return finalFeed;
97+
}
98+
99+
AccountAction? _getDueAccountActionDetails({
100+
required User? user,
101+
required AppConfig appConfig,
102+
}) {
103+
final userRole = user?.role ?? UserRole.guestUser; // Default to guest if user is null
104+
final now = DateTime.now();
105+
final lastActionShown = user?.lastAccountActionShownAt;
106+
final daysBetweenActionsConfig = appConfig.accountActionConfig;
107+
108+
int daysThreshold;
109+
AccountActionType? actionType;
110+
111+
if (userRole == UserRole.guestUser) {
112+
daysThreshold = daysBetweenActionsConfig.guestDaysBetweenAccountActions;
113+
actionType = AccountActionType.linkAccount;
114+
} else if (userRole == UserRole.standardUser) {
115+
// Assuming standardUser is the target for upgrade prompts
116+
daysThreshold = daysBetweenActionsConfig.standardUserDaysBetweenAccountActions;
117+
actionType = AccountActionType.upgrade;
118+
} else {
119+
// No account actions for premium users or other roles for now
120+
return null;
121+
}
122+
123+
if (lastActionShown == null ||
124+
now.difference(lastActionShown).inDays >= daysThreshold) {
125+
if (actionType == AccountActionType.linkAccount) {
126+
return _buildLinkAccountActionVariant(appConfig);
127+
} else if (actionType == AccountActionType.upgrade) {
128+
return _buildUpgradeAccountActionVariant(appConfig);
129+
}
130+
}
131+
return null;
132+
}
133+
134+
AccountAction _buildLinkAccountActionVariant(AppConfig appConfig) {
135+
final prefs = appConfig.userPreferenceLimits;
136+
final ads = appConfig.adConfig;
137+
final variant = _random.nextInt(3);
138+
139+
String title;
140+
String description;
141+
String ctaText = 'Learn More'; // Generic CTA
142+
143+
switch (variant) {
144+
case 0:
145+
title = 'Unlock More Features!';
146+
description =
147+
'Link your account to save up to ${prefs.authenticatedSavedHeadlinesLimit} headlines and follow ${prefs.authenticatedFollowedItemsLimit} topics. Plus, enjoy a less frequent ad experience!';
148+
ctaText = 'Link Account & Explore';
149+
break;
150+
case 1:
151+
title = 'Keep Your Preferences Safe!';
152+
description =
153+
'By linking your account, your followed items (up to ${prefs.authenticatedFollowedItemsLimit}) and saved articles (up to ${prefs.authenticatedSavedHeadlinesLimit}) are synced across devices.';
154+
ctaText = 'Secure My Preferences';
155+
break;
156+
default: // case 2
157+
title = 'Enhance Your News Journey!';
158+
description =
159+
'Get more out of your feed. Link your account for higher content limits (save ${prefs.authenticatedSavedHeadlinesLimit}, follow ${prefs.authenticatedFollowedItemsLimit}) and see ads less often (currently every ${ads.guestAdFrequency} items, improves with linking).';
160+
ctaText = 'Get Started';
161+
break;
162+
}
163+
164+
return AccountAction(
165+
title: title,
166+
description: description,
167+
accountActionType: AccountActionType.linkAccount,
168+
callToActionText: ctaText,
169+
// The actual navigation for linking is typically handled by the UI
170+
// when this action item is tapped. The URL can be a deep link or a route.
171+
callToActionUrl: '/authentication?context=linking',
172+
);
173+
}
174+
175+
AccountAction _buildUpgradeAccountActionVariant(AppConfig appConfig) {
176+
final prefs = appConfig.userPreferenceLimits;
177+
final ads = appConfig.adConfig;
178+
final variant = _random.nextInt(3);
179+
180+
String title;
181+
String description;
182+
String ctaText = 'Explore Premium'; // Generic CTA
183+
184+
switch (variant) {
185+
case 0:
186+
title = 'Go Premium for the Ultimate Experience!';
187+
description =
188+
'Upgrade to enjoy an ad-free feed (or significantly fewer ads: ${ads.premiumAdFrequency} vs ${ads.authenticatedAdFrequency} items), save up to ${prefs.premiumSavedHeadlinesLimit} headlines, and follow ${prefs.premiumFollowedItemsLimit} interests!';
189+
ctaText = 'Upgrade Now';
190+
break;
191+
case 1:
192+
title = 'Maximize Your Content Access!';
193+
description =
194+
'With Premium, your limits expand! Follow ${prefs.premiumFollowedItemsLimit} sources/categories and save ${prefs.premiumSavedHeadlinesLimit} articles. Experience our best ad settings.';
195+
ctaText = 'Discover Premium Benefits';
196+
break;
197+
default: // case 2
198+
title = 'Tired of Ads? Want More Saves?';
199+
description =
200+
'Upgrade to Premium for a superior ad experience (frequency: ${ads.premiumAdFrequency}) and massively increased limits: save ${prefs.premiumSavedHeadlinesLimit} headlines & follow ${prefs.premiumFollowedItemsLimit} items.';
201+
ctaText = 'Yes, Upgrade Me!';
202+
break;
203+
}
204+
return AccountAction(
205+
title: title,
206+
description: description,
207+
accountActionType: AccountActionType.upgrade,
208+
callToActionText: ctaText,
209+
// URL could point to a subscription page/flow
210+
callToActionUrl: '/account/upgrade', // Placeholder route
211+
);
212+
}
213+
214+
// Placeholder for _getAdToInject
215+
Ad? _getAdToInject() {
216+
// For now, return a placeholder Ad.
217+
// In a real scenario, this would fetch from an ad network or predefined list.
218+
final adTypes = AdType.values;
219+
final adPlacements = AdPlacement.values;
220+
221+
return Ad(
222+
// id is generated by model if not provided
223+
imageUrl: 'https://via.placeholder.com/300x250.png/000000/FFFFFF?Text=Placeholder+Ad',
224+
targetUrl: 'https://example.com/adtarget',
225+
adType: adTypes[_random.nextInt(adTypes.length)],
226+
placement: adPlacements[_random.nextInt(adPlacements.length)],
227+
);
228+
}
229+
}

0 commit comments

Comments
 (0)