Skip to content

Commit 5ade1f6

Browse files
authored
Merge pull request #31 from headlines-toolkit/feature_feed_injector
Feature feed injector
2 parents 359b731 + bc0fb29 commit 5ade1f6

16 files changed

+1197
-396
lines changed

lib/app/bloc/app_bloc.dart

Lines changed: 82 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,24 @@ class AppBloc extends Bloc<AppEvent, AppState> {
1515
AppBloc({
1616
required HtAuthRepository authenticationRepository,
1717
required HtDataRepository<UserAppSettings> userAppSettingsRepository,
18-
}) : _authenticationRepository = authenticationRepository,
19-
_userAppSettingsRepository = userAppSettingsRepository,
20-
// Initialize with default state, load settings after user is known
21-
// Provide a default UserAppSettings instance
22-
super(
23-
const AppState(
24-
settings: UserAppSettings(id: 'default'),
25-
selectedBottomNavigationIndex: 0,
26-
),
27-
) {
18+
required HtDataRepository<AppConfig> appConfigRepository, // Added
19+
}) : _authenticationRepository = authenticationRepository,
20+
_userAppSettingsRepository = userAppSettingsRepository,
21+
_appConfigRepository = appConfigRepository, // Added
22+
// Initialize with default state, load settings after user is known
23+
// Provide a default UserAppSettings instance
24+
super(
25+
// AppConfig will be null initially, fetched later
26+
const AppState(
27+
settings: UserAppSettings(id: 'default'),
28+
selectedBottomNavigationIndex: 0,
29+
appConfig: null,
30+
),
31+
) {
2832
on<AppUserChanged>(_onAppUserChanged);
2933
on<AppSettingsRefreshed>(_onAppSettingsRefreshed);
34+
on<_AppConfigFetchRequested>(_onAppConfigFetchRequested);
35+
on<AppUserAccountActionShown>(_onAppUserAccountActionShown); // Added
3036
on<AppLogoutRequested>(_onLogoutRequested);
3137
on<AppThemeModeChanged>(_onThemeModeChanged);
3238
on<AppFlexSchemeChanged>(_onFlexSchemeChanged);
@@ -41,6 +47,7 @@ class AppBloc extends Bloc<AppEvent, AppState> {
4147

4248
final HtAuthRepository _authenticationRepository;
4349
final HtDataRepository<UserAppSettings> _userAppSettingsRepository;
50+
final HtDataRepository<AppConfig> _appConfigRepository; // Added
4451
late final StreamSubscription<User?> _userSubscription;
4552

4653
/// Handles user changes and loads initial settings once user is available.
@@ -67,6 +74,10 @@ class AppBloc extends Bloc<AppEvent, AppState> {
6774
if (event.user != null) {
6875
add(const AppSettingsRefreshed());
6976
}
77+
// Fetch AppConfig regardless of user, as it's global config
78+
// Or fetch it once at BLoC initialization if it doesn't depend on user at all.
79+
// For now, fetching after user ensures some app state is set.
80+
add(const _AppConfigFetchRequested());
7081
}
7182

7283
/// Handles refreshing/loading app settings (theme, font).
@@ -285,4 +296,65 @@ class AppBloc extends Bloc<AppEvent, AppState> {
285296
_userSubscription.cancel();
286297
return super.close();
287298
}
299+
300+
Future<void> _onAppConfigFetchRequested(
301+
_AppConfigFetchRequested event,
302+
Emitter<AppState> emit,
303+
) async {
304+
// Avoid refetching if already loaded, unless a refresh mechanism is added
305+
if (state.appConfig != null && state.status != AppStatus.initial) return;
306+
307+
try {
308+
final appConfig = await _appConfigRepository.read(id: 'app_config');
309+
emit(state.copyWith(appConfig: appConfig));
310+
} on NotFoundException {
311+
// If AppConfig is not found on the backend, use a local default.
312+
// The AppConfig model has default values for its nested configurations.
313+
emit(state.copyWith(appConfig: const AppConfig(id: 'app_config')));
314+
// Optionally, one might want to log this or attempt to create it on backend.
315+
print(
316+
'[AppBloc] AppConfig not found on backend, using local default.',
317+
);
318+
} on HtHttpException catch (e) {
319+
// Failed to fetch AppConfig, log error. App might be partially functional.
320+
print('[AppBloc] Failed to fetch AppConfig: ${e.message}');
321+
// Emit state with null appConfig or keep existing if partially loaded before
322+
emit(state.copyWith(appConfig: null, clearAppConfig: true));
323+
} catch (e) {
324+
print('[AppBloc] Unexpected error fetching AppConfig: $e');
325+
emit(state.copyWith(appConfig: null, clearAppConfig: true));
326+
}
327+
}
328+
329+
Future<void> _onAppUserAccountActionShown(
330+
AppUserAccountActionShown event,
331+
Emitter<AppState> emit,
332+
) async {
333+
if (state.user != null && state.user!.id == event.userId) {
334+
final now = DateTime.now();
335+
// Optimistically update the local user state.
336+
// Corrected parameter name for copyWith as per User model in models.txt
337+
final updatedUser = state.user!.copyWith(lastEngagementShownAt: now);
338+
339+
// Emit the change so UI can react if needed, and other BLoCs get the update.
340+
// This also ensures that FeedInjectorService will see the updated timestamp immediately.
341+
emit(state.copyWith(user: updatedUser));
342+
343+
// TODO: Persist this change to the backend.
344+
// This would typically involve calling a method on a repository, e.g.:
345+
// try {
346+
// await _authenticationRepository.updateUserLastActionTimestamp(event.userId, now);
347+
// // If the repository's authStateChanges stream doesn't automatically emit
348+
// // the updated user, you might need to re-fetch or handle it here.
349+
// // For now, we've optimistically updated the local state.
350+
// } catch (e) {
351+
// // Handle error, potentially revert optimistic update or show an error.
352+
// print('Failed to update lastAccountActionShownAt on backend: $e');
353+
// // Optionally revert: emit(state.copyWith(user: state.user)); // Reverts to original
354+
// }
355+
print(
356+
'[AppBloc] User ${event.userId} AccountAction shown. Last shown timestamp updated locally to $now. Backend update pending.',
357+
);
358+
}
359+
}
288360
}

lib/app/bloc/app_event.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,25 @@ class AppTextScaleFactorChanged extends AppEvent {
9191
@override
9292
List<Object?> get props => [appTextScaleFactor];
9393
}
94+
95+
/// {@template _app_config_fetch_requested}
96+
/// Internal event to trigger fetching of the global AppConfig.
97+
/// {@endtemplate}
98+
class _AppConfigFetchRequested extends AppEvent {
99+
/// {@macro _app_config_fetch_requested}
100+
const _AppConfigFetchRequested();
101+
}
102+
103+
/// {@template app_user_account_action_shown}
104+
/// Event triggered when an AccountAction has been shown to the user,
105+
/// prompting an update to their `lastAccountActionShownAt` timestamp.
106+
/// {@endtemplate}
107+
class AppUserAccountActionShown extends AppEvent {
108+
/// {@macro app_user_account_action_shown}
109+
const AppUserAccountActionShown({required this.userId});
110+
111+
final String userId;
112+
113+
@override
114+
List<Object> get props => [userId];
115+
}

lib/app/bloc/app_state.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class AppState extends Equatable {
2828
this.status = AppStatus.initial,
2929
this.user, // User is now nullable and defaults to null
3030
this.locale, // Added locale
31+
this.appConfig, // Added AppConfig
3132
});
3233

