Skip to content

Commit 3378849

Browse files
authored
Merge pull request #29 from headlines-toolkit/feature_entity_details_page
Feature entity details page
2 parents 07b7101 + b00ff45 commit 3378849

24 files changed

+1220
-126
lines changed

lib/account/view/manage_followed_items/categories/followed_categories_list_page.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
22
import 'package:flutter_bloc/flutter_bloc.dart';
33
import 'package:go_router/go_router.dart';
44
import 'package:ht_main/account/bloc/account_bloc.dart';
5+
import 'package:ht_main/entity_details/view/entity_details_page.dart'; // Added
56
import 'package:ht_main/l10n/l10n.dart';
67
import 'package:ht_main/router/routes.dart';
78
import 'package:ht_main/shared/constants/app_spacing.dart';
@@ -111,6 +112,13 @@ class FollowedCategoriesListPage extends StatelessWidget {
111112
)
112113
: const Icon(Icons.category_outlined),
113114
title: Text(category.name),
115+
onTap: () {
116+
// Added onTap for navigation
117+
context.push(
118+
Routes.categoryDetails,
119+
extra: EntityDetailsPageArguments(entity: category),
120+
);
121+
},
114122
trailing: IconButton(
115123
icon: Icon(
116124
Icons.remove_circle_outline,

lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
22
import 'package:flutter_bloc/flutter_bloc.dart';
33
import 'package:go_router/go_router.dart';
44
import 'package:ht_main/account/bloc/account_bloc.dart';
5+
import 'package:ht_main/entity_details/view/entity_details_page.dart'; // Added
56
import 'package:ht_main/l10n/l10n.dart';
67
import 'package:ht_main/router/routes.dart';
78
import 'package:ht_main/shared/constants/app_spacing.dart';
@@ -95,6 +96,13 @@ class FollowedSourcesListPage extends StatelessWidget {
9596
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
9697
child: ListTile(
9798
title: Text(source.name),
99+
onTap: () {
100+
// Added onTap for navigation
101+
context.push(
102+
Routes.sourceDetails,
103+
extra: EntityDetailsPageArguments(entity: source),
104+
);
105+
},
98106
trailing: IconButton(
99107
icon: Icon(
100108
Icons.remove_circle_outline,

lib/account/view/saved_headlines_page.dart

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,6 @@ class SavedHeadlinesPage extends StatelessWidget {
103103
),
104104
trailing: trailingButton,
105105
);
106-
break;
107106
case HeadlineImageStyle.smallThumbnail:
108107
tile = HeadlineTileImageStart(
109108
headline: headline,
@@ -115,7 +114,6 @@ class SavedHeadlinesPage extends StatelessWidget {
115114
),
116115
trailing: trailingButton,
117116
);
118-
break;
119117
case HeadlineImageStyle.largeThumbnail:
120118
tile = HeadlineTileImageTop(
121119
headline: headline,
@@ -127,7 +125,6 @@ class SavedHeadlinesPage extends StatelessWidget {
127125
),
128126
trailing: trailingButton,
129127
);
130-
break;
131128
}
132129
return tile;
133130
},
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
import 'dart:async';
2+
3+
import 'package:bloc/bloc.dart';
4+
import 'package:equatable/equatable.dart';
5+
import 'package:ht_main/account/bloc/account_bloc.dart'; // Corrected import
6+
import 'package:ht_data_repository/ht_data_repository.dart';
7+
import 'package:ht_main/entity_details/models/entity_type.dart';
8+
import 'package:ht_shared/ht_shared.dart';
9+
10+
part 'entity_details_event.dart';
11+
part 'entity_details_state.dart';
12+
13+
class EntityDetailsBloc extends Bloc<EntityDetailsEvent, EntityDetailsState> {
14+
EntityDetailsBloc({
15+
required HtDataRepository<Headline> headlinesRepository,
16+
required HtDataRepository<Category> categoryRepository,
17+
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()) {
24+
on<EntityDetailsLoadRequested>(_onEntityDetailsLoadRequested);
25+
on<EntityDetailsToggleFollowRequested>(
26+
_onEntityDetailsToggleFollowRequested,
27+
);
28+
on<EntityDetailsLoadMoreHeadlinesRequested>(
29+
_onEntityDetailsLoadMoreHeadlinesRequested,
30+
);
31+
on<_EntityDetailsUserPreferencesChanged>(
32+
_onEntityDetailsUserPreferencesChanged,
33+
);
34+
35+
// Listen to AccountBloc for changes in user preferences
36+
_accountBlocSubscription = _accountBloc.stream.listen((accountState) {
37+
if (accountState.preferences != null) {
38+
add(_EntityDetailsUserPreferencesChanged(accountState.preferences!));
39+
}
40+
});
41+
}
42+
43+
final HtDataRepository<Headline> _headlinesRepository;
44+
final HtDataRepository<Category> _categoryRepository;
45+
final HtDataRepository<Source> _sourceRepository;
46+
final AccountBloc _accountBloc; // Changed to AccountBloc
47+
late final StreamSubscription<AccountState> _accountBlocSubscription;
48+
49+
static const _headlinesLimit = 10;
50+
51+
Future<void> _onEntityDetailsLoadRequested(
52+
EntityDetailsLoadRequested event,
53+
Emitter<EntityDetailsState> emit,
54+
) async {
55+
emit(
56+
state.copyWith(status: EntityDetailsStatus.loading, clearEntity: true),
57+
);
58+
59+
dynamic entityToLoad = event.entity;
60+
var entityTypeToLoad = event.entityType;
61+
62+
try {
63+
// 1. Determine/Fetch Entity
64+
if (entityToLoad == null &&
65+
event.entityId != null &&
66+
event.entityType != null) {
67+
entityTypeToLoad = event.entityType; // Ensure type is set
68+
if (event.entityType == EntityType.category) {
69+
entityToLoad = await _categoryRepository.read(id: event.entityId!);
70+
} else if (event.entityType == EntityType.source) {
71+
entityToLoad = await _sourceRepository.read(id: event.entityId!);
72+
} else {
73+
throw Exception('Unknown entity type for ID fetch');
74+
}
75+
} else if (entityToLoad != null) {
76+
// If entity is directly provided, determine its type
77+
if (entityToLoad is Category) {
78+
entityTypeToLoad = EntityType.category;
79+
} else if (entityToLoad is Source) {
80+
entityTypeToLoad = EntityType.source;
81+
} else {
82+
throw Exception('Provided entity is of unknown type');
83+
}
84+
}
85+
86+
if (entityToLoad == null || entityTypeToLoad == null) {
87+
emit(
88+
state.copyWith(
89+
status: EntityDetailsStatus.failure,
90+
errorMessage: 'Entity could not be determined or loaded.',
91+
),
92+
);
93+
return;
94+
}
95+
96+
// 2. Fetch Initial Headlines
97+
final queryParams = <String, dynamic>{};
98+
if (entityTypeToLoad == EntityType.category) {
99+
queryParams['categories'] = (entityToLoad as Category).id;
100+
} else if (entityTypeToLoad == EntityType.source) {
101+
queryParams['sources'] = (entityToLoad as Source).id;
102+
}
103+
104+
final headlinesResponse = await _headlinesRepository.readAllByQuery(
105+
queryParams,
106+
limit: _headlinesLimit,
107+
);
108+
109+
// 3. Determine isFollowing status
110+
var isCurrentlyFollowing = false;
111+
final currentAccountState = _accountBloc.state;
112+
if (currentAccountState.preferences != null) {
113+
if (entityTypeToLoad == EntityType.category &&
114+
entityToLoad is Category) {
115+
isCurrentlyFollowing = currentAccountState
116+
.preferences!
117+
.followedCategories
118+
.any((cat) => cat.id == entityToLoad.id);
119+
} else if (entityTypeToLoad == EntityType.source &&
120+
entityToLoad is Source) {
121+
isCurrentlyFollowing = currentAccountState
122+
.preferences!
123+
.followedSources
124+
.any((src) => src.id == entityToLoad.id);
125+
}
126+
}
127+
128+
emit(
129+
state.copyWith(
130+
status: EntityDetailsStatus.success,
131+
entityType: entityTypeToLoad,
132+
entity: entityToLoad,
133+
isFollowing: isCurrentlyFollowing,
134+
headlines: headlinesResponse.items,
135+
headlinesStatus: EntityHeadlinesStatus.success,
136+
hasMoreHeadlines: headlinesResponse.hasMore,
137+
headlinesCursor: headlinesResponse.cursor,
138+
clearErrorMessage: true,
139+
),
140+
);
141+
} on HtHttpException catch (e) {
142+
emit(
143+
state.copyWith(
144+
status: EntityDetailsStatus.failure,
145+
errorMessage: e.message,
146+
entityType: entityTypeToLoad, // Keep type if known
147+
),
148+
);
149+
} catch (e) {
150+
emit(
151+
state.copyWith(
152+
status: EntityDetailsStatus.failure,
153+
errorMessage: 'An unexpected error occurred: $e',
154+
entityType: entityTypeToLoad, // Keep type if known
155+
),
156+
);
157+
}
158+
}
159+
160+
Future<void> _onEntityDetailsToggleFollowRequested(
161+
EntityDetailsToggleFollowRequested event,
162+
Emitter<EntityDetailsState> emit,
163+
) async {
164+
if (state.entity == null || state.entityType == null) {
165+
// Cannot toggle follow if no entity is loaded
166+
emit(
167+
state.copyWith(
168+
errorMessage: 'No entity loaded to follow/unfollow.',
169+
clearErrorMessage: false, // Keep existing error if any, or set new
170+
),
171+
);
172+
return;
173+
}
174+
175+
// Optimistic update of UI can be handled by listening to AccountBloc state changes
176+
// which will trigger _onEntityDetailsUserPreferencesChanged.
177+
178+
if (state.entityType == EntityType.category && state.entity is Category) {
179+
_accountBloc.add(
180+
AccountFollowCategoryToggled(category: state.entity as Category),
181+
);
182+
} else if (state.entityType == EntityType.source &&
183+
state.entity is Source) {
184+
_accountBloc.add(
185+
AccountFollowSourceToggled(source: state.entity as Source),
186+
);
187+
} else {
188+
// Should not happen if entity and entityType are consistent
189+
emit(
190+
state.copyWith(
191+
errorMessage: 'Cannot determine entity type to follow/unfollow.',
192+
clearErrorMessage: false,
193+
),
194+
);
195+
}
196+
// Note: We don't emit a new state here for `isFollowing` directly.
197+
// The change will propagate from AccountBloc -> _accountBlocSubscription
198+
// -> _EntityDetailsUserPreferencesChanged -> update state.isFollowing.
199+
// This keeps AccountBloc as the source of truth for preferences.
200+
}
201+
202+
Future<void> _onEntityDetailsLoadMoreHeadlinesRequested(
203+
EntityDetailsLoadMoreHeadlinesRequested event,
204+
Emitter<EntityDetailsState> emit,
205+
) async {
206+
if (!state.hasMoreHeadlines ||
207+
state.headlinesStatus == EntityHeadlinesStatus.loadingMore) {
208+
return;
209+
}
210+
if (state.entity == null || state.entityType == null) return;
211+
212+
emit(state.copyWith(headlinesStatus: EntityHeadlinesStatus.loadingMore));
213+
214+
try {
215+
final queryParams = <String, dynamic>{};
216+
if (state.entityType == EntityType.category) {
217+
queryParams['categories'] = (state.entity as Category).id;
218+
} else if (state.entityType == EntityType.source) {
219+
queryParams['sources'] = (state.entity as Source).id;
220+
} else {
221+
// Should not happen
222+
emit(
223+
state.copyWith(
224+
headlinesStatus: EntityHeadlinesStatus.failure,
225+
errorMessage: 'Cannot load more headlines: Unknown entity type.',
226+
),
227+
);
228+
return;
229+
}
230+
231+
final headlinesResponse = await _headlinesRepository.readAllByQuery(
232+
queryParams,
233+
limit: _headlinesLimit,
234+
startAfterId: state.headlinesCursor,
235+
);
236+
237+
emit(
238+
state.copyWith(
239+
headlines: List.of(state.headlines)..addAll(headlinesResponse.items),
240+
headlinesStatus: EntityHeadlinesStatus.success,
241+
hasMoreHeadlines: headlinesResponse.hasMore,
242+
headlinesCursor: headlinesResponse.cursor,
243+
clearHeadlinesCursor: !headlinesResponse.hasMore, // Clear if no more
244+
),
245+
);
246+
} on HtHttpException catch (e) {
247+
emit(
248+
state.copyWith(
249+
headlinesStatus: EntityHeadlinesStatus.failure,
250+
errorMessage: e.message,
251+
),
252+
);
253+
} catch (e) {
254+
emit(
255+
state.copyWith(
256+
headlinesStatus: EntityHeadlinesStatus.failure,
257+
errorMessage: 'An unexpected error occurred: $e',
258+
),
259+
);
260+
}
261+
}
262+
263+
void _onEntityDetailsUserPreferencesChanged(
264+
_EntityDetailsUserPreferencesChanged event,
265+
Emitter<EntityDetailsState> emit,
266+
) {
267+
if (state.entity == null || state.entityType == null) return;
268+
269+
var isCurrentlyFollowing = false;
270+
final preferences = event.preferences;
271+
272+
if (state.entityType == EntityType.category && state.entity is Category) {
273+
final currentCategory = state.entity as Category;
274+
isCurrentlyFollowing = preferences.followedCategories.any(
275+
(cat) => cat.id == currentCategory.id,
276+
);
277+
} else if (state.entityType == EntityType.source &&
278+
state.entity is Source) {
279+
final currentSource = state.entity as Source;
280+
isCurrentlyFollowing = preferences.followedSources.any(
281+
(src) => src.id == currentSource.id,
282+
);
283+
}
284+
285+
if (state.isFollowing != isCurrentlyFollowing) {
286+
emit(state.copyWith(isFollowing: isCurrentlyFollowing));
287+
}
288+
}
289+
290+
@override
291+
Future<void> close() {
292+
_accountBlocSubscription.cancel();
293+
return super.close();
294+
}
295+
}

0 commit comments

Comments
 (0)