Skip to content

Commit 3dbd8be

Browse files
committed
feat: implement headline details page
- Created details page and bloc - Fetched headline data from repository - Handled loading and error states
1 parent 40dbb09 commit 3dbd8be

File tree

8 files changed

+347
-13
lines changed

8 files changed

+347
-13
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import 'package:bloc/bloc.dart';
2+
import 'package:ht_headlines_repository/ht_headlines_repository.dart';
3+
4+
part 'headline_details_event.dart';
5+
part 'headline_details_state.dart';
6+
7+
class HeadlineDetailsBloc
8+
extends Bloc<HeadlineDetailsEvent, HeadlineDetailsState> {
9+
HeadlineDetailsBloc({required HtHeadlinesRepository headlinesRepository})
10+
: _headlinesRepository = headlinesRepository,
11+
super(HeadlineDetailsInitial()) {
12+
on<HeadlineDetailsRequested>(_onHeadlineDetailsRequested);
13+
}
14+
15+
final HtHeadlinesRepository _headlinesRepository;
16+
17+
Future<void> _onHeadlineDetailsRequested(
18+
HeadlineDetailsRequested event,
19+
Emitter<HeadlineDetailsState> emit,
20+
) async {
21+
emit(HeadlineDetailsLoading());
22+
try {
23+
final headline =
24+
await _headlinesRepository.getHeadline(id: event.headlineId);
25+
emit(HeadlineDetailsLoaded(headline: headline!));
26+
} on HeadlineNotFoundException catch (e) {
27+
emit(HeadlineDetailsFailure(message: e.message));
28+
} on HeadlinesFetchException catch (e) {
29+
emit(HeadlineDetailsFailure(message: e.message));
30+
} catch (e) {
31+
emit(HeadlineDetailsFailure(message: 'An unexpected error occurred: $e'));
32+
}
33+
}
34+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
part of 'headline_details_bloc.dart';
2+
3+
abstract class HeadlineDetailsEvent {}
4+
5+
class HeadlineDetailsRequested extends HeadlineDetailsEvent {
6+
HeadlineDetailsRequested({required this.headlineId});
7+
8+
final String headlineId;
9+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
part of 'headline_details_bloc.dart';
2+
3+
abstract class HeadlineDetailsState {}
4+
5+
class HeadlineDetailsInitial extends HeadlineDetailsState {}
6+
7+
class HeadlineDetailsLoading extends HeadlineDetailsState {}
8+
9+
class HeadlineDetailsLoaded extends HeadlineDetailsState {
10+
HeadlineDetailsLoaded({required this.headline});
11+
12+
final Headline headline;
13+
}
14+
15+
class HeadlineDetailsFailure extends HeadlineDetailsState {
16+
HeadlineDetailsFailure({required this.message});
17+
18+
final String message;
19+
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_bloc/flutter_bloc.dart';
3+
import 'package:ht_headlines_repository/ht_headlines_repository.dart';
4+
import 'package:ht_main/headline-details/view/bloc/headline_details_bloc.dart';
5+
import 'package:ht_main/shared/widgets/failure_state_widget.dart';
6+
import 'package:ht_main/shared/widgets/initial_state_widget.dart';
7+
import 'package:ht_main/shared/widgets/loading_state_widget.dart';
8+
import 'package:url_launcher/url_launcher_string.dart';
9+
import 'package:intl/intl.dart';
10+
11+
class HeadlineDetailsPage extends StatelessWidget {
12+
const HeadlineDetailsPage({required this.headlineId, super.key});
13+
14+
final String headlineId;
15+
16+
static Route<void> route({required String headlineId}) {
17+
return MaterialPageRoute<void>(
18+
builder: (_) => HeadlineDetailsPage(headlineId: headlineId),
19+
);
20+
}
21+
22+
@override
23+
Widget build(BuildContext context) {
24+
return BlocProvider(
25+
create: (context) => HeadlineDetailsBloc(
26+
headlinesRepository: context.read<HtHeadlinesRepository>(),
27+
)..add(HeadlineDetailsRequested(headlineId: headlineId)),
28+
child: const _HeadlineDetailsView(),
29+
);
30+
}
31+
}
32+
33+
class _HeadlineDetailsView extends StatelessWidget {
34+
const _HeadlineDetailsView();
35+
36+
@override
37+
Widget build(BuildContext context) {
38+
return Scaffold(
39+
appBar: AppBar(
40+
title: const Text('Headline Details'),
41+
leading: IconButton(
42+
icon: const Icon(Icons.arrow_back),
43+
onPressed: () => Navigator.of(context).pop(),
44+
),
45+
actions: [
46+
IconButton(
47+
icon: const Icon(Icons.bookmark_border),
48+
onPressed: () {}, // Placeholder
49+
),
50+
IconButton(
51+
icon: const Icon(Icons.share),
52+
onPressed: () {}, // Placeholder
53+
),
54+
],
55+
),
56+
body: BlocBuilder<HeadlineDetailsBloc, HeadlineDetailsState>(
57+
builder: (context, state) {
58+
return switch (state) {
59+
HeadlineDetailsInitial _ => const InitialStateWidget(
60+
icon: Icons.article,
61+
headline: 'Waiting for Headline',
62+
subheadline: 'Please wait...',
63+
),
64+
HeadlineDetailsLoading _ => const LoadingStateWidget(
65+
icon: Icons.downloading,
66+
headline: 'Loading Headline',
67+
subheadline: 'Fetching data...',
68+
),
69+
final HeadlineDetailsFailure state => FailureStateWidget(
70+
message: state.message,
71+
onRetry: () {
72+
context
73+
.read<HeadlineDetailsBloc>()
74+
.add(HeadlineDetailsRequested(headlineId: '1'));
75+
},
76+
),
77+
final HeadlineDetailsLoaded state =>
78+
_buildLoaded(context, state.headline),
79+
_ => const SizedBox.shrink(),
80+
};
81+
},
82+
),
83+
);
84+
}
85+
86+
Widget _buildLoaded(BuildContext context, Headline headline) {
87+
return SingleChildScrollView(
88+
child: Padding(
89+
padding: const EdgeInsets.all(16),
90+
child: Column(
91+
crossAxisAlignment: CrossAxisAlignment.start,
92+
children: [
93+
if (headline.imageUrl != null)
94+
Image.network(
95+
headline.imageUrl!,
96+
width: double.infinity,
97+
height: 200,
98+
fit: BoxFit.cover,
99+
loadingBuilder: (context, child, loadingProgress) {
100+
if (loadingProgress == null) return child;
101+
return Container(
102+
width: double.infinity,
103+
height: 200,
104+
color: Colors.grey[300],
105+
);
106+
},
107+
errorBuilder: (context, error, stackTrace) =>
108+
const Icon(Icons.error),
109+
),
110+
const SizedBox(height: 16), // Keep this
111+
Text(
112+
headline.title,
113+
style: Theme.of(context).textTheme.titleLarge,
114+
),
115+
const SizedBox(height: 8),
116+
Column(
117+
children: [
118+
if (headline.source != null) ...[
119+
Row(
120+
children: [
121+
const Icon(Icons.source),
122+
const SizedBox(width: 4),
123+
Text(
124+
headline.source!,
125+
style: Theme.of(context).textTheme.bodyMedium,
126+
),
127+
],
128+
),
129+
const SizedBox(
130+
height: 8), // Add spacing between metadata items
131+
],
132+
if (headline.categories != null &&
133+
headline.categories!.isNotEmpty) ...[
134+
Row(
135+
children: [
136+
const Icon(Icons.category),
137+
const SizedBox(width: 4),
138+
Text(
139+
headline.categories!.join(', '),
140+
style: Theme.of(context).textTheme.bodyMedium,
141+
),
142+
],
143+
),
144+
const SizedBox(height: 8),
145+
],
146+
if (headline.eventCountry != null) ...[
147+
Row(
148+
children: [
149+
const Icon(Icons.location_on),
150+
const SizedBox(width: 4),
151+
Text(
152+
headline.eventCountry!,
153+
style: Theme.of(context).textTheme.bodyMedium,
154+
),
155+
],
156+
),
157+
const SizedBox(height: 8),
158+
],
159+
if (headline.publishedAt != null)
160+
Row(
161+
children: [
162+
const Icon(Icons.date_range),
163+
const SizedBox(width: 4),
164+
Text(
165+
DateFormat('MMMM dd, yyyy')
166+
.format(headline.publishedAt!),
167+
style: Theme.of(context).textTheme.bodyMedium,
168+
),
169+
],
170+
),
171+
],
172+
),
173+
const SizedBox(height: 16),
174+
if (headline.description != null)
175+
Text(
176+
headline.description!,
177+
style: Theme.of(context).textTheme.bodyLarge,
178+
),
179+
const SizedBox(height: 16),
180+
ElevatedButton(
181+
onPressed: () async {
182+
if (headline.url != null) {
183+
await launchUrlString(headline.url!);
184+
}
185+
},
186+
style: ElevatedButton.styleFrom(
187+
backgroundColor: Theme.of(context).colorScheme.primary,
188+
// Removed custom padding
189+
),
190+
child: Text(
191+
'Continue Reading',
192+
style: Theme.of(context).textTheme.labelLarge,
193+
),
194+
),
195+
],
196+
),
197+
),
198+
);
199+
}
200+
}

lib/headlines-feed/widgets/headline_item_widget.dart

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import 'package:flutter/material.dart';
2+
import 'package:go_router/go_router.dart';
23
import 'package:ht_headlines_repository/ht_headlines_repository.dart'
34
show Headline;
5+
import 'package:ht_main/router/routes.dart';
46

57
/// A widget that displays a single headline.
68
class HeadlineItemWidget extends StatelessWidget {
@@ -16,15 +18,12 @@ class HeadlineItemWidget extends StatelessWidget {
1618
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
1719
child: Card(
1820
child: ListTile(
19-
leading: Image.network(
20-
headline.imageUrl ??
21-
'https://via.placeholder.com/50x50', // Placeholder image
22-
width: 50,
23-
height: 50,
24-
fit: BoxFit.cover,
25-
errorBuilder: (context, error, stackTrace) =>
26-
const Icon(Icons.error),
27-
),
21+
onTap: () {
22+
context.goNamed(
23+
Routes.articleDetailsName,
24+
pathParameters: {'id': headline.id},
25+
);
26+
},
2827
title: Text(
2928
headline.title,
3029
style: Theme.of(context).textTheme.titleMedium,
@@ -52,6 +51,15 @@ class HeadlineItemWidget extends StatelessWidget {
5251
],
5352
),
5453
),
54+
trailing: Image.network(
55+
headline.imageUrl ??
56+
'https://via.placeholder.com/50x50', // Placeholder image
57+
width: 75,
58+
height: 75,
59+
fit: BoxFit.cover,
60+
errorBuilder: (context, error, stackTrace) =>
61+
const Icon(Icons.error),
62+
),
5563
),
5664
),
5765
);

lib/router/router.dart

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'package:flutter/material.dart';
22
import 'package:go_router/go_router.dart';
33
import 'package:ht_main/app/view/app_scaffold.dart';
4+
import 'package:ht_main/headline-details/view/headline_details_page.dart';
45
import 'package:ht_main/headlines-feed/view/headlines_feed_page.dart';
56
import 'package:ht_main/headlines-search/view/headlines_search_page.dart';
67
import 'package:ht_main/router/routes.dart';
@@ -25,7 +26,7 @@ final appRouter = GoRouter(
2526
name: Routes.articleDetailsName,
2627
builder: (BuildContext context, GoRouterState state) {
2728
final id = state.pathParameters['id']!;
28-
return Placeholder(child: Text('Article ID: $id'));
29+
return HeadlineDetailsPage(headlineId: id);
2930
},
3031
),
3132
],
@@ -42,9 +43,7 @@ final appRouter = GoRouter(
4243
name: Routes.accountName,
4344
builder: (BuildContext context, GoRouterState state) {
4445
return const Placeholder(
45-
child: Center(
46-
child: Text('ACCOUNT PAGE'),
47-
),
46+
child: Center(child: Text('ACCOUNT PAGE')),
4847
);
4948
},
5049
),

0 commit comments

Comments
 (0)