3334
/// The index of the currently selected item in the bottom navigation bar.
@@ -58,6 +59,9 @@ class AppState extends Equatable {
5859
/// The current application locale.
5960
final Locale? locale; // Added locale
6061

62+
/// The global application configuration (remote config).
63+
final AppConfig? appConfig; // Added AppConfig
64+
6165
/// Creates a copy of the current state with updated values.
6266
AppState copyWith({
6367
int? selectedBottomNavigationIndex,
@@ -69,8 +73,10 @@ class AppState extends Equatable {
6973
User? user,
7074
UserAppSettings? settings, // Add settings to copyWith
7175
Locale? locale, // Added locale
76+
AppConfig? appConfig, // Added AppConfig
7277
bool clearFontFamily = false,
7378
bool clearLocale = false, // Added to allow clearing locale
79+
bool clearAppConfig = false, // Added to allow clearing appConfig
7480
}) {
7581
return AppState(
7682
selectedBottomNavigationIndex:
@@ -83,6 +89,7 @@ class AppState extends Equatable {
8389
user: user ?? this.user,
8490
settings: settings ?? this.settings, // Copy settings
8591
locale: clearLocale ? null : locale ?? this.locale, // Added locale
92+
appConfig: clearAppConfig ? null : appConfig ?? this.appConfig, // Added
8693
);
8794
}
8895

@@ -97,5 +104,6 @@ class AppState extends Equatable {
97104
user,
98105
settings, // Include settings in props
99106
locale, // Added locale to props
107+
appConfig, // Added AppConfig to props
100108
];
101109
}

lib/app/view/app.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,10 @@ class App extends StatelessWidget {
7070
create:
7171
(context) => AppBloc(
7272
authenticationRepository: context.read<HtAuthRepository>(),
73-
// Pass generic data repositories for preferences
7473
userAppSettingsRepository:
7574
context.read<HtDataRepository<UserAppSettings>>(),
75+
appConfigRepository: // Added
76+
context.read<HtDataRepository<AppConfig>>(), // Added
7677
),
7778
),
7879
BlocProvider(

lib/entity_details/bloc/entity_details_bloc.dart

Lines changed: 83 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import 'dart:async';
33
import 'package:bloc/bloc.dart';
44
import 'package:equatable/equatable.dart';
55
import 'package:ht_data_repository/ht_data_repository.dart';
6-
import 'package:ht_main/account/bloc/account_bloc.dart'; // Corrected import
6+
import 'package:ht_main/account/bloc/account_bloc.dart';
7+
import 'package:ht_main/app/bloc/app_bloc.dart'; // Added
78
import 'package:ht_main/entity_details/models/entity_type.dart';
8-
import 'package:ht_shared/ht_shared.dart';
9+
import 'package:ht_main/shared/services/feed_injector_service.dart'; // Added
10+
import 'package:ht_shared/ht_shared.dart'; // Ensures FeedItem, AppConfig, User are available
911

1012
part 'entity_details_event.dart';
1113
part 'entity_details_state.dart';
@@ -15,12 +17,16 @@ class EntityDetailsBloc extends Bloc<EntityDetailsEvent, EntityDetailsState> {
1517
required HtDataRepository<Headline> headlinesRepository,
1618
required HtDataRepository<Category> categoryRepository,
1719
required HtDataRepository<Source> sourceRepository,
18-
required AccountBloc accountBloc, // Changed to AccountBloc
19-
}) : _headlinesRepository = headlinesRepository,
20-
_categoryRepository = categoryRepository,
21-
_sourceRepository = sourceRepository,
22-
_accountBloc = accountBloc,
23-
super(const EntityDetailsState()) {
20+
required AccountBloc accountBloc,
21+
required AppBloc appBloc, // Added
22+
required FeedInjectorService feedInjectorService, // Added
23+
}) : _headlinesRepository = headlinesRepository,
24+
_categoryRepository = categoryRepository,
25+
_sourceRepository = sourceRepository,
26+
_accountBloc = accountBloc,
27+
_appBloc = appBloc, // Added
28+
_feedInjectorService = feedInjectorService, // Added
29+
super(const EntityDetailsState()) {
2430
on<EntityDetailsLoadRequested>(_onEntityDetailsLoadRequested);
2531
on<EntityDetailsToggleFollowRequested>(
2632
_onEntityDetailsToggleFollowRequested,
@@ -43,10 +49,12 @@ class EntityDetailsBloc extends Bloc<EntityDetailsEvent, EntityDetailsState> {
4349
final HtDataRepository<Headline> _headlinesRepository;
4450
final HtDataRepository<Category> _categoryRepository;
4551
final HtDataRepository<Source> _sourceRepository;
46-
final AccountBloc _accountBloc; // Changed to AccountBloc
52+
final AccountBloc _accountBloc;
53+
final AppBloc _appBloc; // Added
54+
final FeedInjectorService _feedInjectorService; // Added
4755
late final StreamSubscription<AccountState> _accountBlocSubscription;
4856

49-
static const _headlinesLimit = 10;
57+
static const _headlinesLimit = 10; // For fetching original headlines
5058

5159
Future<void> _onEntityDetailsLoadRequested(
5260
EntityDetailsLoadRequested event,
@@ -101,11 +109,33 @@ class EntityDetailsBloc extends Bloc<EntityDetailsEvent, EntityDetailsState> {
101109
queryParams['sources'] = (entityToLoad as Source).id;
102110
}
103111

104-
final headlinesResponse = await _headlinesRepository.readAllByQuery(
112+
final headlineResponse = await _headlinesRepository.readAllByQuery(
105113
queryParams,
106114
limit: _headlinesLimit,
107115
);
108116

117+
final currentUser = _appBloc.state.user;
118+
final appConfig = _appBloc.state.appConfig;
119+
120+
if (appConfig == null) {
121+
emit(
122+
state.copyWith(
123+
status: EntityDetailsStatus.failure,
124+
errorMessage: 'App configuration not available.',
125+
entityType: entityTypeToLoad,
126+
entity: entityToLoad,
127+
),
128+
);
129+
return;
130+
}
131+
132+
final processedFeedItems = _feedInjectorService.injectItems(
133+
headlines: headlineResponse.items,
134+
user: currentUser,
135+
appConfig: appConfig,
136+
currentFeedItemCount: 0, // Initial load for this entity's feed
137+
);
138+
109139
// 3. Determine isFollowing status
110140
var isCurrentlyFollowing = false;
111141
final currentAccountState = _accountBloc.state;
@@ -131,13 +161,19 @@ class EntityDetailsBloc extends Bloc<EntityDetailsEvent, EntityDetailsState> {
131161
entityType: entityTypeToLoad,
132162
entity: entityToLoad,
133163
isFollowing: isCurrentlyFollowing,
134-
headlines: headlinesResponse.items,
164+
feedItems: processedFeedItems, // Changed
135165
headlinesStatus: EntityHeadlinesStatus.success,
136-
hasMoreHeadlines: headlinesResponse.hasMore,
137-
headlinesCursor: headlinesResponse.cursor,
166+
hasMoreHeadlines: headlineResponse.hasMore, // Based on original headlines
167+
headlinesCursor: headlineResponse.cursor,
138168
clearErrorMessage: true,
139169
),
140170
);
171+
172+
// Dispatch event if AccountAction was injected in the initial load
173+
if (processedFeedItems.any((item) => item is AccountAction) &&
174+
_appBloc.state.user?.id != null) {
175+
_appBloc.add(AppUserAccountActionShown(userId: _appBloc.state.user!.id));
176+
}
141177
} on HtHttpException catch (e) {
142178
emit(
143179
state.copyWith(
@@ -203,7 +239,7 @@ class EntityDetailsBloc extends Bloc<EntityDetailsEvent, EntityDetailsState> {
203239
EntityDetailsLoadMoreHeadlinesRequested event,
204240
Emitter<EntityDetailsState> emit,
205241
) async {
206-
if (!state.hasMoreHeadlines ||
242+
if (!state.hasMoreHeadlines || // Still refers to original headlines pagination
207243
state.headlinesStatus == EntityHeadlinesStatus.loadingMore) {
208244
return;
209245
}
@@ -218,7 +254,6 @@ class EntityDetailsBloc extends Bloc<EntityDetailsEvent, EntityDetailsState> {
218254
} else if (state.entityType == EntityType.source) {
219255
queryParams['sources'] = (state.entity as Source).id;
220256
} else {
221-
// Should not happen
222257
emit(
223258
state.copyWith(
224259
headlinesStatus: EntityHeadlinesStatus.failure,
@@ -228,21 +263,47 @@ class EntityDetailsBloc extends Bloc<EntityDetailsEvent, EntityDetailsState> {
228263
return;
229264
}
230265

231-
final headlinesResponse = await _headlinesRepository.readAllByQuery(
266+
final headlineResponse = await _headlinesRepository.readAllByQuery(
232267
queryParams,
233268
limit: _headlinesLimit,
234-
startAfterId: state.headlinesCursor,
269+
startAfterId: state.headlinesCursor, // Cursor for original headlines
270+
);
271+
272+
final currentUser = _appBloc.state.user;
273+
final appConfig = _appBloc.state.appConfig;
274+
275+
if (appConfig == null) {
276+
emit(
277+
state.copyWith(
278+
headlinesStatus: EntityHeadlinesStatus.failure,
279+
errorMessage: 'App configuration not available for pagination.',
280+
),
281+
);
282+
return;
283+
}
284+
285+
final newProcessedFeedItems = _feedInjectorService.injectItems(
286+
headlines: headlineResponse.items,
287+
user: currentUser,
288+
appConfig: appConfig,
289+
currentFeedItemCount: state.feedItems.length, // Pass current total
235290
);
236291

237292
emit(
238293
state.copyWith(
239-
headlines: List.of(state.headlines)..addAll(headlinesResponse.items),
294+
feedItems: List.of(state.feedItems)..addAll(newProcessedFeedItems), // Changed
240295
headlinesStatus: EntityHeadlinesStatus.success,
241-
hasMoreHeadlines: headlinesResponse.hasMore,
242-
headlinesCursor: headlinesResponse.cursor,
243-
clearHeadlinesCursor: !headlinesResponse.hasMore, // Clear if no more
296+
hasMoreHeadlines: headlineResponse.hasMore, // Based on original headlines
297+
headlinesCursor: headlineResponse.cursor,
298+
clearHeadlinesCursor: !headlineResponse.hasMore,
244299
),
245300
);
301+
302+
// Dispatch event if AccountAction was injected in the newly loaded items
303+
if (newProcessedFeedItems.any((item) => item is AccountAction) &&
304+
_appBloc.state.user?.id != null) {
305+
_appBloc.add(AppUserAccountActionShown(userId: _appBloc.state.user!.id));
306+
}
246307
} on HtHttpException catch (e) {
247308
emit(
248309
state.copyWith(

0 commit comments

Comments
 (0)