diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index f555cf03..a7d07bab 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -15,18 +15,24 @@ class AppBloc extends Bloc { AppBloc({ required HtAuthRepository authenticationRepository, required HtDataRepository userAppSettingsRepository, - }) : _authenticationRepository = authenticationRepository, - _userAppSettingsRepository = userAppSettingsRepository, - // Initialize with default state, load settings after user is known - // Provide a default UserAppSettings instance - super( - const AppState( - settings: UserAppSettings(id: 'default'), - selectedBottomNavigationIndex: 0, - ), - ) { + required HtDataRepository appConfigRepository, // Added + }) : _authenticationRepository = authenticationRepository, + _userAppSettingsRepository = userAppSettingsRepository, + _appConfigRepository = appConfigRepository, // Added + // Initialize with default state, load settings after user is known + // Provide a default UserAppSettings instance + super( + // AppConfig will be null initially, fetched later + const AppState( + settings: UserAppSettings(id: 'default'), + selectedBottomNavigationIndex: 0, + appConfig: null, + ), + ) { on(_onAppUserChanged); on(_onAppSettingsRefreshed); + on<_AppConfigFetchRequested>(_onAppConfigFetchRequested); + on(_onAppUserAccountActionShown); // Added on(_onLogoutRequested); on(_onThemeModeChanged); on(_onFlexSchemeChanged); @@ -41,6 +47,7 @@ class AppBloc extends Bloc { final HtAuthRepository _authenticationRepository; final HtDataRepository _userAppSettingsRepository; + final HtDataRepository _appConfigRepository; // Added late final StreamSubscription _userSubscription; /// Handles user changes and loads initial settings once user is available. @@ -67,6 +74,10 @@ class AppBloc extends Bloc { if (event.user != null) { add(const AppSettingsRefreshed()); } + // Fetch AppConfig regardless of user, as it's global config + // Or fetch it once at BLoC initialization if it doesn't depend on user at all. + // For now, fetching after user ensures some app state is set. + add(const _AppConfigFetchRequested()); } /// Handles refreshing/loading app settings (theme, font). @@ -285,4 +296,65 @@ class AppBloc extends Bloc { _userSubscription.cancel(); return super.close(); } + + Future _onAppConfigFetchRequested( + _AppConfigFetchRequested event, + Emitter emit, + ) async { + // Avoid refetching if already loaded, unless a refresh mechanism is added + if (state.appConfig != null && state.status != AppStatus.initial) return; + + try { + final appConfig = await _appConfigRepository.read(id: 'app_config'); + emit(state.copyWith(appConfig: appConfig)); + } on NotFoundException { + // If AppConfig is not found on the backend, use a local default. + // The AppConfig model has default values for its nested configurations. + emit(state.copyWith(appConfig: const AppConfig(id: 'app_config'))); + // Optionally, one might want to log this or attempt to create it on backend. + print( + '[AppBloc] AppConfig not found on backend, using local default.', + ); + } on HtHttpException catch (e) { + // Failed to fetch AppConfig, log error. App might be partially functional. + print('[AppBloc] Failed to fetch AppConfig: ${e.message}'); + // Emit state with null appConfig or keep existing if partially loaded before + emit(state.copyWith(appConfig: null, clearAppConfig: true)); + } catch (e) { + print('[AppBloc] Unexpected error fetching AppConfig: $e'); + emit(state.copyWith(appConfig: null, clearAppConfig: true)); + } + } + + Future _onAppUserAccountActionShown( + AppUserAccountActionShown event, + Emitter emit, + ) async { + if (state.user != null && state.user!.id == event.userId) { + final now = DateTime.now(); + // Optimistically update the local user state. + // Corrected parameter name for copyWith as per User model in models.txt + final updatedUser = state.user!.copyWith(lastEngagementShownAt: now); + + // Emit the change so UI can react if needed, and other BLoCs get the update. + // This also ensures that FeedInjectorService will see the updated timestamp immediately. + emit(state.copyWith(user: updatedUser)); + + // TODO: Persist this change to the backend. + // This would typically involve calling a method on a repository, e.g.: + // try { + // await _authenticationRepository.updateUserLastActionTimestamp(event.userId, now); + // // If the repository's authStateChanges stream doesn't automatically emit + // // the updated user, you might need to re-fetch or handle it here. + // // For now, we've optimistically updated the local state. + // } catch (e) { + // // Handle error, potentially revert optimistic update or show an error. + // print('Failed to update lastAccountActionShownAt on backend: $e'); + // // Optionally revert: emit(state.copyWith(user: state.user)); // Reverts to original + // } + print( + '[AppBloc] User ${event.userId} AccountAction shown. Last shown timestamp updated locally to $now. Backend update pending.', + ); + } + } } diff --git a/lib/app/bloc/app_event.dart b/lib/app/bloc/app_event.dart index 68aaf5a0..263abae1 100644 --- a/lib/app/bloc/app_event.dart +++ b/lib/app/bloc/app_event.dart @@ -91,3 +91,25 @@ class AppTextScaleFactorChanged extends AppEvent { @override List get props => [appTextScaleFactor]; } + +/// {@template _app_config_fetch_requested} +/// Internal event to trigger fetching of the global AppConfig. +/// {@endtemplate} +class _AppConfigFetchRequested extends AppEvent { + /// {@macro _app_config_fetch_requested} + const _AppConfigFetchRequested(); +} + +/// {@template app_user_account_action_shown} +/// Event triggered when an AccountAction has been shown to the user, +/// prompting an update to their `lastAccountActionShownAt` timestamp. +/// {@endtemplate} +class AppUserAccountActionShown extends AppEvent { + /// {@macro app_user_account_action_shown} + const AppUserAccountActionShown({required this.userId}); + + final String userId; + + @override + List get props => [userId]; +} diff --git a/lib/app/bloc/app_state.dart b/lib/app/bloc/app_state.dart index e784ec83..04c01d8f 100644 --- a/lib/app/bloc/app_state.dart +++ b/lib/app/bloc/app_state.dart @@ -28,6 +28,7 @@ class AppState extends Equatable { this.status = AppStatus.initial, this.user, // User is now nullable and defaults to null this.locale, // Added locale + this.appConfig, // Added AppConfig }); /// The index of the currently selected item in the bottom navigation bar. @@ -58,6 +59,9 @@ class AppState extends Equatable { /// The current application locale. final Locale? locale; // Added locale + /// The global application configuration (remote config). + final AppConfig? appConfig; // Added AppConfig + /// Creates a copy of the current state with updated values. AppState copyWith({ int? selectedBottomNavigationIndex, @@ -69,8 +73,10 @@ class AppState extends Equatable { User? user, UserAppSettings? settings, // Add settings to copyWith Locale? locale, // Added locale + AppConfig? appConfig, // Added AppConfig bool clearFontFamily = false, bool clearLocale = false, // Added to allow clearing locale + bool clearAppConfig = false, // Added to allow clearing appConfig }) { return AppState( selectedBottomNavigationIndex: @@ -83,6 +89,7 @@ class AppState extends Equatable { user: user ?? this.user, settings: settings ?? this.settings, // Copy settings locale: clearLocale ? null : locale ?? this.locale, // Added locale + appConfig: clearAppConfig ? null : appConfig ?? this.appConfig, // Added ); } @@ -97,5 +104,6 @@ class AppState extends Equatable { user, settings, // Include settings in props locale, // Added locale to props + appConfig, // Added AppConfig to props ]; } diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index ec041cce..5d438e33 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -70,9 +70,10 @@ class App extends StatelessWidget { create: (context) => AppBloc( authenticationRepository: context.read(), - // Pass generic data repositories for preferences userAppSettingsRepository: context.read>(), + appConfigRepository: // Added + context.read>(), // Added ), ), BlocProvider( diff --git a/lib/entity_details/bloc/entity_details_bloc.dart b/lib/entity_details/bloc/entity_details_bloc.dart index d136d97f..ff3ca37d 100644 --- a/lib/entity_details/bloc/entity_details_bloc.dart +++ b/lib/entity_details/bloc/entity_details_bloc.dart @@ -3,9 +3,11 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; -import 'package:ht_main/account/bloc/account_bloc.dart'; // Corrected import +import 'package:ht_main/account/bloc/account_bloc.dart'; +import 'package:ht_main/app/bloc/app_bloc.dart'; // Added import 'package:ht_main/entity_details/models/entity_type.dart'; -import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_main/shared/services/feed_injector_service.dart'; // Added +import 'package:ht_shared/ht_shared.dart'; // Ensures FeedItem, AppConfig, User are available part 'entity_details_event.dart'; part 'entity_details_state.dart'; @@ -15,12 +17,16 @@ class EntityDetailsBloc extends Bloc { required HtDataRepository headlinesRepository, required HtDataRepository categoryRepository, required HtDataRepository sourceRepository, - required AccountBloc accountBloc, // Changed to AccountBloc - }) : _headlinesRepository = headlinesRepository, - _categoryRepository = categoryRepository, - _sourceRepository = sourceRepository, - _accountBloc = accountBloc, - super(const EntityDetailsState()) { + required AccountBloc accountBloc, + required AppBloc appBloc, // Added + required FeedInjectorService feedInjectorService, // Added + }) : _headlinesRepository = headlinesRepository, + _categoryRepository = categoryRepository, + _sourceRepository = sourceRepository, + _accountBloc = accountBloc, + _appBloc = appBloc, // Added + _feedInjectorService = feedInjectorService, // Added + super(const EntityDetailsState()) { on(_onEntityDetailsLoadRequested); on( _onEntityDetailsToggleFollowRequested, @@ -43,10 +49,12 @@ class EntityDetailsBloc extends Bloc { final HtDataRepository _headlinesRepository; final HtDataRepository _categoryRepository; final HtDataRepository _sourceRepository; - final AccountBloc _accountBloc; // Changed to AccountBloc + final AccountBloc _accountBloc; + final AppBloc _appBloc; // Added + final FeedInjectorService _feedInjectorService; // Added late final StreamSubscription _accountBlocSubscription; - static const _headlinesLimit = 10; + static const _headlinesLimit = 10; // For fetching original headlines Future _onEntityDetailsLoadRequested( EntityDetailsLoadRequested event, @@ -101,11 +109,33 @@ class EntityDetailsBloc extends Bloc { queryParams['sources'] = (entityToLoad as Source).id; } - final headlinesResponse = await _headlinesRepository.readAllByQuery( + final headlineResponse = await _headlinesRepository.readAllByQuery( queryParams, limit: _headlinesLimit, ); + final currentUser = _appBloc.state.user; + final appConfig = _appBloc.state.appConfig; + + if (appConfig == null) { + emit( + state.copyWith( + status: EntityDetailsStatus.failure, + errorMessage: 'App configuration not available.', + entityType: entityTypeToLoad, + entity: entityToLoad, + ), + ); + return; + } + + final processedFeedItems = _feedInjectorService.injectItems( + headlines: headlineResponse.items, + user: currentUser, + appConfig: appConfig, + currentFeedItemCount: 0, // Initial load for this entity's feed + ); + // 3. Determine isFollowing status var isCurrentlyFollowing = false; final currentAccountState = _accountBloc.state; @@ -131,13 +161,19 @@ class EntityDetailsBloc extends Bloc { entityType: entityTypeToLoad, entity: entityToLoad, isFollowing: isCurrentlyFollowing, - headlines: headlinesResponse.items, + feedItems: processedFeedItems, // Changed headlinesStatus: EntityHeadlinesStatus.success, - hasMoreHeadlines: headlinesResponse.hasMore, - headlinesCursor: headlinesResponse.cursor, + hasMoreHeadlines: headlineResponse.hasMore, // Based on original headlines + headlinesCursor: headlineResponse.cursor, clearErrorMessage: true, ), ); + + // Dispatch event if AccountAction was injected in the initial load + if (processedFeedItems.any((item) => item is AccountAction) && + _appBloc.state.user?.id != null) { + _appBloc.add(AppUserAccountActionShown(userId: _appBloc.state.user!.id)); + } } on HtHttpException catch (e) { emit( state.copyWith( @@ -203,7 +239,7 @@ class EntityDetailsBloc extends Bloc { EntityDetailsLoadMoreHeadlinesRequested event, Emitter emit, ) async { - if (!state.hasMoreHeadlines || + if (!state.hasMoreHeadlines || // Still refers to original headlines pagination state.headlinesStatus == EntityHeadlinesStatus.loadingMore) { return; } @@ -218,7 +254,6 @@ class EntityDetailsBloc extends Bloc { } else if (state.entityType == EntityType.source) { queryParams['sources'] = (state.entity as Source).id; } else { - // Should not happen emit( state.copyWith( headlinesStatus: EntityHeadlinesStatus.failure, @@ -228,21 +263,47 @@ class EntityDetailsBloc extends Bloc { return; } - final headlinesResponse = await _headlinesRepository.readAllByQuery( + final headlineResponse = await _headlinesRepository.readAllByQuery( queryParams, limit: _headlinesLimit, - startAfterId: state.headlinesCursor, + startAfterId: state.headlinesCursor, // Cursor for original headlines + ); + + final currentUser = _appBloc.state.user; + final appConfig = _appBloc.state.appConfig; + + if (appConfig == null) { + emit( + state.copyWith( + headlinesStatus: EntityHeadlinesStatus.failure, + errorMessage: 'App configuration not available for pagination.', + ), + ); + return; + } + + final newProcessedFeedItems = _feedInjectorService.injectItems( + headlines: headlineResponse.items, + user: currentUser, + appConfig: appConfig, + currentFeedItemCount: state.feedItems.length, // Pass current total ); emit( state.copyWith( - headlines: List.of(state.headlines)..addAll(headlinesResponse.items), + feedItems: List.of(state.feedItems)..addAll(newProcessedFeedItems), // Changed headlinesStatus: EntityHeadlinesStatus.success, - hasMoreHeadlines: headlinesResponse.hasMore, - headlinesCursor: headlinesResponse.cursor, - clearHeadlinesCursor: !headlinesResponse.hasMore, // Clear if no more + hasMoreHeadlines: headlineResponse.hasMore, // Based on original headlines + headlinesCursor: headlineResponse.cursor, + clearHeadlinesCursor: !headlineResponse.hasMore, ), ); + + // Dispatch event if AccountAction was injected in the newly loaded items + if (newProcessedFeedItems.any((item) => item is AccountAction) && + _appBloc.state.user?.id != null) { + _appBloc.add(AppUserAccountActionShown(userId: _appBloc.state.user!.id)); + } } on HtHttpException catch (e) { emit( state.copyWith( diff --git a/lib/entity_details/bloc/entity_details_state.dart b/lib/entity_details/bloc/entity_details_state.dart index 437e8750..250d17ac 100644 --- a/lib/entity_details/bloc/entity_details_state.dart +++ b/lib/entity_details/bloc/entity_details_state.dart @@ -12,9 +12,9 @@ class EntityDetailsState extends Equatable { this.entityType, this.entity, this.isFollowing = false, - this.headlines = const [], + this.feedItems = const [], // Changed from headlines this.headlinesStatus = EntityHeadlinesStatus.initial, - this.hasMoreHeadlines = true, + this.hasMoreHeadlines = true, // This refers to original headlines this.headlinesCursor, this.errorMessage, }); @@ -23,10 +23,10 @@ class EntityDetailsState extends Equatable { final EntityType? entityType; final dynamic entity; // Will be Category or Source final bool isFollowing; - final List headlines; + final List feedItems; // Changed from List final EntityHeadlinesStatus headlinesStatus; - final bool hasMoreHeadlines; - final String? headlinesCursor; + final bool hasMoreHeadlines; // This refers to original headlines + final String? headlinesCursor; // Cursor for fetching original headlines final String? errorMessage; EntityDetailsState copyWith({ @@ -34,7 +34,7 @@ class EntityDetailsState extends Equatable { EntityType? entityType, dynamic entity, bool? isFollowing, - List? headlines, + List? feedItems, // Changed EntityHeadlinesStatus? headlinesStatus, bool? hasMoreHeadlines, String? headlinesCursor, @@ -48,10 +48,10 @@ class EntityDetailsState extends Equatable { entityType: entityType ?? this.entityType, entity: clearEntity ? null : entity ?? this.entity, isFollowing: isFollowing ?? this.isFollowing, - headlines: headlines ?? this.headlines, + feedItems: feedItems ?? this.feedItems, // Changed headlinesStatus: headlinesStatus ?? this.headlinesStatus, hasMoreHeadlines: hasMoreHeadlines ?? this.hasMoreHeadlines, - headlinesCursor: + headlinesCursor: // This cursor is for fetching original headlines clearHeadlinesCursor ? null : headlinesCursor ?? this.headlinesCursor, errorMessage: clearErrorMessage ? null : errorMessage ?? this.errorMessage, @@ -64,7 +64,7 @@ class EntityDetailsState extends Equatable { entityType, entity, isFollowing, - headlines, + feedItems, // Changed headlinesStatus, hasMoreHeadlines, headlinesCursor, diff --git a/lib/entity_details/view/entity_details_page.dart b/lib/entity_details/view/entity_details_page.dart index 79c89952..99b093a8 100644 --- a/lib/entity_details/view/entity_details_page.dart +++ b/lib/entity_details/view/entity_details_page.dart @@ -9,6 +9,7 @@ import 'package:ht_main/entity_details/models/entity_type.dart'; import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; // Added import 'package:ht_main/shared/constants/app_spacing.dart'; +import 'package:ht_main/shared/services/feed_injector_service.dart'; // Added import 'package:ht_main/shared/widgets/widgets.dart'; import 'package:ht_shared/ht_shared.dart'; @@ -40,21 +41,27 @@ class EntityDetailsPage extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: - (context) => EntityDetailsBloc( - headlinesRepository: context.read>(), - categoryRepository: context.read>(), - sourceRepository: context.read>(), - accountBloc: context.read(), - )..add( - EntityDetailsLoadRequested( - entityId: args.entityId, - entityType: args.entityType, - entity: args.entity, - ), + return BlocProvider( // Explicitly type BlocProvider + create: (context) { + final feedInjectorService = FeedInjectorService(); + final entityDetailsBloc = EntityDetailsBloc( + headlinesRepository: context.read>(), + categoryRepository: context.read>(), + sourceRepository: context.read>(), + accountBloc: context.read(), + appBloc: context.read(), + feedInjectorService: feedInjectorService, + ); + entityDetailsBloc.add( + EntityDetailsLoadRequested( + entityId: args.entityId, + entityType: args.entityType, + entity: args.entity, ), - child: EntityDetailsView(args: args), // Pass args + ); + return entityDetailsBloc; + }, + child: EntityDetailsView(args: args), ); } } @@ -112,17 +119,17 @@ class _EntityDetailsViewState extends State { if (state.status == EntityDetailsStatus.initial || (state.status == EntityDetailsStatus.loading && state.entity == null)) { - return const LoadingStateWidget( - icon: Icons.info_outline, // Or a more specific icon - headline: 'Loading Details', // Replace with l10n - subheadline: 'Please wait...', // Replace with l10n + return LoadingStateWidget( + icon: Icons.info_outline, + headline: l10n.headlineDetailsLoadingHeadline, // Used generic loading + subheadline: l10n.pleaseWait, // Used generic please wait ); } if (state.status == EntityDetailsStatus.failure && state.entity == null) { return FailureStateWidget( - message: state.errorMessage ?? 'Failed to load details.', // l10n + message: state.errorMessage ?? l10n.unknownError, // Used generic error onRetry: () => context.read().add( EntityDetailsLoadRequested( @@ -248,7 +255,7 @@ class _EntityDetailsViewState extends State { ), const SizedBox(height: AppSpacing.lg), ], - if (state.headlines.isNotEmpty || + if (state.feedItems.isNotEmpty || // Changed state.headlinesStatus == EntityHeadlinesStatus.loadingMore) ...[ Text( @@ -261,12 +268,11 @@ class _EntityDetailsViewState extends State { ), ), ), - if (state.headlines.isEmpty && + if (state.feedItems.isEmpty && // Changed state.headlinesStatus != EntityHeadlinesStatus.initial && state.headlinesStatus != EntityHeadlinesStatus.loadingMore && state.status == EntityDetailsStatus.success) SliverFillRemaining( - // Use SliverFillRemaining for empty state child: Center( child: Text( l10n.noHeadlinesFoundMessage, @@ -278,89 +284,161 @@ class _EntityDetailsViewState extends State { SliverList( delegate: SliverChildBuilderDelegate( (context, index) { - if (index >= state.headlines.length) { - return state.hasMoreHeadlines && + if (index >= state.feedItems.length) { // Changed + return state.hasMoreHeadlines && // hasMoreHeadlines still refers to original headlines state.headlinesStatus == EntityHeadlinesStatus.loadingMore ? const Center( - child: Padding( - padding: EdgeInsets.all(AppSpacing.md), - child: CircularProgressIndicator(), - ), - ) + child: Padding( + padding: EdgeInsets.all(AppSpacing.md), + child: CircularProgressIndicator(), + ), + ) : const SizedBox.shrink(); } - final headline = state.headlines[index]; - final imageStyle = - context - .watch() - .state - .settings - .feedPreferences - .headlineImageStyle; + final item = state.feedItems[index]; // Changed - Widget tile; - switch (imageStyle) { - case HeadlineImageStyle.hidden: - tile = HeadlineTileTextOnly( - headline: headline, - onHeadlineTap: - () => context.pushNamed( - Routes - .globalArticleDetailsName, // Use new global route - pathParameters: {'id': headline.id}, - extra: headline, - ), - currentContextEntityType: state.entityType, - currentContextEntityId: - state.entity is Category - ? (state.entity as Category).id - : state.entity is Source - ? (state.entity as Source).id - : null, - ); - case HeadlineImageStyle.smallThumbnail: - tile = HeadlineTileImageStart( - headline: headline, - onHeadlineTap: - () => context.pushNamed( - Routes - .globalArticleDetailsName, // Use new global route - pathParameters: {'id': headline.id}, - extra: headline, + if (item is Headline) { + final imageStyle = context + .watch() + .state + .settings + .feedPreferences + .headlineImageStyle; + Widget tile; + switch (imageStyle) { + case HeadlineImageStyle.hidden: + tile = HeadlineTileTextOnly( + headline: item, + onHeadlineTap: () => context.pushNamed( + Routes.globalArticleDetailsName, + pathParameters: {'id': item.id}, + extra: item, + ), + currentContextEntityType: state.entityType, + currentContextEntityId: state.entity is Category + ? (state.entity as Category).id + : state.entity is Source + ? (state.entity as Source).id + : null, + ); + break; + case HeadlineImageStyle.smallThumbnail: + tile = HeadlineTileImageStart( + headline: item, + onHeadlineTap: () => context.pushNamed( + Routes.globalArticleDetailsName, + pathParameters: {'id': item.id}, + extra: item, + ), + currentContextEntityType: state.entityType, + currentContextEntityId: state.entity is Category + ? (state.entity as Category).id + : state.entity is Source + ? (state.entity as Source).id + : null, + ); + break; + case HeadlineImageStyle.largeThumbnail: + tile = HeadlineTileImageTop( + headline: item, + onHeadlineTap: () => context.pushNamed( + Routes.globalArticleDetailsName, + pathParameters: {'id': item.id}, + extra: item, + ), + currentContextEntityType: state.entityType, + currentContextEntityId: state.entity is Category + ? (state.entity as Category).id + : state.entity is Source + ? (state.entity as Source).id + : null, + ); + break; + } + return tile; + } else if (item is Ad) { + return Card( + margin: const EdgeInsets.symmetric( + horizontal: AppSpacing.paddingMedium, + vertical: AppSpacing.xs, + ), + color: theme.colorScheme.surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + children: [ + if (item.imageUrl.isNotEmpty) + Image.network( + item.imageUrl, + height: 100, + errorBuilder: (ctx, err, st) => + const Icon(Icons.broken_image, size: 50), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Placeholder Ad: ${item.adType?.name ?? 'Generic'}', + style: theme.textTheme.titleSmall, ), - currentContextEntityType: state.entityType, - currentContextEntityId: - state.entity is Category - ? (state.entity as Category).id - : state.entity is Source - ? (state.entity as Source).id - : null, - ); - case HeadlineImageStyle.largeThumbnail: - tile = HeadlineTileImageTop( - headline: headline, - onHeadlineTap: - () => context.pushNamed( - Routes - .globalArticleDetailsName, // Use new global route - pathParameters: {'id': headline.id}, - extra: headline, + Text( + 'Placement: ${item.placement?.name ?? 'Default'}', + style: theme.textTheme.bodySmall, ), - currentContextEntityType: state.entityType, - currentContextEntityId: - state.entity is Category - ? (state.entity as Category).id - : state.entity is Source - ? (state.entity as Source).id - : null, - ); + ], + ), + ), + ); + } else if (item is AccountAction) { + return Card( + margin: const EdgeInsets.symmetric( + horizontal: AppSpacing.paddingMedium, + vertical: AppSpacing.xs, + ), + color: theme.colorScheme.secondaryContainer, + child: ListTile( + leading: Icon( + item.accountActionType == AccountActionType.linkAccount + ? Icons.link + : Icons.upgrade, + color: theme.colorScheme.onSecondaryContainer, + ), + title: Text( + item.title, + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSecondaryContainer, + fontWeight: FontWeight.bold, + ), + ), + subtitle: item.description != null + ? Text( + item.description!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSecondaryContainer.withOpacity(0.8), + ), + ) + : null, + trailing: item.callToActionText != null + ? ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.secondary, + foregroundColor: theme.colorScheme.onSecondary, + ), + onPressed: () { + if (item.callToActionUrl != null) { + context.push(item.callToActionUrl!); + } + }, + child: Text(item.callToActionText!), + ) + : null, + isThreeLine: item.description != null && item.description!.length > 50, + ), + ); } - return tile; + return const SizedBox.shrink(); // Should not happen }, - childCount: - state.headlines.length + - (state.hasMoreHeadlines && + childCount: state.feedItems.length + // Changed + (state.hasMoreHeadlines && // hasMoreHeadlines still refers to original headlines state.headlinesStatus == EntityHeadlinesStatus.loadingMore ? 1 @@ -369,7 +447,7 @@ class _EntityDetailsViewState extends State { ), // Error display for headline loading specifically if (state.headlinesStatus == EntityHeadlinesStatus.failure && - state.headlines.isNotEmpty) + state.feedItems.isNotEmpty) // Changed SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(AppSpacing.md), diff --git a/lib/headlines-feed/bloc/headlines_feed_bloc.dart b/lib/headlines-feed/bloc/headlines_feed_bloc.dart index fbb73ac2..7ce28eec 100644 --- a/lib/headlines-feed/bloc/headlines_feed_bloc.dart +++ b/lib/headlines-feed/bloc/headlines_feed_bloc.dart @@ -4,9 +4,10 @@ import 'package:bloc/bloc.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:equatable/equatable.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; // Generic Data Repository +import 'package:ht_main/app/bloc/app_bloc.dart'; // Added import 'package:ht_main/headlines-feed/models/headline_filter.dart'; -import 'package:ht_shared/ht_shared.dart' - show Headline, HtHttpException; // Shared models and standardized exceptions +import 'package:ht_main/shared/services/feed_injector_service.dart'; // Added +import 'package:ht_shared/ht_shared.dart'; // Updated for FeedItem, AppConfig, User part 'headlines_feed_event.dart'; part 'headlines_feed_state.dart'; @@ -15,15 +16,21 @@ part 'headlines_feed_state.dart'; /// Manages the state for the headlines feed feature. /// /// Handles fetching headlines, applying filters, pagination, and refreshing -/// the feed using the provided [HtDataRepository]. +/// the feed using the provided [HtDataRepository]. It uses [FeedInjectorService] +/// to inject ads and account actions into the feed. /// {@endtemplate} class HeadlinesFeedBloc extends Bloc { /// {@macro headlines_feed_bloc} /// - /// Requires a [HtDataRepository] to interact with the data layer. - HeadlinesFeedBloc({required HtDataRepository headlinesRepository}) - : _headlinesRepository = headlinesRepository, - super(HeadlinesFeedInitial()) { + /// Requires repositories and services for its operations. + HeadlinesFeedBloc({ + required HtDataRepository headlinesRepository, + required FeedInjectorService feedInjectorService, // Added + required AppBloc appBloc, // Added + }) : _headlinesRepository = headlinesRepository, + _feedInjectorService = feedInjectorService, // Added + _appBloc = appBloc, // Added + super(HeadlinesFeedInitial()) { on( _onHeadlinesFeedFetchRequested, transformer: @@ -39,6 +46,8 @@ class HeadlinesFeedBloc extends Bloc { } final HtDataRepository _headlinesRepository; + final FeedInjectorService _feedInjectorService; // Added + final AppBloc _appBloc; // Added /// The number of headlines to fetch per page during pagination or initial load. static const _headlinesFetchLimit = 10; @@ -67,18 +76,41 @@ class HeadlinesFeedBloc extends Bloc { .join(','); } - final response = await _headlinesRepository.readAllByQuery( + final headlineResponse = await _headlinesRepository.readAllByQuery( queryParams, limit: _headlinesFetchLimit, ); + + final currentUser = _appBloc.state.user; + final appConfig = _appBloc.state.appConfig; + + if (appConfig == null) { + // AppConfig is crucial for injection rules. + emit(const HeadlinesFeedError(message: 'App configuration not available.')); + return; + } + + final processedFeedItems = _feedInjectorService.injectItems( + headlines: headlineResponse.items, + user: currentUser, + appConfig: appConfig, + currentFeedItemCount: 0, // Initial load for filters + ); + emit( HeadlinesFeedLoaded( - headlines: response.items, - hasMore: response.hasMore, - cursor: response.cursor, - filter: event.filter, // Store the applied filter + feedItems: processedFeedItems, + hasMore: headlineResponse.hasMore, + cursor: headlineResponse.cursor, + filter: event.filter, ), ); + + // Dispatch event if AccountAction was injected + if (processedFeedItems.any((item) => item is AccountAction) && + _appBloc.state.user?.id != null) { + _appBloc.add(AppUserAccountActionShown(userId: _appBloc.state.user!.id)); + } } on HtHttpException catch (e) { emit(HeadlinesFeedError(message: e.message)); } catch (e, st) { @@ -99,16 +131,38 @@ class HeadlinesFeedBloc extends Bloc { emit(HeadlinesFeedLoading()); // Show loading indicator try { // Fetch the first page with no filters - final response = await _headlinesRepository.readAll( + final headlineResponse = await _headlinesRepository.readAll( limit: _headlinesFetchLimit, ); + + final currentUser = _appBloc.state.user; + final appConfig = _appBloc.state.appConfig; + + if (appConfig == null) { + emit(const HeadlinesFeedError(message: 'App configuration not available.')); + return; + } + + final processedFeedItems = _feedInjectorService.injectItems( + headlines: headlineResponse.items, + user: currentUser, + appConfig: appConfig, + currentFeedItemCount: 0, + ); + emit( HeadlinesFeedLoaded( - headlines: response.items, - hasMore: response.hasMore, - cursor: response.cursor, + feedItems: processedFeedItems, + hasMore: headlineResponse.hasMore, + cursor: headlineResponse.cursor, ), ); + + // Dispatch event if AccountAction was injected + if (processedFeedItems.any((item) => item is AccountAction) && + _appBloc.state.user?.id != null) { + _appBloc.add(AppUserAccountActionShown(userId: _appBloc.state.user!.id)); + } } on HtHttpException catch (e) { emit(HeadlinesFeedError(message: e.message)); } catch (e, st) { @@ -130,42 +184,42 @@ class HeadlinesFeedBloc extends Bloc { Emitter emit, ) async { // Determine current filter and cursor based on state - var currentFilter = const HeadlineFilter(); // Made type explicit - var currentCursor = // Made type explicit - event.cursor; // Use event's cursor if provided (for pagination) - var currentHeadlines = []; - var isPaginating = false; + var currentFilter = const HeadlineFilter(); + String? currentCursorForFetch = event.cursor; + List currentFeedItems = []; + bool isPaginating = false; + int currentFeedItemCountForInjector = 0; if (state is HeadlinesFeedLoaded) { final loadedState = state as HeadlinesFeedLoaded; currentFilter = loadedState.filter; - // Only use state's cursor if event's cursor is null (i.e., not explicit pagination request) - currentCursor ??= loadedState.cursor; - currentHeadlines = loadedState.headlines; - // Check if we should paginate - isPaginating = event.cursor != null && loadedState.hasMore; - if (isPaginating && state is HeadlinesFeedLoadingSilently) { - return; // Avoid concurrent pagination - } - if (!loadedState.hasMore && event.cursor != null) { - return; // Don't fetch if no more items + currentFeedItems = loadedState.feedItems; + currentFeedItemCountForInjector = loadedState.feedItems.length; + + if (event.cursor != null) { // Explicit pagination request + if (!loadedState.hasMore) return; // No more items to fetch + isPaginating = true; + currentCursorForFetch = loadedState.cursor; // Use BLoC's cursor for safety + } else { // Initial fetch or refresh (event.cursor is null) + currentFeedItems = []; // Reset for non-pagination + currentFeedItemCountForInjector = 0; } - } else if (state is HeadlinesFeedLoading || - state is HeadlinesFeedLoadingSilently) { - // Avoid concurrent fetches if already loading, unless it's explicit pagination - if (event.cursor == null) return; + } else if (state is HeadlinesFeedLoading || state is HeadlinesFeedLoadingSilently) { + if (event.cursor == null) return; // Avoid concurrent initial fetches } + // For initial load or if event.cursor is null, currentCursorForFetch remains null. - // Emit appropriate loading state - if (isPaginating) { - emit(HeadlinesFeedLoadingSilently()); - } else { - // Initial load or load after error/clear - emit(HeadlinesFeedLoading()); - currentHeadlines = []; // Reset headlines on non-pagination fetch - } + emit(isPaginating ? HeadlinesFeedLoadingSilently() : HeadlinesFeedLoading()); try { + final currentUser = _appBloc.state.user; + final appConfig = _appBloc.state.appConfig; + + if (appConfig == null) { + emit(const HeadlinesFeedError(message: 'App configuration not available.')); + return; + } + final queryParams = {}; if (currentFilter.categories?.isNotEmpty ?? false) { queryParams['categories'] = currentFilter.categories! @@ -178,21 +232,37 @@ class HeadlinesFeedBloc extends Bloc { .join(','); } - final response = await _headlinesRepository.readAllByQuery( + final headlineResponse = await _headlinesRepository.readAllByQuery( queryParams, limit: _headlinesFetchLimit, - startAfterId: currentCursor, // Use determined cursor + startAfterId: currentCursorForFetch, ); + + final newProcessedFeedItems = _feedInjectorService.injectItems( + headlines: headlineResponse.items, + user: currentUser, + appConfig: appConfig, + currentFeedItemCount: currentFeedItemCountForInjector, + ); + + final resultingFeedItems = isPaginating + ? (List.of(currentFeedItems)..addAll(newProcessedFeedItems)) + : newProcessedFeedItems; + emit( HeadlinesFeedLoaded( - // Append if paginating, otherwise replace - headlines: - isPaginating ? currentHeadlines + response.items : response.items, - hasMore: response.hasMore, - cursor: response.cursor, - filter: currentFilter, // Preserve the filter + feedItems: resultingFeedItems, + hasMore: headlineResponse.hasMore, + cursor: headlineResponse.cursor, + filter: currentFilter, ), ); + + // Dispatch event if AccountAction was injected + if (newProcessedFeedItems.any((item) => item is AccountAction) && + _appBloc.state.user?.id != null) { + _appBloc.add(AppUserAccountActionShown(userId: _appBloc.state.user!.id)); + } } on HtHttpException catch (e) { emit(HeadlinesFeedError(message: e.message)); } catch (e, st) { @@ -211,38 +281,61 @@ class HeadlinesFeedBloc extends Bloc { ) async { emit(HeadlinesFeedLoading()); // Show loading indicator for refresh - // Determine the filter currently applied in the state - var currentFilter = const HeadlineFilter(); // Made type explicit + var currentFilter = const HeadlineFilter(); if (state is HeadlinesFeedLoaded) { currentFilter = (state as HeadlinesFeedLoaded).filter; } try { + final currentUser = _appBloc.state.user; + final appConfig = _appBloc.state.appConfig; + + if (appConfig == null) { + emit(const HeadlinesFeedError(message: 'App configuration not available.')); + return; + } + final queryParams = {}; if (currentFilter.categories?.isNotEmpty ?? false) { - queryParams['categories'] = currentFilter.categories! - .map((c) => c.id) - .join(','); + queryParams['categories'] = + currentFilter.categories!.map((c) => c.id).join(','); } if (currentFilter.sources?.isNotEmpty ?? false) { - queryParams['sources'] = currentFilter.sources! - .map((s) => s.id) - .join(','); + queryParams['sources'] = + currentFilter.sources!.map((s) => s.id).join(','); } - // Fetch the first page using the current filter - final response = await _headlinesRepository.readAllByQuery( + final headlineResponse = await _headlinesRepository.readAllByQuery( queryParams, limit: _headlinesFetchLimit, ); + + final List headlinesToInject = headlineResponse.items; + final User? userForInjector = currentUser; + final AppConfig configForInjector = appConfig; + const int itemCountForInjector = 0; + + final processedFeedItems = _feedInjectorService.injectItems( + headlines: headlinesToInject, + user: userForInjector, + appConfig: configForInjector, + currentFeedItemCount: itemCountForInjector, + ); + emit( HeadlinesFeedLoaded( - headlines: response.items, // Replace headlines on refresh - hasMore: response.hasMore, - cursor: response.cursor, - filter: currentFilter, // Preserve the filter + feedItems: processedFeedItems, + hasMore: headlineResponse.hasMore, + cursor: headlineResponse.cursor, + filter: currentFilter, ), ); + + // Dispatch event if AccountAction was injected + if (processedFeedItems.any((item) => item is AccountAction) && + _appBloc.state.user?.id != null) { + _appBloc.add(AppUserAccountActionShown(userId: _appBloc.state.user!.id)); + } } on HtHttpException catch (e) { emit(HeadlinesFeedError(message: e.message)); } catch (e, st) { diff --git a/lib/headlines-feed/bloc/headlines_feed_state.dart b/lib/headlines-feed/bloc/headlines_feed_state.dart index da70e7b1..628a49b7 100644 --- a/lib/headlines-feed/bloc/headlines_feed_state.dart +++ b/lib/headlines-feed/bloc/headlines_feed_state.dart @@ -1,5 +1,7 @@ part of 'headlines_feed_bloc.dart'; +// Removed import from here, will be added to headlines_feed_bloc.dart + /// {@template headlines_feed_state} /// Represents the possible states of the headlines feed feature. /// {@endtemplate} @@ -39,14 +41,14 @@ final class HeadlinesFeedLoadingSilently extends HeadlinesFeedState {} final class HeadlinesFeedLoaded extends HeadlinesFeedState { /// {@macro headlines_feed_loaded} const HeadlinesFeedLoaded({ - this.headlines = const [], + this.feedItems = const [], // Changed from headlines this.hasMore = true, this.cursor, this.filter = const HeadlineFilter(), }); - /// The list of [Headline] objects currently loaded. - final List headlines; + /// The list of [FeedItem] objects currently loaded. + final List feedItems; // Changed from List /// Flag indicating if there are more headlines available to fetch /// via pagination. `true` if more might exist, `false` otherwise. @@ -63,13 +65,13 @@ final class HeadlinesFeedLoaded extends HeadlinesFeedState { /// Creates a copy of this [HeadlinesFeedLoaded] state with the given fields /// replaced with new values. HeadlinesFeedLoaded copyWith({ - List? headlines, + List? feedItems, // Changed from List bool? hasMore, String? cursor, HeadlineFilter? filter, }) { return HeadlinesFeedLoaded( - headlines: headlines ?? this.headlines, + feedItems: feedItems ?? this.feedItems, // Changed hasMore: hasMore ?? this.hasMore, cursor: cursor ?? this.cursor, filter: filter ?? this.filter, @@ -77,7 +79,7 @@ final class HeadlinesFeedLoaded extends HeadlinesFeedState { } @override - List get props => [headlines, hasMore, cursor, filter]; + List get props => [feedItems, hasMore, cursor, filter]; // Changed } /// {@template headlines_feed_error} diff --git a/lib/headlines-feed/view/headlines_feed_page.dart b/lib/headlines-feed/view/headlines_feed_page.dart index f0684582..d12b24fc 100644 --- a/lib/headlines-feed/view/headlines_feed_page.dart +++ b/lib/headlines-feed/view/headlines_feed_page.dart @@ -10,8 +10,7 @@ import 'package:ht_main/headlines-feed/bloc/headlines_feed_bloc.dart'; import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; import 'package:ht_main/shared/shared.dart'; -import 'package:ht_shared/ht_shared.dart' - show HeadlineImageStyle; // Added HeadlineImageStyle +import 'package:ht_shared/ht_shared.dart'; // Import all of ht_shared /// {@template headlines_feed_view} /// The core view widget for the headlines feed. @@ -164,100 +163,184 @@ class _HeadlinesFeedPageState extends State { return const SizedBox.shrink(); case HeadlinesFeedLoaded(): - if (state.headlines.isEmpty) { - // If the list is empty after filters, show a message - // with a "Clear Filters" button using FailureStateWidget. + if (state.feedItems.isEmpty) { // Changed from state.headlines return FailureStateWidget( message: '${l10n.headlinesFeedEmptyFilteredHeadline}\n${l10n.headlinesFeedEmptyFilteredSubheadline}', onRetry: () { - // This will be our "Clear Filters" action context.read().add( HeadlinesFeedFiltersCleared(), ); }, retryButtonText: - l10n.headlinesFeedClearFiltersButton, // New l10n string + l10n.headlinesFeedClearFiltersButton, ); } - // Display the list of headlines with pull-to-refresh return RefreshIndicator( onRefresh: () async { - // Dispatch refresh event to the BLoC context.read().add( HeadlinesFeedRefreshRequested(), ); - // Note: BLoC handles emitting loading state during refresh }, child: ListView.separated( controller: _scrollController, padding: const EdgeInsets.only( top: AppSpacing.md, - bottom: - AppSpacing.xxl, // Ensure space below last item/loader + bottom: AppSpacing.xxl, ), - itemCount: - state.hasMore - ? state.headlines.length + - 1 // +1 for loading indicator - : state.headlines.length, - separatorBuilder: - (context, index) => const SizedBox( - height: AppSpacing.lg, - ), // Consistent spacing + itemCount: state.hasMore + ? state.feedItems.length + 1 // Changed + : state.feedItems.length, // Changed + separatorBuilder: (context, index) { + // Add a bit more space if the next item is an Ad or AccountAction + if (index < state.feedItems.length -1) { + final currentItem = state.feedItems[index]; + final nextItem = state.feedItems[index+1]; + if ((currentItem is Headline && (nextItem is Ad || nextItem is AccountAction)) || + ((currentItem is Ad || currentItem is AccountAction) && nextItem is Headline)) { + return const SizedBox(height: AppSpacing.md); + } + } + return const SizedBox(height: AppSpacing.lg); + }, itemBuilder: (context, index) { - // Check if it's the loading indicator item - if (index >= state.headlines.length) { - // Show loading indicator at the bottom if more items exist + if (index >= state.feedItems.length) { // Changed return const Padding( padding: EdgeInsets.symmetric(vertical: AppSpacing.lg), child: Center(child: CircularProgressIndicator()), ); } - // Otherwise, build the headline item - final headline = state.headlines[index]; - final imageStyle = - context - .watch() - .state - .settings - .feedPreferences - .headlineImageStyle; + final item = state.feedItems[index]; // Changed - Widget tile; - switch (imageStyle) { - case HeadlineImageStyle.hidden: - tile = HeadlineTileTextOnly( - headline: headline, - onHeadlineTap: - () => context.goNamed( - Routes.articleDetailsName, - pathParameters: {'id': headline.id}, - extra: headline, - ), - ); - case HeadlineImageStyle.smallThumbnail: - tile = HeadlineTileImageStart( - headline: headline, - onHeadlineTap: - () => context.goNamed( - Routes.articleDetailsName, - pathParameters: {'id': headline.id}, - extra: headline, + if (item is Headline) { + final imageStyle = context + .watch() + .state + .settings + .feedPreferences + .headlineImageStyle; + Widget tile; + switch (imageStyle) { + case HeadlineImageStyle.hidden: + tile = HeadlineTileTextOnly( + headline: item, + onHeadlineTap: () => context.goNamed( + Routes.articleDetailsName, + pathParameters: {'id': item.id}, + extra: item, + ), + ); + break; + case HeadlineImageStyle.smallThumbnail: + tile = HeadlineTileImageStart( + headline: item, + onHeadlineTap: () => context.goNamed( + Routes.articleDetailsName, + pathParameters: {'id': item.id}, + extra: item, + ), + ); + break; + case HeadlineImageStyle.largeThumbnail: + tile = HeadlineTileImageTop( + headline: item, + onHeadlineTap: () => context.goNamed( + Routes.articleDetailsName, + pathParameters: {'id': item.id}, + extra: item, + ), + ); + break; + } + return tile; + } else if (item is Ad) { + // Placeholder UI for Ad + return Card( + margin: const EdgeInsets.symmetric( + horizontal: AppSpacing.paddingMedium, + vertical: AppSpacing.xs, + ), + color: colorScheme.surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + children: [ + if (item.imageUrl.isNotEmpty) + Image.network( + item.imageUrl, + height: 100, + errorBuilder: (ctx, err, st) => + const Icon(Icons.broken_image, size: 50), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Placeholder Ad: ${item.adType?.name ?? 'Generic'}', + style: textTheme.titleSmall, ), - ); - case HeadlineImageStyle.largeThumbnail: - tile = HeadlineTileImageTop( - headline: headline, - onHeadlineTap: - () => context.goNamed( - Routes.articleDetailsName, - pathParameters: {'id': headline.id}, - extra: headline, + Text( + 'Placement: ${item.placement?.name ?? 'Default'}', + style: textTheme.bodySmall, ), - ); + if (item.targetUrl.isNotEmpty) + TextButton( + onPressed: () { + // TODO: Launch URL + }, + child: const Text('Visit Advertiser'), + ), + ], + ), + ), + ); + } else if (item is AccountAction) { + // Placeholder UI for AccountAction + return Card( + margin: const EdgeInsets.symmetric( + horizontal: AppSpacing.paddingMedium, + vertical: AppSpacing.xs, + ), + color: colorScheme.secondaryContainer, + child: ListTile( + leading: Icon( + item.accountActionType == AccountActionType.linkAccount + ? Icons.link + : Icons.upgrade, + color: colorScheme.onSecondaryContainer, + ), + title: Text( + item.title, + style: textTheme.titleMedium?.copyWith( + color: colorScheme.onSecondaryContainer, + fontWeight: FontWeight.bold, + ), + ), + subtitle: item.description != null + ? Text( + item.description!, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSecondaryContainer.withOpacity(0.8), + ), + ) + : null, + trailing: item.callToActionText != null + ? ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.secondary, + foregroundColor: colorScheme.onSecondary, + ), + onPressed: () { + if (item.callToActionUrl != null) { + context.push(item.callToActionUrl!); + } + }, + child: Text(item.callToActionText!), + ) + : null, + isThreeLine: item.description != null && item.description!.length > 50, + ), + ); } - return tile; + return const SizedBox.shrink(); // Should not happen }, ), ); diff --git a/lib/headlines-search/bloc/headlines_search_bloc.dart b/lib/headlines-search/bloc/headlines_search_bloc.dart index ba1661a2..af6cdec9 100644 --- a/lib/headlines-search/bloc/headlines_search_bloc.dart +++ b/lib/headlines-search/bloc/headlines_search_bloc.dart @@ -1,9 +1,11 @@ import 'package:bloc/bloc.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:equatable/equatable.dart'; -import 'package:ht_data_repository/ht_data_repository.dart'; // Generic Data Repository -import 'package:ht_main/headlines-search/models/search_model_type.dart'; // Import SearchModelType -import 'package:ht_shared/ht_shared.dart' show Category, Headline, HtHttpException, PaginatedResponse, Source; // Shared models +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_main/app/bloc/app_bloc.dart'; // Added +import 'package:ht_main/headlines-search/models/search_model_type.dart'; +import 'package:ht_main/shared/services/feed_injector_service.dart'; // Added +import 'package:ht_shared/ht_shared.dart'; // Updated for FeedItem, AppConfig, User etc. part 'headlines_search_event.dart'; part 'headlines_search_state.dart'; @@ -14,12 +16,14 @@ class HeadlinesSearchBloc required HtDataRepository headlinesRepository, required HtDataRepository categoryRepository, required HtDataRepository sourceRepository, - // required HtDataRepository countryRepository, // Removed - }) : _headlinesRepository = headlinesRepository, - _categoryRepository = categoryRepository, - _sourceRepository = sourceRepository, - // _countryRepository = countryRepository, // Removed - super(const HeadlinesSearchInitial()) { + required AppBloc appBloc, // Added + required FeedInjectorService feedInjectorService, // Added + }) : _headlinesRepository = headlinesRepository, + _categoryRepository = categoryRepository, + _sourceRepository = sourceRepository, + _appBloc = appBloc, // Added + _feedInjectorService = feedInjectorService, // Added + super(const HeadlinesSearchInitial()) { on(_onHeadlinesSearchModelTypeChanged); on( _onSearchFetchRequested, @@ -30,7 +34,8 @@ class HeadlinesSearchBloc final HtDataRepository _headlinesRepository; final HtDataRepository _categoryRepository; final HtDataRepository _sourceRepository; - // final HtDataRepository _countryRepository; // Removed + final AppBloc _appBloc; // Added + final FeedInjectorService _feedInjectorService; // Added static const _limit = 10; Future _onHeadlinesSearchModelTypeChanged( @@ -61,7 +66,7 @@ class HeadlinesSearchBloc if (searchTerm.isEmpty) { emit( HeadlinesSearchSuccess( - results: const [], + items: const [], // Changed hasMore: false, lastSearchTerm: '', selectedModelType: modelType, @@ -86,32 +91,62 @@ class HeadlinesSearchBloc limit: _limit, startAfterId: successState.cursor, ); + // Cast to List for the injector + final headlines = response.items.cast(); + final currentUser = _appBloc.state.user; + final appConfig = _appBloc.state.appConfig; + if (appConfig == null) { + emit(successState.copyWith(errorMessage: 'App configuration not available for pagination.')); + return; + } + final injectedItems = _feedInjectorService.injectItems( + headlines: headlines, + user: currentUser, + appConfig: appConfig, + currentFeedItemCount: successState.items.length, + ); + emit( + successState.copyWith( + items: List.of(successState.items)..addAll(injectedItems), + hasMore: response.hasMore, // hasMore from original headline fetch + cursor: response.cursor, + ), + ); + // Dispatch event if AccountAction was injected during pagination + if (injectedItems.any((item) => item is AccountAction) && + _appBloc.state.user?.id != null) { + _appBloc.add(AppUserAccountActionShown(userId: _appBloc.state.user!.id)); + } + break; case SearchModelType.category: response = await _categoryRepository.readAllByQuery( {'q': searchTerm, 'model': modelType.toJson()}, limit: _limit, startAfterId: successState.cursor, ); + emit( + successState.copyWith( + items: List.of(successState.items)..addAll(response.items.cast()), + hasMore: response.hasMore, + cursor: response.cursor, + ), + ); + break; // Added break case SearchModelType.source: response = await _sourceRepository.readAllByQuery( {'q': searchTerm, 'model': modelType.toJson()}, limit: _limit, startAfterId: successState.cursor, ); - // case SearchModelType.country: // Removed - // response = await _countryRepository.readAllByQuery( - // {'q': searchTerm, 'model': modelType.toJson()}, - // limit: _limit, - // startAfterId: successState.cursor, - // ); + emit( + successState.copyWith( + items: List.of(successState.items)..addAll(response.items.cast()), + hasMore: response.hasMore, + cursor: response.cursor, + ), + ); + break; // Added break } - emit( - successState.copyWith( - results: List.of(successState.results)..addAll(response.items), - hasMore: response.hasMore, - cursor: response.cursor, - ), - ); } on HtHttpException catch (e) { emit(successState.copyWith(errorMessage: e.message)); } catch (e, st) { @@ -132,38 +167,65 @@ class HeadlinesSearchBloc ), ); try { - PaginatedResponse response; + PaginatedResponse rawResponse; + List processedItems; + switch (modelType) { case SearchModelType.headline: - response = await _headlinesRepository.readAllByQuery({ - 'q': searchTerm, - 'model': modelType.toJson(), - }, limit: _limit,); + rawResponse = await _headlinesRepository.readAllByQuery( + {'q': searchTerm, 'model': modelType.toJson()}, + limit: _limit, + ); + final headlines = rawResponse.items.cast(); + final currentUser = _appBloc.state.user; + final appConfig = _appBloc.state.appConfig; + if (appConfig == null) { + emit( + HeadlinesSearchFailure( + errorMessage: 'App configuration not available.', + lastSearchTerm: searchTerm, + selectedModelType: modelType, + ), + ); + return; + } + processedItems = _feedInjectorService.injectItems( + headlines: headlines, + user: currentUser, + appConfig: appConfig, + currentFeedItemCount: 0, + ); + break; case SearchModelType.category: - response = await _categoryRepository.readAllByQuery({ - 'q': searchTerm, - 'model': modelType.toJson(), - }, limit: _limit,); + rawResponse = await _categoryRepository.readAllByQuery( + {'q': searchTerm, 'model': modelType.toJson()}, + limit: _limit, + ); + processedItems = rawResponse.items.cast(); + break; case SearchModelType.source: - response = await _sourceRepository.readAllByQuery({ - 'q': searchTerm, - 'model': modelType.toJson(), - }, limit: _limit,); - // case SearchModelType.country: // Removed - // response = await _countryRepository.readAllByQuery({ - // 'q': searchTerm, - // 'model': modelType.toJson(), - // }, limit: _limit,); + rawResponse = await _sourceRepository.readAllByQuery( + {'q': searchTerm, 'model': modelType.toJson()}, + limit: _limit, + ); + processedItems = rawResponse.items.cast(); + break; } emit( HeadlinesSearchSuccess( - results: response.items, - hasMore: response.hasMore, - cursor: response.cursor, + items: processedItems, + hasMore: rawResponse.hasMore, + cursor: rawResponse.cursor, lastSearchTerm: searchTerm, selectedModelType: modelType, ), ); + // Dispatch event if AccountAction was injected in new search + if (modelType == SearchModelType.headline && + processedItems.any((item) => item is AccountAction) && + _appBloc.state.user?.id != null) { + _appBloc.add(AppUserAccountActionShown(userId: _appBloc.state.user!.id)); + } } on HtHttpException catch (e) { emit( HeadlinesSearchFailure( diff --git a/lib/headlines-search/bloc/headlines_search_state.dart b/lib/headlines-search/bloc/headlines_search_state.dart index 09d35295..c7d091ea 100644 --- a/lib/headlines-search/bloc/headlines_search_state.dart +++ b/lib/headlines-search/bloc/headlines_search_state.dart @@ -31,7 +31,7 @@ class HeadlinesSearchLoading extends HeadlinesSearchState { /// State when a search has successfully returned results. class HeadlinesSearchSuccess extends HeadlinesSearchState { const HeadlinesSearchSuccess({ - required this.results, + required this.items, // Changed from results required this.hasMore, required this.lastSearchTerm, required super.selectedModelType, // The model type for these results @@ -39,14 +39,14 @@ class HeadlinesSearchSuccess extends HeadlinesSearchState { this.errorMessage, // For non-critical errors like pagination failure }); - final List results; // Can hold Headline, Category, Source, Country + final List items; // Changed from List to List final bool hasMore; final String? cursor; final String? errorMessage; // e.g., for pagination errors final String lastSearchTerm; // The term that yielded these results HeadlinesSearchSuccess copyWith({ - List? results, + List? items, // Changed bool? hasMore, String? cursor, String? errorMessage, @@ -55,7 +55,7 @@ class HeadlinesSearchSuccess extends HeadlinesSearchState { bool clearErrorMessage = false, }) { return HeadlinesSearchSuccess( - results: results ?? this.results, + items: items ?? this.items, // Changed hasMore: hasMore ?? this.hasMore, cursor: cursor ?? this.cursor, errorMessage: @@ -68,7 +68,7 @@ class HeadlinesSearchSuccess extends HeadlinesSearchState { @override List get props => [ ...super.props, - results, + items, // Changed hasMore, cursor, errorMessage, diff --git a/lib/headlines-search/view/headlines_search_page.dart b/lib/headlines-search/view/headlines_search_page.dart index 836347df..87520c62 100644 --- a/lib/headlines-search/view/headlines_search_page.dart +++ b/lib/headlines-search/view/headlines_search_page.dart @@ -16,8 +16,7 @@ import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; import 'package:ht_main/shared/constants/app_spacing.dart'; import 'package:ht_main/shared/shared.dart'; // Imports new headline tiles -// Adjusted imports to only include what's necessary after country removal -import 'package:ht_shared/ht_shared.dart' show Category, Headline, HeadlineImageStyle, SearchModelType, Source; +import 'package:ht_shared/ht_shared.dart'; // Changed to general import /// Page widget responsible for providing the BLoC for the headlines search feature. class HeadlinesSearchPage extends StatelessWidget { @@ -240,7 +239,7 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { 'Searching ${state.selectedModelType.displayName.toLowerCase()}...', ), HeadlinesSearchSuccess( - results: final results, + items: final items, // Changed from results: final results hasMore: final hasMore, errorMessage: final errorMessage, lastSearchTerm: final lastSearchTerm, @@ -256,7 +255,7 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { ), ), ) - : results.isEmpty + : items.isEmpty ? FailureStateWidget( message: '${l10n.headlinesSearchNoResultsHeadline} for "$lastSearchTerm" in ${resultsModelType.displayName.toLowerCase()}.\n${l10n.headlinesSearchNoResultsSubheadline}', @@ -264,67 +263,148 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { : ListView.separated( controller: _scrollController, padding: const EdgeInsets.all(AppSpacing.paddingMedium), - itemCount: hasMore ? results.length + 1 : results.length, - separatorBuilder: - (context, index) => const SizedBox(height: AppSpacing.md), + itemCount: hasMore ? items.length + 1 : items.length, + separatorBuilder: (context, index) { + // Add a bit more space if the next item is an Ad or AccountAction + if (index < items.length -1) { + final currentItem = items[index]; + final nextItem = items[index+1]; + if ((currentItem is Headline && (nextItem is Ad || nextItem is AccountAction)) || + ((currentItem is Ad || currentItem is AccountAction) && nextItem is Headline)) { + return const SizedBox(height: AppSpacing.md); + } + } + return const SizedBox(height: AppSpacing.md); + }, itemBuilder: (context, index) { - if (index >= results.length) { + if (index >= items.length) { return const Padding( padding: EdgeInsets.symmetric(vertical: AppSpacing.lg), child: Center(child: CircularProgressIndicator()), ); } - final item = results[index]; - // The switch is now exhaustive for the remaining SearchModelType values - switch (resultsModelType) { - case SearchModelType.headline: - final headline = item as Headline; - final imageStyle = - context - .watch() - .state - .settings - .feedPreferences - .headlineImageStyle; - Widget tile; - switch (imageStyle) { - case HeadlineImageStyle.hidden: - tile = HeadlineTileTextOnly( - headline: headline, - onHeadlineTap: - () => context.goNamed( - Routes.searchArticleDetailsName, - pathParameters: {'id': headline.id}, - extra: headline, - ), - ); - case HeadlineImageStyle.smallThumbnail: - tile = HeadlineTileImageStart( - headline: headline, - onHeadlineTap: - () => context.goNamed( - Routes.searchArticleDetailsName, - pathParameters: {'id': headline.id}, - extra: headline, + final feedItem = items[index]; + + if (feedItem is Headline) { + final imageStyle = context + .watch() + .state + .settings + .feedPreferences + .headlineImageStyle; + Widget tile; + switch (imageStyle) { + case HeadlineImageStyle.hidden: + tile = HeadlineTileTextOnly( + headline: feedItem, + onHeadlineTap: () => context.goNamed( + Routes.searchArticleDetailsName, + pathParameters: {'id': feedItem.id}, + extra: feedItem, + ), + ); + break; + case HeadlineImageStyle.smallThumbnail: + tile = HeadlineTileImageStart( + headline: feedItem, + onHeadlineTap: () => context.goNamed( + Routes.searchArticleDetailsName, + pathParameters: {'id': feedItem.id}, + extra: feedItem, + ), + ); + break; + case HeadlineImageStyle.largeThumbnail: + tile = HeadlineTileImageTop( + headline: feedItem, + onHeadlineTap: () => context.goNamed( + Routes.searchArticleDetailsName, + pathParameters: {'id': feedItem.id}, + extra: feedItem, + ), + ); + break; + } + return tile; + } else if (feedItem is Category) { + return CategoryItemWidget(category: feedItem); + } else if (feedItem is Source) { + return SourceItemWidget(source: feedItem); + } else if (feedItem is Ad) { + // Placeholder UI for Ad + return Card( + margin: const EdgeInsets.symmetric(vertical: AppSpacing.xs), + color: colorScheme.surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + children: [ + if (feedItem.imageUrl.isNotEmpty) + Image.network( + feedItem.imageUrl, + height: 100, + errorBuilder: (ctx, err, st) => + const Icon(Icons.broken_image, size: 50), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Placeholder Ad: ${feedItem.adType?.name ?? 'Generic'}', + style: theme.textTheme.titleSmall, + ), + Text( + 'Placement: ${feedItem.placement?.name ?? 'Default'}', + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + ); + } else if (feedItem is AccountAction) { + // Placeholder UI for AccountAction + return Card( + margin: const EdgeInsets.symmetric(vertical: AppSpacing.xs), + color: colorScheme.secondaryContainer, + child: ListTile( + leading: Icon( + feedItem.accountActionType == AccountActionType.linkAccount + ? Icons.link + : Icons.upgrade, + color: colorScheme.onSecondaryContainer, + ), + title: Text( + feedItem.title, + style: theme.textTheme.titleMedium?.copyWith( + color: colorScheme.onSecondaryContainer, + fontWeight: FontWeight.bold, + ), + ), + subtitle: feedItem.description != null + ? Text( + feedItem.description!, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSecondaryContainer.withOpacity(0.8), ), - ); - case HeadlineImageStyle.largeThumbnail: - tile = HeadlineTileImageTop( - headline: headline, - onHeadlineTap: - () => context.goNamed( - Routes.searchArticleDetailsName, - pathParameters: {'id': headline.id}, - extra: headline, + ) + : null, + trailing: feedItem.callToActionText != null + ? ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.secondary, + foregroundColor: colorScheme.onSecondary, ), - ); - } - return tile; - case SearchModelType.category: - return CategoryItemWidget(category: item as Category); - case SearchModelType.source: - return SourceItemWidget(source: item as Source); + onPressed: () { + if (feedItem.callToActionUrl != null) { + context.push(feedItem.callToActionUrl!); + } + }, + child: Text(feedItem.callToActionText!), + ) + : null, + isThreeLine: feedItem.description != null && feedItem.description!.length > 50, + ), + ); } + return const SizedBox.shrink(); // Should not happen }, ), HeadlinesSearchFailure( diff --git a/lib/router/router.dart b/lib/router/router.dart index a7402ba7..d2f358d6 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -27,6 +27,7 @@ import 'package:ht_main/headlines-feed/bloc/categories_filter_bloc.dart'; // Imp // import 'package:ht_main/headlines-feed/bloc/countries_filter_bloc.dart'; // Import new BLoC - REMOVED import 'package:ht_main/headlines-feed/bloc/headlines_feed_bloc.dart'; import 'package:ht_main/headlines-feed/bloc/sources_filter_bloc.dart'; // Import new BLoC +import 'package:ht_main/shared/services/feed_injector_service.dart'; // Added import 'package:ht_main/headlines-feed/view/category_filter_page.dart'; // import 'package:ht_main/headlines-feed/view/country_filter_page.dart'; // REMOVED import 'package:ht_main/headlines-feed/view/headlines_feed_page.dart'; @@ -420,24 +421,31 @@ GoRouter createRouter({ providers: [ BlocProvider.value(value: accountBloc), // Use the shared instance BlocProvider( - create: - (context) => HeadlinesFeedBloc( - headlinesRepository: - context.read>(), - )..add(const HeadlinesFeedFetchRequested()), + create: (context) { + // Instantiate FeedInjectorService here as it's stateless for now + final feedInjectorService = FeedInjectorService(); + return HeadlinesFeedBloc( + headlinesRepository: + context.read>(), + feedInjectorService: feedInjectorService, // Pass instance + appBloc: context.read(), // Pass AppBloc instance + )..add(const HeadlinesFeedFetchRequested()); + }, ), BlocProvider( - create: - (context) => HeadlinesSearchBloc( - headlinesRepository: - context.read>(), - categoryRepository: - context.read>(), - sourceRepository: - context.read>(), - // countryRepository: // Removed - // context.read>(), // Removed - ), + create: (context) { + final feedInjectorService = FeedInjectorService(); // Instantiate + return HeadlinesSearchBloc( + headlinesRepository: + context.read>(), + categoryRepository: + context.read>(), + sourceRepository: + context.read>(), + appBloc: context.read(), // Provide AppBloc + feedInjectorService: feedInjectorService, // Provide Service + ); + }, ), // Removed separate AccountBloc creation here ], diff --git a/lib/shared/services/feed_injector_service.dart b/lib/shared/services/feed_injector_service.dart new file mode 100644 index 00000000..46c71387 --- /dev/null +++ b/lib/shared/services/feed_injector_service.dart @@ -0,0 +1,231 @@ +import 'dart:math'; + +import 'package:ht_shared/ht_shared.dart'; + +/// A service responsible for injecting various types of FeedItems (like Ads +/// and AccountActions) into a list of primary content items (e.g., Headlines). +class FeedInjectorService { + final Random _random = Random(); + + /// Processes a list of [Headline] items and injects [Ad] and + /// [AccountAction] items based on the provided configurations and user state. + /// + /// Parameters: + /// - `headlines`: The list of original [Headline] items. + /// - `user`: The current [User] object (nullable). This is used to determine + /// user role for ad frequency and account action relevance. + /// - `appConfig`: The application's configuration ([AppConfig]), which contains + /// [AdConfig] for ad injection rules and [AccountActionConfig] for + /// account action rules. + /// - `currentFeedItemCount`: The total number of items already present in the + /// feed before this batch of headlines is processed. This is crucial for + /// correctly applying ad frequency and placement intervals, especially + /// during pagination. Defaults to 0 for the first batch. + /// + /// Returns a new list of [FeedItem] objects, interspersed with ads and + /// account actions according to the defined logic. + List injectItems({ + required List headlines, + required User? user, + required AppConfig appConfig, + int currentFeedItemCount = 0, + }) { + final List finalFeed = []; + bool accountActionInjectedThisBatch = false; + int headlinesInThisBatchCount = 0; + final adConfig = appConfig.adConfig; + final userRole = user?.role ?? UserRole.guestUser; + + int adFrequency; + int adPlacementInterval; + + switch (userRole) { + case UserRole.guestUser: + adFrequency = adConfig.guestAdFrequency; + adPlacementInterval = adConfig.guestAdPlacementInterval; + break; + case UserRole.standardUser: // Assuming 'authenticated' maps to standard + adFrequency = adConfig.authenticatedAdFrequency; + adPlacementInterval = adConfig.authenticatedAdPlacementInterval; + break; + case UserRole.premiumUser: + adFrequency = adConfig.premiumAdFrequency; + adPlacementInterval = adConfig.premiumAdPlacementInterval; + break; + default: // For any other roles, or if UserRole enum expands + adFrequency = adConfig.guestAdFrequency; // Default to guest ads + adPlacementInterval = adConfig.guestAdPlacementInterval; + break; + } + + // Determine if an AccountAction is due before iterating + final accountActionToInject = _getDueAccountActionDetails( + user: user, + appConfig: appConfig, + ); + + for (int i = 0; i < headlines.length; i++) { + final headline = headlines[i]; + finalFeed.add(headline); + headlinesInThisBatchCount++; + + final totalItemsSoFar = currentFeedItemCount + finalFeed.length; + + // 1. Inject AccountAction (if due and not already injected in this batch) + // Attempt to inject after the first headline of the current batch. + if (i == 0 && + accountActionToInject != null && + !accountActionInjectedThisBatch) { + finalFeed.add(accountActionToInject); + accountActionInjectedThisBatch = true; + // Note: AccountAction also counts as an item for ad placement interval + } + + // 2. Inject Ad + if (adFrequency > 0 && totalItemsSoFar >= adPlacementInterval) { + // Check frequency against headlines processed *in this batch* after interval met + // This is a simplified local frequency. A global counter might be needed for strict global frequency. + if (headlinesInThisBatchCount % adFrequency == 0) { + final adToInject = _getAdToInject(); + if (adToInject != null) { + finalFeed.add(adToInject); + } + } + } + } + return finalFeed; + } + + AccountAction? _getDueAccountActionDetails({ + required User? user, + required AppConfig appConfig, + }) { + final userRole = user?.role ?? UserRole.guestUser; // Default to guest if user is null + final now = DateTime.now(); + final lastActionShown = user?.lastAccountActionShownAt; + final daysBetweenActionsConfig = appConfig.accountActionConfig; + + int daysThreshold; + AccountActionType? actionType; + + if (userRole == UserRole.guestUser) { + daysThreshold = daysBetweenActionsConfig.guestDaysBetweenAccountActions; + actionType = AccountActionType.linkAccount; + } else if (userRole == UserRole.standardUser) { + // Assuming standardUser is the target for upgrade prompts + daysThreshold = daysBetweenActionsConfig.standardUserDaysBetweenAccountActions; + actionType = AccountActionType.upgrade; + } else { + // No account actions for premium users or other roles for now + return null; + } + + if (lastActionShown == null || + now.difference(lastActionShown).inDays >= daysThreshold) { + if (actionType == AccountActionType.linkAccount) { + return _buildLinkAccountActionVariant(appConfig); + } else if (actionType == AccountActionType.upgrade) { + return _buildUpgradeAccountActionVariant(appConfig); + } + } + return null; + } + + AccountAction _buildLinkAccountActionVariant(AppConfig appConfig) { + final prefs = appConfig.userPreferenceLimits; + // final prefs = appConfig.userPreferenceLimits; // Not using specific numbers + // final ads = appConfig.adConfig; // Not using specific numbers + final variant = _random.nextInt(3); + + String title; + String description; + String ctaText = 'Learn More'; + + switch (variant) { + case 0: + title = 'Unlock Your Full Potential!'; + description = + 'Link your account to enjoy expanded content access, keep your preferences synced, and experience a more streamlined ad display.'; + ctaText = 'Link Account & Explore'; + break; + case 1: + title = 'Personalize Your Experience!'; + description = + 'Secure your settings and reading history across all your devices by linking your account. Enjoy a tailored news journey!'; + ctaText = 'Secure My Preferences'; + break; + default: // case 2 + title = 'Get More From Your News!'; + description = + 'Link your account for enhanced content limits, better ad experiences, and ensure your preferences are always with you.'; + ctaText = 'Get Started'; + break; + } + + return AccountAction( + title: title, + description: description, + accountActionType: AccountActionType.linkAccount, + callToActionText: ctaText, + // The actual navigation for linking is typically handled by the UI + // when this action item is tapped. The URL can be a deep link or a route. + callToActionUrl: '/authentication?context=linking', + ); + } + + AccountAction _buildUpgradeAccountActionVariant(AppConfig appConfig) { + final prefs = appConfig.userPreferenceLimits; + // final prefs = appConfig.userPreferenceLimits; // Not using specific numbers + // final ads = appConfig.adConfig; // Not using specific numbers + final variant = _random.nextInt(3); + + String title; + String description; + String ctaText = 'Explore Premium'; + + switch (variant) { + case 0: + title = 'Unlock Our Best Features!'; + description = + 'Go Premium to enjoy our most comprehensive content access, the best ad experience, and many more exclusive perks.'; + ctaText = 'Upgrade Now'; + break; + case 1: + title = 'Elevate Your News Consumption!'; + description = + 'With Premium, your content limits are greatly expanded and you will enjoy our most favorable ad settings. Discover the difference!'; + ctaText = 'Discover Premium Benefits'; + break; + default: // case 2 + title = 'Want More Control & Fewer Interruptions?'; + description = + 'Upgrade to Premium for a superior ad experience, massively increased content limits, and a more focused news journey.'; + ctaText = 'Yes, Upgrade Me!'; + break; + } + return AccountAction( + title: title, + description: description, + accountActionType: AccountActionType.upgrade, + callToActionText: ctaText, + // URL could point to a subscription page/flow + callToActionUrl: '/account/upgrade', // Placeholder route + ); + } + + // Placeholder for _getAdToInject + Ad? _getAdToInject() { + // For now, return a placeholder Ad, always native. + // In a real scenario, this would fetch from an ad network or predefined list. + // final adPlacements = AdPlacement.values; // Can still use for variety if needed + + return Ad( + // id is generated by model if not provided + imageUrl: 'https://via.placeholder.com/300x100.png/000000/FFFFFF?Text=Native+Placeholder+Ad', // Adjusted placeholder + targetUrl: 'https://example.com/adtarget', + adType: AdType.native, // Always native + // Default placement or random from native-compatible placements + placement: AdPlacement.feedInlineNativeBanner, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 1a3d01dc..a01d5e4d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -403,7 +403,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: "5258cd241bbab3e68692963124eeef2a36fc7f64" + resolved-ref: d309fa8da138a706d22fe78c73046fb2cede06e5 url: "https://github.com/headlines-toolkit/ht-shared.git" source: git version: "0.0.0"