feat(feed): add Bluesky post embed support

Add UI components to render Bluesky crosspost embeds in feeds with
Bluesky-styled post cards.

New files:
- lib/models/bluesky_post.dart: Data models for BlueskyPostEmbed,
BlueskyPostResult with URL helpers and robust JSON validation
- lib/constants/bluesky_colors.dart: Bluesky brand color palette
- lib/widgets/bluesky_action_bar.dart: Disabled action bar showing
engagement counts (view-only)
- lib/widgets/bluesky_post_card.dart: Main card widget with avatar,
author info, text, media placeholder, quote posts, and action bar

Changes:
- lib/models/post.dart: Add BlueskyPostEmbed parsing for
social.coves.embed.post type, fix nullable text field handling
- lib/widgets/post_card.dart: Conditionally render BlueskyPostCard
when embed is present
- lib/services/coves_api_service.dart: Add catch blocks for parsing
errors to prevent silent failures

Features:
- 42px circular avatar (tappable → opens Bluesky profile)
- Author name, handle, and relative timestamp
- Post text with max 6 lines
- Media placeholder with "View on Bluesky" link
- Nested quote post support (1 level)
- Disabled action bar with reply/repost/like counts
- "View on Bluesky" footer link
- Graceful unavailable post handling

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

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

+27
lib/constants/bluesky_colors.dart
···
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + /// Bluesky color constants 4 + /// 5 + /// Color palette matching Bluesky's brand identity for crosspost UI elements. 6 + class BlueskyColors { 7 + // Private constructor to prevent instantiation 8 + BlueskyColors._(); 9 + 10 + /// Background color - dark blue-gray 11 + static const background = Color(0xFF161E27); 12 + 13 + /// Card border color - medium blue-gray 14 + static const cardBorder = Color(0xFF2E3D4F); 15 + 16 + /// Primary text color - white 17 + static const textPrimary = Color(0xFFFFFFFF); 18 + 19 + /// Secondary text color - light blue-gray 20 + static const textSecondary = Color(0xFF8B98A5); 21 + 22 + /// Bluesky brand blue 23 + static const blueskyBlue = Color(0xFF1185FE); 24 + 25 + /// Disabled/muted action color 26 + static const actionDisabled = Color(0xFF5C6E7E); 27 + }
+253
lib/models/bluesky_post.dart
···
··· 1 + // Bluesky post data models for embedded posts and external links 2 + // 3 + // These models handle Bluesky post embeds from the backend API 4 + 5 + import 'package:flutter/foundation.dart'; 6 + 7 + import 'post.dart'; 8 + 9 + class BlueskyPostResult { 10 + BlueskyPostResult({ 11 + required this.uri, 12 + required this.cid, 13 + required this.createdAt, 14 + required this.author, 15 + required this.text, 16 + required this.replyCount, 17 + required this.repostCount, 18 + required this.likeCount, 19 + required this.hasMedia, 20 + required this.mediaCount, 21 + this.quotedPost, 22 + required this.unavailable, 23 + this.message, 24 + }) : assert(replyCount >= 0, 'replyCount must be non-negative'), 25 + assert(repostCount >= 0, 'repostCount must be non-negative'), 26 + assert(likeCount >= 0, 'likeCount must be non-negative'), 27 + assert(mediaCount >= 0, 'mediaCount must be non-negative'); 28 + 29 + /// Creates a [BlueskyPostResult] from JSON data. 30 + /// 31 + /// Throws [FormatException] if required fields are missing or have invalid types. 32 + /// This includes validation for all required string, int, bool, and DateTime fields. 33 + factory BlueskyPostResult.fromJson(Map<String, dynamic> json) { 34 + // Validate required string fields 35 + final uri = json['uri']; 36 + if (uri == null || uri is! String) { 37 + throw const FormatException( 38 + 'Missing or invalid uri field in BlueskyPostResult', 39 + ); 40 + } 41 + 42 + final cid = json['cid']; 43 + if (cid == null || cid is! String) { 44 + throw const FormatException( 45 + 'Missing or invalid cid field in BlueskyPostResult', 46 + ); 47 + } 48 + 49 + final createdAtStr = json['createdAt']; 50 + if (createdAtStr == null || createdAtStr is! String) { 51 + throw const FormatException( 52 + 'Missing or invalid createdAt field in BlueskyPostResult', 53 + ); 54 + } 55 + 56 + // Parse DateTime with error handling 57 + final DateTime createdAt; 58 + try { 59 + createdAt = DateTime.parse(createdAtStr); 60 + } on FormatException { 61 + throw FormatException('Invalid date format for createdAt: $createdAtStr'); 62 + } 63 + 64 + // Validate author field 65 + final author = json['author']; 66 + if (author == null || author is! Map<String, dynamic>) { 67 + throw const FormatException( 68 + 'Missing or invalid author field in BlueskyPostResult', 69 + ); 70 + } 71 + 72 + final text = json['text']; 73 + if (text == null || text is! String) { 74 + throw const FormatException( 75 + 'Missing or invalid text field in BlueskyPostResult', 76 + ); 77 + } 78 + 79 + // Validate required int fields 80 + final replyCount = json['replyCount']; 81 + if (replyCount == null || replyCount is! int) { 82 + throw const FormatException( 83 + 'Missing or invalid replyCount field in BlueskyPostResult', 84 + ); 85 + } 86 + 87 + final repostCount = json['repostCount']; 88 + if (repostCount == null || repostCount is! int) { 89 + throw const FormatException( 90 + 'Missing or invalid repostCount field in BlueskyPostResult', 91 + ); 92 + } 93 + 94 + final likeCount = json['likeCount']; 95 + if (likeCount == null || likeCount is! int) { 96 + throw const FormatException( 97 + 'Missing or invalid likeCount field in BlueskyPostResult', 98 + ); 99 + } 100 + 101 + // Validate required bool fields 102 + final hasMedia = json['hasMedia']; 103 + if (hasMedia == null || hasMedia is! bool) { 104 + throw const FormatException( 105 + 'Missing or invalid hasMedia field in BlueskyPostResult', 106 + ); 107 + } 108 + 109 + final mediaCount = json['mediaCount']; 110 + if (mediaCount == null || mediaCount is! int) { 111 + throw const FormatException( 112 + 'Missing or invalid mediaCount field in BlueskyPostResult', 113 + ); 114 + } 115 + 116 + final unavailable = json['unavailable']; 117 + if (unavailable == null || unavailable is! bool) { 118 + throw const FormatException( 119 + 'Missing or invalid unavailable field in BlueskyPostResult', 120 + ); 121 + } 122 + 123 + return BlueskyPostResult( 124 + uri: uri, 125 + cid: cid, 126 + createdAt: createdAt, 127 + author: AuthorView.fromJson(author), 128 + text: text, 129 + replyCount: replyCount, 130 + repostCount: repostCount, 131 + likeCount: likeCount, 132 + hasMedia: hasMedia, 133 + mediaCount: mediaCount, 134 + quotedPost: 135 + json['quotedPost'] != null 136 + ? BlueskyPostResult.fromJson( 137 + json['quotedPost'] as Map<String, dynamic>, 138 + ) 139 + : null, 140 + unavailable: unavailable, 141 + message: json['message'] as String?, 142 + ); 143 + } 144 + 145 + final String uri; 146 + final String cid; 147 + final DateTime createdAt; 148 + final AuthorView author; 149 + final String text; 150 + final int replyCount; 151 + final int repostCount; 152 + final int likeCount; 153 + final bool hasMedia; 154 + final int mediaCount; 155 + final BlueskyPostResult? quotedPost; 156 + final bool unavailable; 157 + final String? message; 158 + 159 + // NOTE: Missing ==, hashCode overrides (known limitation) 160 + // Consider using freezed package for value equality in the future 161 + } 162 + 163 + class BlueskyPostEmbed { 164 + BlueskyPostEmbed({required this.uri, required this.cid, this.resolved}); 165 + 166 + /// Creates a [BlueskyPostEmbed] from JSON data. 167 + /// 168 + /// Throws [FormatException] if the 'post' field is missing or invalid, 169 + /// or if required fields (uri, cid) within the post object are missing. 170 + factory BlueskyPostEmbed.fromJson(Map<String, dynamic> json) { 171 + // Parse the post reference 172 + final post = json['post']; 173 + if (post == null || post is! Map<String, dynamic>) { 174 + throw const FormatException( 175 + 'Missing or invalid post field in BlueskyPostEmbed', 176 + ); 177 + } 178 + 179 + // Validate required fields in post 180 + final uri = post['uri']; 181 + if (uri == null || uri is! String) { 182 + throw const FormatException( 183 + 'Missing or invalid uri field in BlueskyPostEmbed.post', 184 + ); 185 + } 186 + 187 + final cid = post['cid']; 188 + if (cid == null || cid is! String) { 189 + throw const FormatException( 190 + 'Missing or invalid cid field in BlueskyPostEmbed.post', 191 + ); 192 + } 193 + 194 + return BlueskyPostEmbed( 195 + uri: uri, 196 + cid: cid, 197 + resolved: 198 + json['resolved'] != null 199 + ? BlueskyPostResult.fromJson( 200 + json['resolved'] as Map<String, dynamic>, 201 + ) 202 + : null, 203 + ); 204 + } 205 + 206 + static const _blueskyBaseUrl = 'https://bsky.app'; 207 + 208 + /// AT-URI of the embedded post (e.g., "at://did:plc:xxx/app.bsky.feed.post/abc123") 209 + final String uri; 210 + 211 + /// CID of the embedded post 212 + final String cid; 213 + 214 + /// Resolved post data (if available from backend) 215 + final BlueskyPostResult? resolved; 216 + 217 + /// Build Bluesky web URL from AT-URI and author handle 218 + /// at://did:plc:xxx/app.bsky.feed.post/abc123 -> https://bsky.app/profile/handle/post/abc123 219 + /// 220 + /// Returns null if the AT-URI is invalid. Logs debug information when validation fails. 221 + static String? getPostWebUrl(BlueskyPostResult post, String atUri) { 222 + final uri = Uri.tryParse(atUri); 223 + if (uri == null) { 224 + if (kDebugMode) { 225 + debugPrint('getPostWebUrl: Failed to parse URI: $atUri'); 226 + } 227 + return null; 228 + } 229 + if (uri.scheme != 'at') { 230 + if (kDebugMode) { 231 + debugPrint( 232 + 'getPostWebUrl: Invalid URI scheme (expected "at", got "${uri.scheme}"): $atUri', 233 + ); 234 + } 235 + return null; 236 + } 237 + if (uri.pathSegments.length < 2) { 238 + if (kDebugMode) { 239 + debugPrint( 240 + 'getPostWebUrl: Invalid URI path (expected at least 2 segments, got ${uri.pathSegments.length}): $atUri', 241 + ); 242 + } 243 + return null; 244 + } 245 + final rkey = uri.pathSegments.last; 246 + return '$_blueskyBaseUrl/profile/${post.author.handle}/post/$rkey'; 247 + } 248 + 249 + /// Build Bluesky profile URL from handle 250 + static String getProfileUrl(String handle) { 251 + return '$_blueskyBaseUrl/profile/$handle'; 252 + } 253 + }
+21 -3
lib/models/post.dart
··· 4 // /xrpc/social.coves.feed.getTimeline 5 // /xrpc/social.coves.feed.getDiscover 6 7 class TimelineResponse { 8 TimelineResponse({required this.feed, this.cursor}); 9 ··· 110 ), 111 createdAt: DateTime.parse(json['createdAt'] as String), 112 indexedAt: DateTime.parse(json['indexedAt'] as String), 113 - text: json['text'] as String, 114 title: json['title'] as String?, 115 stats: PostStats.fromJson(json['stats'] as Map<String, dynamic>), 116 embed: ··· 211 } 212 213 class PostEmbed { 214 - PostEmbed({required this.type, this.external, required this.data}); 215 216 factory PostEmbed.fromJson(Map<String, dynamic> json) { 217 final embedType = json[r'$type'] as String? ?? 'unknown'; 218 ExternalEmbed? externalEmbed; 219 220 if (embedType == 'social.coves.embed.external' && 221 json['external'] != null) { ··· 224 ); 225 } 226 227 - return PostEmbed(type: embedType, external: externalEmbed, data: json); 228 } 229 final String type; 230 final ExternalEmbed? external; 231 final Map<String, dynamic> data; 232 } 233
··· 4 // /xrpc/social.coves.feed.getTimeline 5 // /xrpc/social.coves.feed.getDiscover 6 7 + import 'bluesky_post.dart'; 8 + 9 class TimelineResponse { 10 TimelineResponse({required this.feed, this.cursor}); 11 ··· 112 ), 113 createdAt: DateTime.parse(json['createdAt'] as String), 114 indexedAt: DateTime.parse(json['indexedAt'] as String), 115 + text: json['text'] as String? ?? '', 116 title: json['title'] as String?, 117 stats: PostStats.fromJson(json['stats'] as Map<String, dynamic>), 118 embed: ··· 213 } 214 215 class PostEmbed { 216 + PostEmbed({ 217 + required this.type, 218 + this.external, 219 + this.blueskyPost, 220 + required this.data, 221 + }); 222 223 factory PostEmbed.fromJson(Map<String, dynamic> json) { 224 final embedType = json[r'$type'] as String? ?? 'unknown'; 225 ExternalEmbed? externalEmbed; 226 + BlueskyPostEmbed? blueskyPostEmbed; 227 228 if (embedType == 'social.coves.embed.external' && 229 json['external'] != null) { ··· 232 ); 233 } 234 235 + if (embedType == 'social.coves.embed.post') { 236 + blueskyPostEmbed = BlueskyPostEmbed.fromJson(json); 237 + } 238 + 239 + return PostEmbed( 240 + type: embedType, 241 + external: externalEmbed, 242 + blueskyPost: blueskyPostEmbed, 243 + data: json, 244 + ); 245 } 246 final String type; 247 final ExternalEmbed? external; 248 + final BlueskyPostEmbed? blueskyPost; 249 final Map<String, dynamic> data; 250 } 251
+10
lib/services/coves_api_service.dart
··· 250 return TimelineResponse.fromJson(response.data as Map<String, dynamic>); 251 } on DioException catch (e) { 252 _handleDioException(e, 'timeline'); 253 } 254 } 255 ··· 293 return TimelineResponse.fromJson(response.data as Map<String, dynamic>); 294 } on DioException catch (e) { 295 _handleDioException(e, 'discover feed'); 296 } 297 } 298
··· 250 return TimelineResponse.fromJson(response.data as Map<String, dynamic>); 251 } on DioException catch (e) { 252 _handleDioException(e, 'timeline'); 253 + } catch (e) { 254 + if (kDebugMode) { 255 + debugPrint('❌ Error parsing timeline response: $e'); 256 + } 257 + throw ApiException('Failed to parse server response', originalError: e); 258 } 259 } 260 ··· 298 return TimelineResponse.fromJson(response.data as Map<String, dynamic>); 299 } on DioException catch (e) { 300 _handleDioException(e, 'discover feed'); 301 + } catch (e) { 302 + if (kDebugMode) { 303 + debugPrint('❌ Error parsing discover feed response: $e'); 304 + } 305 + throw ApiException('Failed to parse server response', originalError: e); 306 } 307 } 308
+59
lib/widgets/bluesky_action_bar.dart
···
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + import '../constants/bluesky_colors.dart'; 4 + import '../utils/date_time_utils.dart'; 5 + 6 + /// Bluesky action bar widget for displaying engagement counts 7 + /// 8 + /// Displays a read-only row of engagement metrics (replies, reposts, likes) 9 + /// with disabled styling to indicate these are view-only from Bluesky. 10 + /// 11 + /// All counts are formatted using [DateTimeUtils.formatCount] for readability 12 + /// (e.g., 1200 becomes "1.2k"). 13 + class BlueskyActionBar extends StatelessWidget { 14 + const BlueskyActionBar({ 15 + required this.replyCount, 16 + required this.repostCount, 17 + required this.likeCount, 18 + super.key, 19 + }); 20 + 21 + final int replyCount; 22 + final int repostCount; 23 + final int likeCount; 24 + 25 + @override 26 + Widget build(BuildContext context) { 27 + return Row( 28 + children: [ 29 + // Reply count 30 + _buildActionItem(icon: Icons.chat_bubble_outline, count: replyCount), 31 + const SizedBox(width: 24), 32 + 33 + // Repost count 34 + _buildActionItem(icon: Icons.repeat, count: repostCount), 35 + const SizedBox(width: 24), 36 + 37 + // Like count 38 + _buildActionItem(icon: Icons.favorite_border, count: likeCount), 39 + ], 40 + ); 41 + } 42 + 43 + /// Builds a single action item with icon and count 44 + Widget _buildActionItem({required IconData icon, required int count}) { 45 + return Row( 46 + children: [ 47 + Icon(icon, size: 16, color: BlueskyColors.actionDisabled), 48 + const SizedBox(width: 4), 49 + Text( 50 + DateTimeUtils.formatCount(count), 51 + style: const TextStyle( 52 + color: BlueskyColors.actionDisabled, 53 + fontSize: 13, 54 + ), 55 + ), 56 + ], 57 + ); 58 + } 59 + }
+423
lib/widgets/bluesky_post_card.dart
···
··· 1 + import 'package:cached_network_image/cached_network_image.dart'; 2 + import 'package:flutter/foundation.dart'; 3 + import 'package:flutter/material.dart'; 4 + 5 + import '../constants/bluesky_colors.dart'; 6 + import '../models/bluesky_post.dart'; 7 + import '../models/post.dart'; 8 + import '../utils/date_time_utils.dart'; 9 + import '../utils/url_launcher.dart'; 10 + import 'bluesky_action_bar.dart'; 11 + 12 + /// Bluesky post card widget for displaying Bluesky crossposts 13 + /// 14 + /// Renders a Bluesky post embed with: 15 + /// - User avatar and profile information (tappable to view on bsky.app) 16 + /// - Post text content with overflow handling 17 + /// - Media indicators for images/videos 18 + /// - Quoted posts (nested cards) 19 + /// - Read-only engagement metrics 20 + /// - "View on Bluesky" footer link 21 + /// 22 + /// The [currentTime] parameter allows passing the current time for 23 + /// time-ago calculations, enabling periodic updates and deterministic testing. 24 + class BlueskyPostCard extends StatelessWidget { 25 + const BlueskyPostCard({required this.embed, this.currentTime, super.key}); 26 + 27 + static const _blueskyBaseUrl = 'https://bsky.app'; 28 + 29 + final BlueskyPostEmbed embed; 30 + final DateTime? currentTime; 31 + 32 + /// Constructs the Bluesky post URL 33 + String _getPostUrl() { 34 + final resolved = embed.resolved; 35 + if (resolved == null) { 36 + return _blueskyBaseUrl; 37 + } 38 + 39 + return BlueskyPostEmbed.getPostWebUrl(resolved, embed.uri) ?? 40 + BlueskyPostEmbed.getProfileUrl(resolved.author.handle); 41 + } 42 + 43 + /// Constructs the Bluesky profile URL 44 + String _getProfileUrl() { 45 + final handle = embed.resolved?.author.handle; 46 + if (handle == null || handle.isEmpty) { 47 + return _blueskyBaseUrl; 48 + } 49 + return BlueskyPostEmbed.getProfileUrl(handle); 50 + } 51 + 52 + @override 53 + Widget build(BuildContext context) { 54 + // Handle unavailable posts 55 + if (embed.resolved == null) { 56 + return _buildUnavailableCard(); 57 + } 58 + 59 + final post = embed.resolved!; 60 + final author = post.author; 61 + 62 + return Container( 63 + decoration: BoxDecoration( 64 + border: Border.all(color: BlueskyColors.cardBorder), 65 + borderRadius: BorderRadius.circular(8), 66 + ), 67 + child: Padding( 68 + padding: const EdgeInsets.all(12), 69 + child: Column( 70 + crossAxisAlignment: CrossAxisAlignment.start, 71 + children: [ 72 + // Header: Avatar, display name, handle, timestamp 73 + _buildHeader(context, author), 74 + const SizedBox(height: 8), 75 + 76 + // Post text content 77 + if (post.text.isNotEmpty) ...[ 78 + Text( 79 + post.text, 80 + style: const TextStyle( 81 + color: BlueskyColors.textPrimary, 82 + fontSize: 14, 83 + height: 1.4, 84 + ), 85 + maxLines: 6, 86 + overflow: TextOverflow.ellipsis, 87 + ), 88 + const SizedBox(height: 8), 89 + ], 90 + 91 + // Media placeholder 92 + if (post.hasMedia) ...[ 93 + _buildMediaPlaceholder(context, post.mediaCount), 94 + const SizedBox(height: 8), 95 + ], 96 + 97 + // Quoted post 98 + if (post.quotedPost != null) ...[ 99 + _buildQuotedPost(context, post.quotedPost!), 100 + const SizedBox(height: 8), 101 + ], 102 + 103 + // Action bar (disabled) 104 + BlueskyActionBar( 105 + replyCount: post.replyCount, 106 + repostCount: post.repostCount, 107 + likeCount: post.likeCount, 108 + ), 109 + 110 + const SizedBox(height: 8), 111 + 112 + // "View on Bluesky" footer 113 + _buildFooterLink(context), 114 + ], 115 + ), 116 + ), 117 + ); 118 + } 119 + 120 + /// Builds the header row with avatar, name, handle, and timestamp 121 + Widget _buildHeader(BuildContext context, AuthorView author) { 122 + return GestureDetector( 123 + onTap: () { 124 + UrlLauncher.launchExternalUrl(_getProfileUrl(), context: context); 125 + }, 126 + child: Row( 127 + children: [ 128 + _buildAvatar(author), 129 + const SizedBox(width: 8), 130 + Expanded( 131 + child: Column( 132 + crossAxisAlignment: CrossAxisAlignment.start, 133 + children: [ 134 + Text( 135 + author.displayName ?? author.handle, 136 + style: const TextStyle( 137 + color: BlueskyColors.textPrimary, 138 + fontSize: 14, 139 + fontWeight: FontWeight.bold, 140 + ), 141 + overflow: TextOverflow.ellipsis, 142 + ), 143 + Text( 144 + '@${author.handle}', 145 + style: const TextStyle( 146 + color: BlueskyColors.textSecondary, 147 + fontSize: 12, 148 + ), 149 + overflow: TextOverflow.ellipsis, 150 + ), 151 + ], 152 + ), 153 + ), 154 + Text( 155 + DateTimeUtils.formatTimeAgo( 156 + embed.resolved!.createdAt, 157 + currentTime: currentTime, 158 + ), 159 + style: const TextStyle( 160 + color: BlueskyColors.textSecondary, 161 + fontSize: 12, 162 + ), 163 + ), 164 + ], 165 + ), 166 + ); 167 + } 168 + 169 + /// Builds the avatar widget with fallback 170 + Widget _buildAvatar(AuthorView author) { 171 + final avatarUrl = author.avatar; 172 + if (avatarUrl != null && avatarUrl.isNotEmpty) { 173 + return ClipRRect( 174 + borderRadius: BorderRadius.circular(21), 175 + child: CachedNetworkImage( 176 + imageUrl: avatarUrl, 177 + width: 42, 178 + height: 42, 179 + fit: BoxFit.cover, 180 + placeholder: (context, url) => _buildFallbackAvatar(author), 181 + errorWidget: (context, url, error) { 182 + if (kDebugMode) { 183 + debugPrint('Bluesky avatar load error: $error'); 184 + } 185 + return _buildFallbackAvatar(author); 186 + }, 187 + ), 188 + ); 189 + } 190 + 191 + return _buildFallbackAvatar(author); 192 + } 193 + 194 + /// Builds a fallback avatar with the first letter of display name or handle 195 + Widget _buildFallbackAvatar(AuthorView author) { 196 + final text = author.displayName ?? author.handle; 197 + final firstLetter = text.isNotEmpty ? text[0].toUpperCase() : '?'; 198 + 199 + return Container( 200 + width: 42, 201 + height: 42, 202 + decoration: BoxDecoration( 203 + color: BlueskyColors.blueskyBlue, 204 + borderRadius: BorderRadius.circular(21), 205 + ), 206 + child: Center( 207 + child: Text( 208 + firstLetter, 209 + style: const TextStyle( 210 + color: BlueskyColors.textPrimary, 211 + fontSize: 18, 212 + fontWeight: FontWeight.bold, 213 + ), 214 + ), 215 + ), 216 + ); 217 + } 218 + 219 + /// Builds the media placeholder for images 220 + Widget _buildMediaPlaceholder(BuildContext context, int mediaCount) { 221 + final mediaText = 222 + mediaCount == 1 ? 'Contains 1 image' : 'Contains $mediaCount images'; 223 + 224 + return GestureDetector( 225 + onTap: () { 226 + UrlLauncher.launchExternalUrl(_getPostUrl(), context: context); 227 + }, 228 + child: Container( 229 + padding: const EdgeInsets.all(10), 230 + decoration: BoxDecoration( 231 + color: BlueskyColors.background, 232 + borderRadius: BorderRadius.circular(6), 233 + border: Border.all(color: BlueskyColors.cardBorder), 234 + ), 235 + child: Row( 236 + children: [ 237 + const Icon( 238 + Icons.image_outlined, 239 + size: 16, 240 + color: BlueskyColors.textSecondary, 241 + ), 242 + const SizedBox(width: 6), 243 + Expanded( 244 + child: Text( 245 + mediaText, 246 + style: const TextStyle( 247 + color: BlueskyColors.textSecondary, 248 + fontSize: 13, 249 + ), 250 + ), 251 + ), 252 + const Icon( 253 + Icons.open_in_new, 254 + size: 14, 255 + color: BlueskyColors.textSecondary, 256 + ), 257 + ], 258 + ), 259 + ), 260 + ); 261 + } 262 + 263 + /// Builds the quoted post card (nested) 264 + Widget _buildQuotedPost(BuildContext context, BlueskyPostResult quotedPost) { 265 + return GestureDetector( 266 + onTap: () { 267 + // Open the quoted post on Bluesky 268 + final url = 269 + BlueskyPostEmbed.getPostWebUrl(quotedPost, quotedPost.uri) ?? 270 + BlueskyPostEmbed.getProfileUrl(quotedPost.author.handle); 271 + UrlLauncher.launchExternalUrl(url, context: context); 272 + }, 273 + child: Container( 274 + padding: const EdgeInsets.all(10), 275 + decoration: BoxDecoration( 276 + color: BlueskyColors.background, 277 + borderRadius: BorderRadius.circular(6), 278 + border: Border.all(color: BlueskyColors.cardBorder), 279 + ), 280 + child: Column( 281 + crossAxisAlignment: CrossAxisAlignment.start, 282 + children: [ 283 + Row( 284 + children: [ 285 + _buildSmallAvatar(quotedPost.author), 286 + const SizedBox(width: 6), 287 + Expanded( 288 + child: Text( 289 + quotedPost.author.displayName ?? quotedPost.author.handle, 290 + style: const TextStyle( 291 + color: BlueskyColors.textPrimary, 292 + fontSize: 12, 293 + fontWeight: FontWeight.bold, 294 + ), 295 + overflow: TextOverflow.ellipsis, 296 + ), 297 + ), 298 + ], 299 + ), 300 + const SizedBox(height: 6), 301 + 302 + // Quoted post text 303 + if (quotedPost.text.isNotEmpty) 304 + Text( 305 + quotedPost.text, 306 + style: const TextStyle( 307 + color: BlueskyColors.textPrimary, 308 + fontSize: 13, 309 + height: 1.4, 310 + ), 311 + maxLines: 3, 312 + overflow: TextOverflow.ellipsis, 313 + ), 314 + ], 315 + ), 316 + ), 317 + ); 318 + } 319 + 320 + /// Builds a small avatar widget with fallback for quoted posts 321 + Widget _buildSmallAvatar(AuthorView author) { 322 + final avatarUrl = author.avatar; 323 + if (avatarUrl != null && avatarUrl.isNotEmpty) { 324 + return ClipRRect( 325 + borderRadius: BorderRadius.circular(10), 326 + child: CachedNetworkImage( 327 + imageUrl: avatarUrl, 328 + width: 20, 329 + height: 20, 330 + fit: BoxFit.cover, 331 + placeholder: (context, url) => _buildSmallFallbackAvatar(author), 332 + errorWidget: (context, url, error) { 333 + if (kDebugMode) { 334 + debugPrint('Bluesky quoted post avatar load error: $error'); 335 + } 336 + return _buildSmallFallbackAvatar(author); 337 + }, 338 + ), 339 + ); 340 + } 341 + 342 + return _buildSmallFallbackAvatar(author); 343 + } 344 + 345 + /// Builds a small fallback avatar for quoted posts 346 + Widget _buildSmallFallbackAvatar(AuthorView author) { 347 + final text = author.displayName ?? author.handle; 348 + final firstLetter = text.isNotEmpty ? text[0].toUpperCase() : '?'; 349 + 350 + return Container( 351 + width: 20, 352 + height: 20, 353 + decoration: BoxDecoration( 354 + color: BlueskyColors.blueskyBlue, 355 + borderRadius: BorderRadius.circular(10), 356 + ), 357 + child: Center( 358 + child: Text( 359 + firstLetter, 360 + style: const TextStyle( 361 + color: BlueskyColors.textPrimary, 362 + fontSize: 10, 363 + fontWeight: FontWeight.bold, 364 + ), 365 + ), 366 + ), 367 + ); 368 + } 369 + 370 + /// Builds the "View on Bluesky" footer link 371 + Widget _buildFooterLink(BuildContext context) { 372 + return GestureDetector( 373 + onTap: () { 374 + UrlLauncher.launchExternalUrl(_getPostUrl(), context: context); 375 + }, 376 + child: Container( 377 + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), 378 + decoration: BoxDecoration( 379 + color: BlueskyColors.background, 380 + borderRadius: BorderRadius.circular(6), 381 + ), 382 + child: const Row( 383 + mainAxisSize: MainAxisSize.min, 384 + children: [ 385 + Icon(Icons.open_in_new, size: 14, color: BlueskyColors.blueskyBlue), 386 + SizedBox(width: 4), 387 + Text( 388 + 'View on Bluesky', 389 + style: TextStyle( 390 + color: BlueskyColors.blueskyBlue, 391 + fontSize: 13, 392 + fontWeight: FontWeight.w500, 393 + ), 394 + ), 395 + ], 396 + ), 397 + ), 398 + ); 399 + } 400 + 401 + /// Builds the unavailable post card 402 + Widget _buildUnavailableCard() { 403 + return Container( 404 + decoration: BoxDecoration( 405 + border: Border.all(color: BlueskyColors.cardBorder), 406 + borderRadius: BorderRadius.circular(8), 407 + ), 408 + child: const Padding( 409 + padding: EdgeInsets.all(16), 410 + child: Center( 411 + child: Text( 412 + 'This post is no longer available', 413 + style: TextStyle( 414 + color: BlueskyColors.textSecondary, 415 + fontSize: 14, 416 + fontStyle: FontStyle.italic, 417 + ), 418 + ), 419 + ), 420 + ), 421 + ); 422 + } 423 + }
+10
lib/widgets/post_card.dart
··· 9 import '../services/streamable_service.dart'; 10 import '../utils/community_handle_utils.dart'; 11 import '../utils/date_time_utils.dart'; 12 import 'external_link_bar.dart'; 13 import 'fullscreen_video_player.dart'; 14 import 'post_card_actions.dart'; ··· 187 disableNavigation 188 ? null 189 : () => _navigateToDetail(context), 190 ), 191 const SizedBox(height: 8), 192 ],
··· 9 import '../services/streamable_service.dart'; 10 import '../utils/community_handle_utils.dart'; 11 import '../utils/date_time_utils.dart'; 12 + import 'bluesky_post_card.dart'; 13 import 'external_link_bar.dart'; 14 import 'fullscreen_video_player.dart'; 15 import 'post_card_actions.dart'; ··· 188 disableNavigation 189 ? null 190 : () => _navigateToDetail(context), 191 + ), 192 + const SizedBox(height: 8), 193 + ], 194 + 195 + // Bluesky post embed 196 + if (post.post.embed?.blueskyPost != null) ...[ 197 + BlueskyPostCard( 198 + embed: post.post.embed!.blueskyPost!, 199 + currentTime: currentTime, 200 ), 201 const SizedBox(height: 8), 202 ],