Skip to content

Feature feed injector #31

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Jun 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
df4643f
feat(router): inject FeedInjectorService into blocs
fulleni Jun 1, 2025
4ece27f
feat: Implement feed injection service
fulleni Jun 1, 2025
c91f6cb
feat(search): Support ads and account actions
fulleni Jun 1, 2025
2ffd617
refactor(search): rename results to items
fulleni Jun 1, 2025
cfca236
feat(search): Inject feed items in search results
fulleni Jun 1, 2025
903946c
feat(feed): support ads and account actions
fulleni Jun 1, 2025
d715d5e
refactor(feed): rename headlines to feed items
fulleni Jun 1, 2025
9f4178f
feat(feed): inject ads and account actions
fulleni Jun 1, 2025
c7ab168
feat(entity_details): inject feed items
fulleni Jun 1, 2025
3a9da2f
refactor(entity): rename headlines to feedItems
fulleni Jun 1, 2025
1393b60
feat(entity_details): Inject feed items
fulleni Jun 1, 2025
95045b2
feat(app): add app config to app bloc
fulleni Jun 1, 2025
0a1f8ed
feat: add app config and locale to AppState
fulleni Jun 1, 2025
1ec0145
feat: add AppConfig fetch event
fulleni Jun 1, 2025
05fca73
feat: fetch and use app config
fulleni Jun 1, 2025
159214e
build: update package resolved-ref in pubspec.lock
fulleni Jun 1, 2025
bd844ab
feat: Improve account action descriptions
fulleni Jun 1, 2025
5ee4eb0
feat(search): Dispatch event on AccountAction shown
fulleni Jun 1, 2025
5489427
feat(feed): track account action impressions
fulleni Jun 1, 2025
50285ef
feat(entity_details): Track account action shown
fulleni Jun 1, 2025
75fcf7a
feat: Add AppUserAccountActionShown event
fulleni Jun 1, 2025
bc0fb29
feat: Track user account action shown
fulleni Jun 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 82 additions & 10 deletions lib/app/bloc/app_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,24 @@ class AppBloc extends Bloc<AppEvent, AppState> {
AppBloc({
required HtAuthRepository authenticationRepository,
required HtDataRepository<UserAppSettings> 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<AppConfig> 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<AppUserChanged>(_onAppUserChanged);
on<AppSettingsRefreshed>(_onAppSettingsRefreshed);
on<_AppConfigFetchRequested>(_onAppConfigFetchRequested);
on<AppUserAccountActionShown>(_onAppUserAccountActionShown); // Added
on<AppLogoutRequested>(_onLogoutRequested);
on<AppThemeModeChanged>(_onThemeModeChanged);
on<AppFlexSchemeChanged>(_onFlexSchemeChanged);
Expand All @@ -41,6 +47,7 @@ class AppBloc extends Bloc<AppEvent, AppState> {

final HtAuthRepository _authenticationRepository;
final HtDataRepository<UserAppSettings> _userAppSettingsRepository;
final HtDataRepository<AppConfig> _appConfigRepository; // Added
late final StreamSubscription<User?> _userSubscription;

/// Handles user changes and loads initial settings once user is available.
Expand All @@ -67,6 +74,10 @@ class AppBloc extends Bloc<AppEvent, AppState> {
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).
Expand Down Expand Up @@ -285,4 +296,65 @@ class AppBloc extends Bloc<AppEvent, AppState> {
_userSubscription.cancel();
return super.close();
}

Future<void> _onAppConfigFetchRequested(
_AppConfigFetchRequested event,
Emitter<AppState> 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<void> _onAppUserAccountActionShown(
AppUserAccountActionShown event,
Emitter<AppState> 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.',
);
}
}
}
22 changes: 22 additions & 0 deletions lib/app/bloc/app_event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,25 @@ class AppTextScaleFactorChanged extends AppEvent {
@override
List<Object?> 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<Object> get props => [userId];
}
8 changes: 8 additions & 0 deletions lib/app/bloc/app_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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
);
}

Expand All @@ -97,5 +104,6 @@ class AppState extends Equatable {
user,
settings, // Include settings in props
locale, // Added locale to props
appConfig, // Added AppConfig to props
];
}
3 changes: 2 additions & 1 deletion lib/app/view/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,10 @@ class App extends StatelessWidget {
create:
(context) => AppBloc(
authenticationRepository: context.read<HtAuthRepository>(),
// Pass generic data repositories for preferences
userAppSettingsRepository:
context.read<HtDataRepository<UserAppSettings>>(),
appConfigRepository: // Added
context.read<HtDataRepository<AppConfig>>(), // Added
),
),
BlocProvider(
Expand Down
105 changes: 83 additions & 22 deletions lib/entity_details/bloc/entity_details_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -15,12 +17,16 @@ class EntityDetailsBloc extends Bloc<EntityDetailsEvent, EntityDetailsState> {
required HtDataRepository<Headline> headlinesRepository,
required HtDataRepository<Category> categoryRepository,
required HtDataRepository<Source> 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<EntityDetailsLoadRequested>(_onEntityDetailsLoadRequested);
on<EntityDetailsToggleFollowRequested>(
_onEntityDetailsToggleFollowRequested,
Expand All @@ -43,10 +49,12 @@ class EntityDetailsBloc extends Bloc<EntityDetailsEvent, EntityDetailsState> {
final HtDataRepository<Headline> _headlinesRepository;
final HtDataRepository<Category> _categoryRepository;
final HtDataRepository<Source> _sourceRepository;
final AccountBloc _accountBloc; // Changed to AccountBloc
final AccountBloc _accountBloc;
final AppBloc _appBloc; // Added
final FeedInjectorService _feedInjectorService; // Added
late final StreamSubscription<AccountState> _accountBlocSubscription;

static const _headlinesLimit = 10;
static const _headlinesLimit = 10; // For fetching original headlines

Future<void> _onEntityDetailsLoadRequested(
EntityDetailsLoadRequested event,
Expand Down Expand Up @@ -101,11 +109,33 @@ class EntityDetailsBloc extends Bloc<EntityDetailsEvent, EntityDetailsState> {
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;
Expand All @@ -131,13 +161,19 @@ class EntityDetailsBloc extends Bloc<EntityDetailsEvent, EntityDetailsState> {
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(
Expand Down Expand Up @@ -203,7 +239,7 @@ class EntityDetailsBloc extends Bloc<EntityDetailsEvent, EntityDetailsState> {
EntityDetailsLoadMoreHeadlinesRequested event,
Emitter<EntityDetailsState> emit,
) async {
if (!state.hasMoreHeadlines ||
if (!state.hasMoreHeadlines || // Still refers to original headlines pagination
state.headlinesStatus == EntityHeadlinesStatus.loadingMore) {
return;
}
Expand All @@ -218,7 +254,6 @@ class EntityDetailsBloc extends Bloc<EntityDetailsEvent, EntityDetailsState> {
} else if (state.entityType == EntityType.source) {
queryParams['sources'] = (state.entity as Source).id;
} else {
// Should not happen
emit(
state.copyWith(
headlinesStatus: EntityHeadlinesStatus.failure,
Expand All @@ -228,21 +263,47 @@ class EntityDetailsBloc extends Bloc<EntityDetailsEvent, EntityDetailsState> {
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(
Expand Down
Loading
Loading