feat(post-detail): improve post detail view styling and add sources support

- Adjust post detail text sizing to match feed (title 16px, text 14px)
- Remove bold styling from title and author handle
- Reduce author avatar size from 24px to 20px
- Keep external embed images at same height as feed view (180px)
- Add sources section for megathread posts with clickable source links
- Add EmbedSource model with URI validation and security checks
- Add SourceLinkBar widget matching ExternalLinkBar styling
- Improve ValueKey comment clarity in feed_page.dart

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+247 -15
lib
+66
lib/models/post.dart
··· 281 281 this.provider, 282 282 this.images, 283 283 this.totalCount, 284 + this.sources, 284 285 }); 285 286 286 287 factory ExternalEmbed.fromJson(Map<String, dynamic> json) { ··· 294 295 (json['images'] as List).whereType<Map<String, dynamic>>().toList(); 295 296 } 296 297 298 + // Handle sources array if present 299 + List<EmbedSource>? sourcesList; 300 + if (json['sources'] != null && json['sources'] is List) { 301 + sourcesList = 302 + (json['sources'] as List) 303 + .whereType<Map<String, dynamic>>() 304 + .map(EmbedSource.fromJson) 305 + .toList(); 306 + } 307 + 297 308 return ExternalEmbed( 298 309 uri: json['uri'] as String, 299 310 title: json['title'] as String?, ··· 304 315 provider: json['provider'] as String?, 305 316 images: imagesList, 306 317 totalCount: json['totalCount'] as int?, 318 + sources: sourcesList, 307 319 ); 308 320 } 309 321 final String uri; ··· 315 327 final String? provider; 316 328 final List<Map<String, dynamic>>? images; 317 329 final int? totalCount; 330 + final List<EmbedSource>? sources; 331 + } 332 + 333 + /// A source link aggregated into a megathread 334 + class EmbedSource { 335 + EmbedSource({ 336 + required this.uri, 337 + this.title, 338 + this.domain, 339 + }); 340 + 341 + factory EmbedSource.fromJson(Map<String, dynamic> json) { 342 + final uri = json['uri']; 343 + if (uri == null || uri is! String || uri.isEmpty) { 344 + throw const FormatException( 345 + 'EmbedSource: Required field "uri" is missing or invalid', 346 + ); 347 + } 348 + 349 + // Validate URI scheme for security 350 + final parsedUri = Uri.tryParse(uri); 351 + if (parsedUri == null || 352 + !parsedUri.hasScheme || 353 + !['http', 'https'].contains(parsedUri.scheme.toLowerCase())) { 354 + throw FormatException( 355 + 'EmbedSource: URI has invalid or unsupported scheme: $uri', 356 + ); 357 + } 358 + 359 + return EmbedSource( 360 + uri: uri, 361 + title: json['title'] as String?, 362 + domain: json['domain'] as String?, 363 + ); 364 + } 365 + 366 + final String uri; 367 + final String? title; 368 + final String? domain; 369 + 370 + @override 371 + String toString() => 'EmbedSource(uri: $uri, title: $title, domain: $domain)'; 372 + 373 + @override 374 + bool operator ==(Object other) => 375 + identical(this, other) || 376 + other is EmbedSource && 377 + runtimeType == other.runtimeType && 378 + uri == other.uri && 379 + title == other.title && 380 + domain == other.domain; 381 + 382 + @override 383 + int get hashCode => Object.hash(uri, title, domain); 318 384 } 319 385 320 386 class PostFacet {
+5 -5
lib/screens/home/post_detail_screen.dart
··· 820 820 showBorder: false, 821 821 showFullText: true, 822 822 showAuthorFooter: true, 823 - textFontSize: 16, 824 - textLineHeight: 1.6, 825 - embedHeight: 280, 826 - titleFontSize: 20, 827 - titleFontWeight: FontWeight.w600, 823 + showSources: true, 824 + textFontSize: 14, 825 + textLineHeight: 1.5, 826 + titleFontSize: 16, 827 + titleFontWeight: FontWeight.w800, 828 828 ); 829 829 }, 830 830 );
+5 -1
lib/widgets/feed_page.dart
··· 243 243 244 244 final post = widget.posts[index]; 245 245 // RepaintBoundary isolates each post card to prevent unnecessary 246 - // repaints of other items during scrolling 246 + // repaints of other items during scrolling. 247 + // ValueKey on RepaintBoundary ensures Flutter correctly identifies 248 + // and reuses the entire isolated subtree during list updates, 249 + // preserving both identity and paint optimization. 247 250 return RepaintBoundary( 251 + key: ValueKey(post.post.uri), 248 252 child: Semantics( 249 253 label: 250 254 'Feed post in ${post.post.community.name} by '
+33 -9
lib/widgets/post_card.dart
··· 13 13 import 'external_link_bar.dart'; 14 14 import 'fullscreen_video_player.dart'; 15 15 import 'post_card_actions.dart'; 16 + import 'source_link_bar.dart'; 16 17 17 18 /// Post card widget for displaying feed posts 18 19 /// ··· 37 38 this.showBorder = true, 38 39 this.showFullText = false, 39 40 this.showAuthorFooter = false, 41 + this.showSources = false, 40 42 this.textFontSize = 13, 41 43 this.textLineHeight = 1.4, 42 44 this.embedHeight = 180, ··· 54 56 final bool showBorder; 55 57 final bool showFullText; 56 58 final bool showAuthorFooter; 59 + final bool showSources; 57 60 final double textFontSize; 58 61 final double textLineHeight; 59 62 final double embedHeight; ··· 222 225 ExternalLinkBar(embed: post.post.embed!.external!), 223 226 ], 224 227 228 + // Sources section (for megathreads, shown in detail view) 229 + if (showSources && 230 + post.post.embed?.external?.sources != null && 231 + post.post.embed!.external!.sources!.isNotEmpty) ...[ 232 + const SizedBox(height: 16), 233 + const Text( 234 + 'Sources', 235 + style: TextStyle( 236 + color: AppColors.textSecondary, 237 + fontSize: 12, 238 + fontWeight: FontWeight.w500, 239 + ), 240 + ), 241 + const SizedBox(height: 8), 242 + ...post.post.embed!.external!.sources!.map( 243 + (source) => Padding( 244 + padding: const EdgeInsets.only(bottom: 6), 245 + child: SourceLinkBar(source: source), 246 + ), 247 + ), 248 + ], 249 + 225 250 // Reduced spacing before action buttons 226 251 if (showActions) const SizedBox(height: 4), 227 252 ··· 366 391 // Author avatar (circular, small) 367 392 if (author.avatar != null && author.avatar!.isNotEmpty) 368 393 ClipRRect( 369 - borderRadius: BorderRadius.circular(12), 394 + borderRadius: BorderRadius.circular(10), 370 395 child: CachedNetworkImage( 371 396 imageUrl: author.avatar!, 372 - width: 24, 373 - height: 24, 397 + width: 20, 398 + height: 20, 374 399 fit: BoxFit.cover, 375 400 placeholder: 376 401 (context, url) => _buildAuthorFallbackAvatar(author), ··· 387 412 '@${author.handle}', 388 413 style: const TextStyle( 389 414 color: AppColors.textPrimary, 390 - fontSize: 14, 391 - fontWeight: FontWeight.w500, 415 + fontSize: 13, 392 416 ), 393 417 overflow: TextOverflow.ellipsis, 394 418 ), ··· 418 442 ? (author.displayName ?? author.handle)[0] 419 443 : '?'; 420 444 return Container( 421 - width: 24, 422 - height: 24, 445 + width: 20, 446 + height: 20, 423 447 decoration: BoxDecoration( 424 448 color: AppColors.primary, 425 - borderRadius: BorderRadius.circular(12), 449 + borderRadius: BorderRadius.circular(10), 426 450 ), 427 451 child: Center( 428 452 child: Text( 429 453 firstLetter.toUpperCase(), 430 454 style: const TextStyle( 431 455 color: AppColors.textPrimary, 432 - fontSize: 12, 456 + fontSize: 10, 433 457 fontWeight: FontWeight.bold, 434 458 ), 435 459 ),