···
37
+
this.showFullText = false,
38
+
this.showAuthorFooter = false,
39
+
this.textFontSize = 13,
40
+
this.textLineHeight = 1.4,
41
+
this.embedHeight = 180,
42
+
this.titleFontSize = 16,
43
+
this.titleFontWeight = FontWeight.w400,
···
48
-
/// Check if this post should be clickable
49
-
/// Only text posts (no embeds or non-video/link embeds) are
51
-
bool get _isClickable {
52
-
// If navigation is explicitly disabled (e.g., on detail screen),
54
-
if (disableNavigation) {
58
-
final embed = post.post.embed;
60
-
// If no embed, it's a text-only post - clickable
61
-
if (embed == null) {
65
-
// If embed exists, check if it's a video or link type
66
-
final external = embed.external;
67
-
if (external == null) {
68
-
return true; // No external embed, clickable
71
-
final embedType = external.embedType;
73
-
// Video and video-stream posts should NOT be clickable (they have
74
-
// their own tap handling)
75
-
if (embedType == 'video' || embedType == 'video-stream') {
79
-
// Link embeds should NOT be clickable (they have their own link handling)
80
-
if (embedType == 'link') {
84
-
// All other types are clickable
54
+
final bool showFullText;
55
+
final bool showAuthorFooter;
56
+
final double textFontSize;
57
+
final double textLineHeight;
58
+
final double embedHeight;
59
+
final double titleFontSize;
60
+
final FontWeight titleFontWeight;
void _navigateToDetail(BuildContext context) {
// Navigate to post detail screen
···
const SizedBox(height: 8),
152
-
// Wrap content in InkWell if clickable (text-only posts)
155
-
onTap: () => _navigateToDetail(context),
157
-
crossAxisAlignment: CrossAxisAlignment.start,
160
-
if (post.post.title != null) ...[
163
-
style: const TextStyle(
164
-
color: AppColors.textPrimary,
166
-
fontWeight: FontWeight.w400,
126
+
// Post content - title and text are clickable, embed handles
129
+
crossAxisAlignment: CrossAxisAlignment.start,
131
+
// Author info (shown in detail view, above title)
132
+
if (showAuthorFooter) _buildAuthorFooter(),
171
-
// Spacing after title (only if we have text)
172
-
if (post.post.title != null && post.post.text.isNotEmpty)
173
-
const SizedBox(height: 8),
175
-
// Post text body preview
176
-
if (post.post.text.isNotEmpty) ...[
178
-
padding: const EdgeInsets.all(10),
179
-
decoration: BoxDecoration(
180
-
color: AppColors.backgroundSecondary,
181
-
borderRadius: BorderRadius.circular(8),
186
-
color: AppColors.textPrimary.withValues(alpha: 0.7),
134
+
// Title and text wrapped in InkWell for navigation
135
+
if (!disableNavigation &&
136
+
(post.post.title != null || post.post.text.isNotEmpty))
138
+
onTap: () => _navigateToDetail(context),
140
+
crossAxisAlignment: CrossAxisAlignment.start,
143
+
if (post.post.title != null) ...[
147
+
color: AppColors.textPrimary,
148
+
fontSize: titleFontSize,
149
+
fontWeight: titleFontWeight,
191
-
overflow: TextOverflow.ellipsis,
199
-
// Non-clickable content (video/link posts)
201
-
crossAxisAlignment: CrossAxisAlignment.start,
155
+
// Spacing after title
156
+
if (post.post.title != null &&
157
+
(post.post.embed?.external != null ||
158
+
post.post.text.isNotEmpty))
159
+
const SizedBox(height: 8),
164
+
// Title when navigation is disabled
if (post.post.title != null) ...[
207
-
style: const TextStyle(
color: AppColors.textPrimary,
210
-
fontWeight: FontWeight.w400,
170
+
fontSize: titleFontSize,
171
+
fontWeight: titleFontWeight,
175
+
if (post.post.embed?.external != null ||
176
+
post.post.text.isNotEmpty)
177
+
const SizedBox(height: 8),
215
-
// Spacing after title (only if we have content below)
216
-
if (post.post.title != null &&
217
-
(post.post.embed?.external != null ||
218
-
post.post.text.isNotEmpty))
219
-
const SizedBox(height: 8),
180
+
// Embed (handles its own taps - not wrapped in InkWell)
181
+
if (post.post.embed?.external != null) ...[
183
+
embed: post.post.embed!.external!,
184
+
streamableService: context.read<StreamableService>(),
185
+
height: embedHeight,
189
+
: () => _navigateToDetail(context),
191
+
const SizedBox(height: 8),
221
-
// Embed (link preview)
222
-
if (post.post.embed?.external != null) ...[
224
-
embed: post.post.embed!.external!,
225
-
streamableService: context.read<StreamableService>(),
227
-
const SizedBox(height: 8),
230
-
// Post text body preview
231
-
if (post.post.text.isNotEmpty) ...[
233
-
padding: const EdgeInsets.all(10),
234
-
decoration: BoxDecoration(
235
-
color: AppColors.backgroundSecondary,
236
-
borderRadius: BorderRadius.circular(8),
241
-
color: AppColors.textPrimary.withValues(alpha: 0.7),
246
-
overflow: TextOverflow.ellipsis,
194
+
// Post text (clickable for navigation)
195
+
if (post.post.text.isNotEmpty) ...[
196
+
if (!disableNavigation)
198
+
onTap: () => _navigateToDetail(context),
199
+
child: _buildTextContent(),
202
+
_buildTextContent(),
// External link (if present)
if (post.post.embed?.external != null) ...[
···
225
+
/// Builds the text content with appropriate styling
226
+
Widget _buildTextContent() {
227
+
if (showFullText) {
228
+
// Detail view: no container, better readability
230
+
padding: const EdgeInsets.symmetric(horizontal: 4),
234
+
color: AppColors.textPrimary,
235
+
fontSize: textFontSize,
236
+
height: textLineHeight,
241
+
// Feed view: compact preview with container
243
+
padding: const EdgeInsets.all(10),
244
+
decoration: BoxDecoration(
245
+
color: AppColors.backgroundSecondary,
246
+
borderRadius: BorderRadius.circular(8),
251
+
color: AppColors.textPrimary.withValues(alpha: 0.85),
252
+
fontSize: textFontSize,
253
+
height: textLineHeight,
256
+
overflow: TextOverflow.ellipsis,
/// Builds the community handle with styled parts (name + instance)
···
338
+
/// Builds author footer with avatar, handle, and timestamp
339
+
Widget _buildAuthorFooter() {
340
+
final author = post.post.author;
343
+
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
346
+
// Author avatar (circular, small)
347
+
if (author.avatar != null && author.avatar!.isNotEmpty)
349
+
borderRadius: BorderRadius.circular(12),
350
+
child: CachedNetworkImage(
351
+
imageUrl: author.avatar!,
356
+
(context, url) => _buildAuthorFallbackAvatar(author),
358
+
(context, url, error) => _buildAuthorFallbackAvatar(author),
362
+
_buildAuthorFallbackAvatar(author),
363
+
const SizedBox(width: 8),
367
+
'@${author.handle}',
368
+
style: const TextStyle(
369
+
color: AppColors.textPrimary,
371
+
fontWeight: FontWeight.w500,
373
+
overflow: TextOverflow.ellipsis,
376
+
const SizedBox(width: 8),
380
+
DateTimeUtils.formatTimeAgo(
381
+
post.post.createdAt,
382
+
currentTime: currentTime,
385
+
color: AppColors.textSecondary.withValues(alpha: 0.7),
394
+
/// Builds a fallback avatar for the author
395
+
Widget _buildAuthorFallbackAvatar(AuthorView author) {
396
+
final firstLetter =
397
+
(author.displayName ?? author.handle).isNotEmpty
398
+
? (author.displayName ?? author.handle)[0]
403
+
decoration: BoxDecoration(
404
+
color: AppColors.primary,
405
+
borderRadius: BorderRadius.circular(12),
409
+
firstLetter.toUpperCase(),
410
+
style: const TextStyle(
411
+
color: AppColors.textPrimary,
413
+
fontWeight: FontWeight.bold,
/// Embed card widget for displaying link previews
···
/// For video embeds (Streamable), displays a play button overlay and opens
/// a video player dialog when tapped.
class _EmbedCard extends StatefulWidget {
354
-
const _EmbedCard({required this.embed, required this.streamableService});
428
+
required this.embed,
429
+
required this.streamableService,
final ExternalEmbed embed;
final StreamableService streamableService;
436
+
final double height;
437
+
final VoidCallback? onImageTap;
State<_EmbedCard> createState() => _EmbedCardState();
···
child: CachedNetworkImage(
imageUrl: widget.embed.thumb!,
526
+
height: widget.height,
(context, url) => Container(
531
+
height: widget.height,
color: AppColors.background,
child: CircularProgressIndicator(
···
546
+
height: widget.height,
color: AppColors.background,
···
510
-
// For non-video embeds, just return the thumbnail
590
+
// For non-video embeds (images, link previews), make them tappable
591
+
// to navigate to post detail
592
+
if (widget.onImageTap != null) {
593
+
return GestureDetector(
594
+
onTap: widget.onImageTap,
595
+
child: thumbnailWidget,
599
+
// No tap handler provided, just return the thumbnail