Skip to content

Commit 62e0583

Browse files
committed
feat: Implement entity details page
- Displays category/source details - Loads and displays headlines - Implements follow/unfollow logic
1 parent 0441379 commit 62e0583

File tree

1 file changed

+330
-0
lines changed

1 file changed

+330
-0
lines changed
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_bloc/flutter_bloc.dart';
3+
import 'package:go_router/go_router.dart'; // Added
4+
import 'package:ht_main/account/bloc/account_bloc.dart';
5+
import 'package:ht_main/app/bloc/app_bloc.dart'; // For accessing settings
6+
import 'package:ht_main/entity_details/bloc/entity_details_bloc.dart';
7+
import 'package:ht_main/entity_details/models/entity_type.dart';
8+
import 'package:ht_main/l10n/l10n.dart';
9+
import 'package:ht_main/router/routes.dart'; // Added
10+
import 'package:ht_main/shared/constants/app_spacing.dart';
11+
import 'package:ht_main/shared/widgets/widgets.dart';
12+
import 'package:ht_shared/ht_shared.dart';
13+
import 'package:ht_data_repository/ht_data_repository.dart'; // For repository provider
14+
15+
class EntityDetailsPageArguments {
16+
const EntityDetailsPageArguments({
17+
this.entityId,
18+
this.entityType,
19+
this.entity,
20+
}) : assert(
21+
(entityId != null && entityType != null) || entity != null,
22+
'Either entityId/entityType or entity must be provided.',
23+
);
24+
25+
final String? entityId;
26+
final EntityType? entityType;
27+
final dynamic entity; // Category or Source
28+
}
29+
30+
class EntityDetailsPage extends StatelessWidget {
31+
const EntityDetailsPage({super.key, required this.args});
32+
33+
final EntityDetailsPageArguments args;
34+
35+
static Route<void> route({required EntityDetailsPageArguments args}) {
36+
return MaterialPageRoute<void>(
37+
builder: (_) => EntityDetailsPage(args: args),
38+
);
39+
}
40+
41+
@override
42+
Widget build(BuildContext context) {
43+
return BlocProvider(
44+
create: (context) => EntityDetailsBloc(
45+
headlinesRepository: context.read<HtDataRepository<Headline>>(),
46+
categoryRepository: context.read<HtDataRepository<Category>>(),
47+
sourceRepository: context.read<HtDataRepository<Source>>(),
48+
accountBloc: context.read<AccountBloc>(),
49+
)..add(
50+
EntityDetailsLoadRequested(
51+
entityId: args.entityId,
52+
entityType: args.entityType,
53+
entity: args.entity,
54+
),
55+
),
56+
child: EntityDetailsView(args: args), // Pass args
57+
);
58+
}
59+
}
60+
61+
class EntityDetailsView extends StatefulWidget {
62+
const EntityDetailsView({super.key, required this.args}); // Accept args
63+
64+
final EntityDetailsPageArguments args; // Store args
65+
66+
@override
67+
State<EntityDetailsView> createState() => _EntityDetailsViewState();
68+
}
69+
70+
class _EntityDetailsViewState extends State<EntityDetailsView> {
71+
final _scrollController = ScrollController();
72+
73+
@override
74+
void initState() {
75+
super.initState();
76+
_scrollController.addListener(_onScroll);
77+
}
78+
79+
@override
80+
void dispose() {
81+
_scrollController
82+
..removeListener(_onScroll)
83+
..dispose();
84+
super.dispose();
85+
}
86+
87+
void _onScroll() {
88+
if (_isBottom) {
89+
context
90+
.read<EntityDetailsBloc>()
91+
.add(const EntityDetailsLoadMoreHeadlinesRequested());
92+
}
93+
}
94+
95+
bool get _isBottom {
96+
if (!_scrollController.hasClients) return false;
97+
final maxScroll = _scrollController.position.maxScrollExtent;
98+
final currentScroll = _scrollController.offset;
99+
// Trigger load a bit before reaching the absolute bottom
100+
return currentScroll >= (maxScroll * 0.9);
101+
}
102+
103+
@override
104+
Widget build(BuildContext context) {
105+
final l10n = context.l10n;
106+
final theme = Theme.of(context);
107+
108+
return Scaffold(
109+
body: BlocBuilder<EntityDetailsBloc, EntityDetailsState>(
110+
builder: (context, state) {
111+
if (state.status == EntityDetailsStatus.initial ||
112+
(state.status == EntityDetailsStatus.loading &&
113+
state.entity == null)) {
114+
return const LoadingStateWidget(
115+
icon: Icons.info_outline, // Or a more specific icon
116+
headline: 'Loading Details', // Replace with l10n
117+
subheadline: 'Please wait...', // Replace with l10n
118+
);
119+
}
120+
121+
if (state.status == EntityDetailsStatus.failure &&
122+
state.entity == null) {
123+
return FailureStateWidget(
124+
message: state.errorMessage ?? 'Failed to load details.', // l10n
125+
onRetry: () => context.read<EntityDetailsBloc>().add(
126+
EntityDetailsLoadRequested(
127+
entityId: widget.args.entityId,
128+
entityType: widget.args.entityType,
129+
entity: widget.args.entity,
130+
),
131+
),
132+
);
133+
}
134+
135+
// At this point, state.entity should not be null if success or loading more
136+
final String appBarTitle = state.entity is Category
137+
? (state.entity as Category).name
138+
: state.entity is Source
139+
? (state.entity as Source).name
140+
: l10n.detailsPageTitle;
141+
142+
final String? description = state.entity is Category
143+
? (state.entity as Category).description
144+
: state.entity is Source
145+
? (state.entity as Source).description
146+
: null;
147+
148+
final String? entityIconUrl = (state.entity is Category && (state.entity as Category).iconUrl != null)
149+
? (state.entity as Category).iconUrl
150+
: null; // Source model does not have iconUrl
151+
152+
return CustomScrollView(
153+
controller: _scrollController,
154+
slivers: [
155+
SliverAppBar(
156+
title: Text(appBarTitle),
157+
pinned: true,
158+
expandedHeight: entityIconUrl != null ? 200.0 : kToolbarHeight,
159+
flexibleSpace: entityIconUrl != null
160+
? FlexibleSpaceBar(
161+
background: Image.network(
162+
entityIconUrl,
163+
fit: BoxFit.cover,
164+
errorBuilder: (context, error, stackTrace) =>
165+
const Icon(Icons.image_not_supported_outlined, size: 48),
166+
),
167+
)
168+
: null,
169+
),
170+
SliverToBoxAdapter(
171+
child: Padding(
172+
padding: const EdgeInsets.all(AppSpacing.paddingMedium),
173+
child: Column(
174+
crossAxisAlignment: CrossAxisAlignment.start,
175+
children: [
176+
Row(
177+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
178+
crossAxisAlignment: CrossAxisAlignment.start,
179+
children: [
180+
Expanded(
181+
child: Text(
182+
appBarTitle,
183+
style: theme.textTheme.headlineMedium,
184+
),
185+
),
186+
const SizedBox(width: AppSpacing.md),
187+
ElevatedButton.icon(
188+
icon: Icon(
189+
state.isFollowing
190+
? Icons.check_circle // Filled when following
191+
: Icons.add_circle_outline,
192+
),
193+
label: Text(
194+
state.isFollowing
195+
? l10n.unfollowButtonLabel
196+
: l10n.followButtonLabel,
197+
),
198+
style: ElevatedButton.styleFrom(
199+
backgroundColor: state.isFollowing
200+
? theme.colorScheme.secondaryContainer
201+
: theme.colorScheme.primaryContainer,
202+
foregroundColor: state.isFollowing
203+
? theme.colorScheme.onSecondaryContainer
204+
: theme.colorScheme.onPrimaryContainer,
205+
),
206+
onPressed: () {
207+
context
208+
.read<EntityDetailsBloc>()
209+
.add(const EntityDetailsToggleFollowRequested());
210+
},
211+
),
212+
],
213+
),
214+
if (description != null && description.isNotEmpty) ...[
215+
const SizedBox(height: AppSpacing.md),
216+
Text(description, style: theme.textTheme.bodyMedium),
217+
],
218+
const SizedBox(height: AppSpacing.lg), // Increased spacing
219+
if (state.headlines.isNotEmpty || state.headlinesStatus == EntityHeadlinesStatus.loadingMore)
220+
Text(l10n.headlinesSectionTitle, style: theme.textTheme.titleLarge), // Section title
221+
if (state.headlines.isNotEmpty || state.headlinesStatus == EntityHeadlinesStatus.loadingMore)
222+
const Divider(height: AppSpacing.md),
223+
],
224+
),
225+
),
226+
),
227+
if (state.headlines.isEmpty &&
228+
state.headlinesStatus != EntityHeadlinesStatus.initial &&
229+
state.headlinesStatus != EntityHeadlinesStatus.loadingMore &&
230+
state.status == EntityDetailsStatus.success)
231+
SliverFillRemaining( // Use SliverFillRemaining for empty state
232+
child: Center(
233+
child: Text(
234+
l10n.noHeadlinesFoundMessage,
235+
style: theme.textTheme.titleMedium,
236+
),
237+
),
238+
)
239+
else
240+
SliverList(
241+
delegate: SliverChildBuilderDelegate(
242+
(context, index) {
243+
if (index >= state.headlines.length) {
244+
return state.hasMoreHeadlines && state.headlinesStatus == EntityHeadlinesStatus.loadingMore
245+
? const Center(
246+
child: Padding(
247+
padding: EdgeInsets.all(AppSpacing.md),
248+
child: CircularProgressIndicator(),
249+
),
250+
)
251+
: const SizedBox.shrink();
252+
}
253+
final headline = state.headlines[index];
254+
final imageStyle = context.watch<AppBloc>().state.settings.feedPreferences.headlineImageStyle;
255+
256+
Widget tile;
257+
switch (imageStyle) {
258+
case HeadlineImageStyle.hidden:
259+
tile = HeadlineTileTextOnly(
260+
headline: headline,
261+
onHeadlineTap: () => context.pushNamed(
262+
Routes.articleDetailsName, // Use named route
263+
pathParameters: {'id': headline.id},
264+
extra: headline,
265+
),
266+
currentContextEntityType: state.entityType,
267+
currentContextEntityId: state.entity is Category
268+
? (state.entity as Category).id
269+
: state.entity is Source
270+
? (state.entity as Source).id
271+
: null,
272+
);
273+
break;
274+
case HeadlineImageStyle.smallThumbnail:
275+
tile = HeadlineTileImageStart(
276+
headline: headline,
277+
onHeadlineTap: () => context.pushNamed(
278+
Routes.articleDetailsName, // Use named route
279+
pathParameters: {'id': headline.id},
280+
extra: headline,
281+
),
282+
currentContextEntityType: state.entityType,
283+
currentContextEntityId: state.entity is Category
284+
? (state.entity as Category).id
285+
: state.entity is Source
286+
? (state.entity as Source).id
287+
: null,
288+
);
289+
break;
290+
case HeadlineImageStyle.largeThumbnail:
291+
tile = HeadlineTileImageTop(
292+
headline: headline,
293+
onHeadlineTap: () => context.pushNamed(
294+
Routes.articleDetailsName, // Use named route
295+
pathParameters: {'id': headline.id},
296+
extra: headline,
297+
),
298+
currentContextEntityType: state.entityType,
299+
currentContextEntityId: state.entity is Category
300+
? (state.entity as Category).id
301+
: state.entity is Source
302+
? (state.entity as Source).id
303+
: null,
304+
);
305+
break;
306+
}
307+
return tile;
308+
},
309+
childCount: state.headlines.length + (state.hasMoreHeadlines && state.headlinesStatus == EntityHeadlinesStatus.loadingMore ? 1 : 0),
310+
),
311+
),
312+
// Error display for headline loading specifically
313+
if (state.headlinesStatus == EntityHeadlinesStatus.failure && state.headlines.isNotEmpty)
314+
SliverToBoxAdapter(
315+
child: Padding(
316+
padding: const EdgeInsets.all(AppSpacing.md),
317+
child: Text(
318+
state.errorMessage ?? l10n.failedToLoadMoreHeadlines,
319+
style: TextStyle(color: theme.colorScheme.error),
320+
textAlign: TextAlign.center,
321+
),
322+
),
323+
),
324+
],
325+
);
326+
},
327+
),
328+
);
329+
}
330+
}

0 commit comments

Comments
 (0)