|
| 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