Skip to content

Commit 641924f

Browse files
committed
feat: add HeadlineTileImageStart widget
- Display headline with image - Show category, source, date - Uses shared Headline model
1 parent c44ec5f commit 641924f

File tree

1 file changed

+243
-0
lines changed

1 file changed

+243
-0
lines changed
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
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_start}
8+
/// A shared widget to display a headline item with a small image at the start.
9+
/// {@endtemplate}
10+
class HeadlineTileImageStart extends StatelessWidget {
11+
/// {@macro headline_tile_image_start}
12+
const HeadlineTileImageStart({
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.
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: InkWell(
41+
onTap: onHeadlineTap, // Main tap for image + title area
42+
child: Padding(
43+
padding: const EdgeInsets.all(AppSpacing.md),
44+
child: Row(
45+
crossAxisAlignment: CrossAxisAlignment.start,
46+
children: [
47+
SizedBox(
48+
width: 72, // Standard small image size
49+
height: 72,
50+
child: ClipRRect(
51+
borderRadius: BorderRadius.circular(AppSpacing.xs),
52+
child: headline.imageUrl != null
53+
? Image.network(
54+
headline.imageUrl!,
55+
fit: BoxFit.cover,
56+
loadingBuilder: (context, child, loadingProgress) {
57+
if (loadingProgress == null) return child;
58+
return Container(
59+
color: colorScheme.surfaceContainerHighest,
60+
child: const Center(
61+
child:
62+
CircularProgressIndicator(strokeWidth: 2),
63+
),
64+
);
65+
},
66+
errorBuilder: (context, error, stackTrace) =>
67+
Container(
68+
color: colorScheme.surfaceContainerHighest,
69+
child: Icon(
70+
Icons.broken_image_outlined,
71+
color: colorScheme.onSurfaceVariant,
72+
size: AppSpacing.xl,
73+
),
74+
),
75+
)
76+
: Container(
77+
color: colorScheme.surfaceContainerHighest,
78+
child: Icon(
79+
Icons.image_not_supported_outlined,
80+
color: colorScheme.onSurfaceVariant,
81+
size: AppSpacing.xl,
82+
),
83+
),
84+
),
85+
),
86+
const SizedBox(width: AppSpacing.md), // Always add spacing
87+
Expanded(
88+
child: Column(
89+
crossAxisAlignment: CrossAxisAlignment.start,
90+
children: [
91+
Text(
92+
headline.title,
93+
style: textTheme.titleMedium?.copyWith(
94+
fontWeight: FontWeight.w500,
95+
),
96+
maxLines: 2,
97+
overflow: TextOverflow.ellipsis,
98+
),
99+
const SizedBox(height: AppSpacing.sm),
100+
_HeadlineMetadataRow(
101+
headline: headline,
102+
l10n: l10n,
103+
colorScheme: colorScheme,
104+
textTheme: textTheme,
105+
),
106+
],
107+
),
108+
),
109+
if (trailing != null) ...[
110+
const SizedBox(width: AppSpacing.sm),
111+
trailing!,
112+
],
113+
],
114+
),
115+
),
116+
),
117+
);
118+
}
119+
}
120+
121+
/// Private helper widget to build the metadata row.
122+
class _HeadlineMetadataRow extends StatelessWidget {
123+
const _HeadlineMetadataRow({
124+
required this.headline,
125+
required this.l10n,
126+
required this.colorScheme,
127+
required this.textTheme,
128+
});
129+
130+
final Headline headline;
131+
final AppLocalizations l10n;
132+
final ColorScheme colorScheme;
133+
final TextTheme textTheme;
134+
135+
@override
136+
Widget build(BuildContext context) {
137+
final String formattedDate;
138+
if (headline.publishedAt != null) {
139+
formattedDate = timeago.format(
140+
headline.publishedAt!,
141+
locale: Localizations.localeOf(context).languageCode,
142+
);
143+
} else {
144+
formattedDate = '';
145+
}
146+
147+
final metadataStyle = textTheme.bodySmall?.copyWith(
148+
color: colorScheme.onSurfaceVariant,
149+
);
150+
const iconSize = 12.0;
151+
152+
return Wrap(
153+
spacing: AppSpacing.md,
154+
runSpacing: AppSpacing.xs,
155+
crossAxisAlignment: WrapCrossAlignment.center,
156+
children: [
157+
if (headline.category?.name != null)
158+
GestureDetector(
159+
onTap: () {
160+
ScaffoldMessenger.of(context)
161+
..hideCurrentSnackBar()
162+
..showSnackBar(
163+
SnackBar(
164+
content: Text(
165+
'Tapped Category: ${headline.category!.name}',
166+
),
167+
),
168+
);
169+
},
170+
child: Chip(
171+
avatar: Icon(
172+
Icons.label_outline,
173+
size: iconSize,
174+
color: colorScheme.onSurfaceVariant, // Changed color
175+
),
176+
label: Text(headline.category!.name),
177+
labelStyle: textTheme.labelSmall?.copyWith(
178+
color: colorScheme.onSurfaceVariant, // Changed color
179+
),
180+
// backgroundColor removed
181+
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
182+
visualDensity: VisualDensity.compact,
183+
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
184+
),
185+
),
186+
if (headline.source?.name != null)
187+
GestureDetector(
188+
onTap: () {
189+
ScaffoldMessenger.of(context)
190+
..hideCurrentSnackBar()
191+
..showSnackBar(
192+
SnackBar(
193+
content: Text('Tapped Source: ${headline.source!.name}'),
194+
),
195+
);
196+
},
197+
child: Row(
198+
mainAxisSize: MainAxisSize.min,
199+
children: [
200+
Icon(
201+
Icons.source_outlined,
202+
size: iconSize,
203+
color: colorScheme.onSurfaceVariant,
204+
),
205+
const SizedBox(width: AppSpacing.xs),
206+
Flexible(
207+
child: Text(
208+
headline.source!.name,
209+
style: metadataStyle,
210+
overflow: TextOverflow.ellipsis,
211+
),
212+
),
213+
],
214+
),
215+
),
216+
if (formattedDate.isNotEmpty)
217+
GestureDetector(
218+
onTap: () {
219+
ScaffoldMessenger.of(context)
220+
..hideCurrentSnackBar()
221+
..showSnackBar(
222+
SnackBar(
223+
content: Text('Tapped Date: $formattedDate'),
224+
),
225+
);
226+
},
227+
child: Row(
228+
mainAxisSize: MainAxisSize.min,
229+
children: [
230+
Icon(
231+
Icons.calendar_today_outlined,
232+
size: iconSize,
233+
color: colorScheme.onSurfaceVariant,
234+
),
235+
const SizedBox(width: AppSpacing.xs),
236+
Text(formattedDate, style: metadataStyle),
237+
],
238+
),
239+
),
240+
],
241+
);
242+
}
243+
}

0 commit comments

Comments
 (0)