Skip to content

Commit e2e7eb9

Browse files
committed
feat: add HeadlineTileImageTop widget
- Display headlines with image on top - Supports optional trailing widget - Shows category, source, and date
1 parent 641924f commit e2e7eb9

File tree

1 file changed

+255
-0
lines changed

1 file changed

+255
-0
lines changed
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:ht_main/l10n/l10n.dart';
3+
import 'package:ht_main/shared/constants/app_spacing.dart';
4+
import 'package:ht_shared/ht_shared.dart' show Headline;
5+
import 'package:timeago/timeago.dart' as timeago;
6+
7+
/// {@template headline_tile_image_top}
8+
/// A shared widget to display a headline item with a large image at the top.
9+
/// {@endtemplate}
10+
class HeadlineTileImageTop extends StatelessWidget {
11+
/// {@macro headline_tile_image_top}
12+
const HeadlineTileImageTop({
13+
required this.headline,
14+
super.key,
15+
this.onHeadlineTap,
16+
this.trailing,
17+
});
18+
19+
/// The headline data to display.
20+
final Headline headline;
21+
22+
/// Callback when the main content of the headline (e.g., title area) is tapped.
23+
final VoidCallback? onHeadlineTap;
24+
25+
/// An optional widget to display at the end of the tile (e.g., in line with title).
26+
final Widget? trailing;
27+
28+
@override
29+
Widget build(BuildContext context) {
30+
final l10n = context.l10n;
31+
final theme = Theme.of(context);
32+
final textTheme = theme.textTheme;
33+
final colorScheme = theme.colorScheme;
34+
35+
return Card(
36+
margin: const EdgeInsets.symmetric(
37+
horizontal: AppSpacing.paddingMedium,
38+
vertical: AppSpacing.xs,
39+
),
40+
child: Column(
41+
crossAxisAlignment: CrossAxisAlignment.start,
42+
children: [
43+
InkWell(
44+
onTap: onHeadlineTap, // Image area is part of the main tap area
45+
child: ClipRRect(
46+
borderRadius: const BorderRadius.only(
47+
topLeft: Radius.circular(AppSpacing.xs),
48+
topRight: Radius.circular(AppSpacing.xs),
49+
),
50+
child: headline.imageUrl != null
51+
? Image.network(
52+
headline.imageUrl!,
53+
width: double.infinity,
54+
height: 180, // Standard large image height
55+
fit: BoxFit.cover,
56+
loadingBuilder: (context, child, loadingProgress) {
57+
if (loadingProgress == null) return child;
58+
return Container(
59+
width: double.infinity,
60+
height: 180,
61+
color: colorScheme.surfaceContainerHighest,
62+
child: const Center(
63+
child: CircularProgressIndicator(strokeWidth: 2),
64+
),
65+
);
66+
},
67+
errorBuilder: (context, error, stackTrace) => Container(
68+
width: double.infinity,
69+
height: 180,
70+
color: colorScheme.surfaceContainerHighest,
71+
child: Icon(
72+
Icons.broken_image_outlined,
73+
color: colorScheme.onSurfaceVariant,
74+
size: AppSpacing.xxl,
75+
),
76+
),
77+
)
78+
: Container(
79+
width: double.infinity,
80+
height: 180,
81+
color: colorScheme.surfaceContainerHighest,
82+
child: Icon(
83+
Icons.image_not_supported_outlined,
84+
color: colorScheme.onSurfaceVariant,
85+
size: AppSpacing.xxl,
86+
),
87+
),
88+
),
89+
),
90+
Padding(
91+
padding: const EdgeInsets.all(AppSpacing.md),
92+
child: Column(
93+
crossAxisAlignment: CrossAxisAlignment.start,
94+
children: [
95+
Row(
96+
crossAxisAlignment: CrossAxisAlignment.start,
97+
children: [
98+
Expanded(
99+
child: InkWell(
100+
onTap: onHeadlineTap, // Title is part of main tap area
101+
child: Text(
102+
headline.title,
103+
style: textTheme.titleMedium?.copyWith(
104+
fontWeight: FontWeight.w500,
105+
),
106+
maxLines: 3,
107+
overflow: TextOverflow.ellipsis,
108+
),
109+
),
110+
),
111+
if (trailing != null) ...[
112+
const SizedBox(width: AppSpacing.sm),
113+
trailing!,
114+
],
115+
],
116+
),
117+
const SizedBox(height: AppSpacing.sm),
118+
_HeadlineMetadataRow(
119+
headline: headline,
120+
l10n: l10n,
121+
colorScheme: colorScheme,
122+
textTheme: textTheme,
123+
),
124+
],
125+
),
126+
),
127+
],
128+
),
129+
);
130+
}
131+
}
132+
133+
/// Private helper widget to build the metadata row.
134+
class _HeadlineMetadataRow extends StatelessWidget {
135+
const _HeadlineMetadataRow({
136+
required this.headline,
137+
required this.l10n,
138+
required this.colorScheme,
139+
required this.textTheme,
140+
});
141+
142+
final Headline headline;
143+
final AppLocalizations l10n;
144+
final ColorScheme colorScheme;
145+
final TextTheme textTheme;
146+
147+
@override
148+
Widget build(BuildContext context) {
149+
final String formattedDate;
150+
if (headline.publishedAt != null) {
151+
formattedDate = timeago.format(
152+
headline.publishedAt!,
153+
locale: Localizations.localeOf(context).languageCode,
154+
);
155+
} else {
156+
formattedDate = '';
157+
}
158+
159+
final metadataStyle = textTheme.bodySmall?.copyWith(
160+
color: colorScheme.onSurfaceVariant,
161+
);
162+
const iconSize = 12.0;
163+
164+
return Wrap(
165+
spacing: AppSpacing.md,
166+
runSpacing: AppSpacing.xs,
167+
crossAxisAlignment: WrapCrossAlignment.center,
168+
children: [
169+
if (headline.category?.name != null)
170+
GestureDetector(
171+
onTap: () {
172+
ScaffoldMessenger.of(context)
173+
..hideCurrentSnackBar()
174+
..showSnackBar(
175+
SnackBar(
176+
content: Text(
177+
'Tapped Category: ${headline.category!.name}',
178+
),
179+
),
180+
);
181+
},
182+
child: Chip(
183+
avatar: Icon(
184+
Icons.label_outline,
185+
size: iconSize,
186+
color: colorScheme.onSurfaceVariant, // Changed color
187+
),
188+
label: Text(headline.category!.name),
189+
labelStyle: textTheme.labelSmall?.copyWith(
190+
color: colorScheme.onSurfaceVariant, // Changed color
191+
),
192+
// backgroundColor removed
193+
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
194+
visualDensity: VisualDensity.compact,
195+
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
196+
),
197+
),
198+
if (headline.source?.name != null)
199+
GestureDetector(
200+
onTap: () {
201+
ScaffoldMessenger.of(context)
202+
..hideCurrentSnackBar()
203+
..showSnackBar(
204+
SnackBar(
205+
content: Text('Tapped Source: ${headline.source!.name}'),
206+
),
207+
);
208+
},
209+
child: Row(
210+
mainAxisSize: MainAxisSize.min,
211+
children: [
212+
Icon(
213+
Icons.source_outlined,
214+
size: iconSize,
215+
color: colorScheme.onSurfaceVariant,
216+
),
217+
const SizedBox(width: AppSpacing.xs),
218+
Flexible(
219+
child: Text(
220+
headline.source!.name,
221+
style: metadataStyle,
222+
overflow: TextOverflow.ellipsis,
223+
),
224+
),
225+
],
226+
),
227+
),
228+
if (formattedDate.isNotEmpty)
229+
GestureDetector(
230+
onTap: () {
231+
ScaffoldMessenger.of(context)
232+
..hideCurrentSnackBar()
233+
..showSnackBar(
234+
SnackBar(
235+
content: Text('Tapped Date: $formattedDate'),
236+
),
237+
);
238+
},
239+
child: Row(
240+
mainAxisSize: MainAxisSize.min,
241+
children: [
242+
Icon(
243+
Icons.calendar_today_outlined,
244+
size: iconSize,
245+
color: colorScheme.onSurfaceVariant,
246+
),
247+
const SizedBox(width: AppSpacing.xs),
248+
Text(formattedDate, style: metadataStyle),
249+
],
250+
),
251+
),
252+
],
253+
);
254+
}
255+
}

0 commit comments

Comments
 (0)