feat(bluesky): enhance post embeds with external links and quoted post improvements

- Add external link embed support (link cards with thumbnail, title, description)
- Add BlueskyExternalEmbed model with domain extraction
- Improve quoted posts: show handle, timestamp, media placeholder
- Handle unavailable quoted posts (blocked, deleted, detached)
- Add official Bluesky SVG icons (reply, repost, like, logo)
- Match Bluesky's dim theme colors exactly
- Remove text truncation for posts (Bluesky has 300 char limit)
- Add formatCount and formatFullDateTime utilities
- Add comprehensive tests for Bluesky post models (67 tests)

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

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

+26 -9
lib/constants/bluesky_colors.dart
··· 2 2 3 3 /// Bluesky color constants 4 4 /// 5 - /// Color palette matching Bluesky's brand identity for crosspost UI elements. 5 + /// Color palette matching Bluesky's "dim" theme (dark blue-gray). 6 + /// These values are taken from the bskyembed component in social-app. 6 7 class BlueskyColors { 7 8 // Private constructor to prevent instantiation 8 9 BlueskyColors._(); 9 10 10 - /// Background color - dark blue-gray 11 - static const background = Color(0xFF161E27); 11 + /// Bluesky brand blue - outer container background 12 + /// From tailwind.config.cjs: brand = rgb(10,122,255) 13 + static const blueskyBlue = Color(0xFF0A7AFF); 12 14 13 - /// Card border color - medium blue-gray 15 + /// Inner card background - dim theme: dimmedBg = rgb(22,30,39) 16 + static const cardBackground = Color(0xFF161E27); 17 + 18 + /// Card border color - dim theme border 14 19 static const cardBorder = Color(0xFF2E3D4F); 15 20 16 21 /// Primary text color - white 17 22 static const textPrimary = Color(0xFFFFFFFF); 18 23 19 - /// Secondary text color - light blue-gray 24 + /// Secondary text color - gray-blue for handles 20 25 static const textSecondary = Color(0xFF8B98A5); 21 26 22 - /// Bluesky brand blue 23 - static const blueskyBlue = Color(0xFF1185FE); 27 + /// Muted text color - for timestamps 28 + static const textMuted = Color(0xFF8B98A5); 29 + 30 + /// Action icon/text color - same gray-blue 31 + static const actionColor = Color(0xFF8B98A5); 32 + 33 + /// Link color - Bluesky blue 34 + static const linkBlue = Color(0xFF1185FE); 35 + 36 + /// Avatar fallback background 37 + static const avatarFallback = Color(0xFF2E3D4F); 38 + 39 + /// Outer container border radius 40 + static const double outerBorderRadius = 20; 24 41 25 - /// Disabled/muted action color 26 - static const actionDisabled = Color(0xFF5C6E7E); 42 + /// Inner card border radius 43 + static const double innerBorderRadius = 14; 27 44 }
+83
lib/constants/bluesky_icons.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_svg/flutter_svg.dart'; 3 + 4 + /// Bluesky SVG icons matching the official bskyembed styling 5 + class BlueskyIcons { 6 + BlueskyIcons._(); 7 + 8 + /// Reply/comment icon 9 + static const String _replySvg = ''' 10 + <svg viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg"> 11 + <path fill-rule="evenodd" clip-rule="evenodd" d="M1.3335 4.23242C1.3335 3.12785 2.22893 2.23242 3.3335 2.23242H12.6668C13.7714 2.23242 14.6668 3.12785 14.6668 4.23242V10.8991C14.6668 12.0037 13.7714 12.8991 12.6668 12.8991H8.18482L5.00983 14.8041C4.80387 14.9277 4.54737 14.9309 4.33836 14.8126C4.12936 14.6942 4.00016 14.4726 4.00016 14.2324V12.8991H3.3335C2.22893 12.8991 1.3335 12.0037 1.3335 10.8991V4.23242ZM3.3335 3.56576C2.96531 3.56576 2.66683 3.86423 2.66683 4.23242V10.8991C2.66683 11.2673 2.96531 11.5658 3.3335 11.5658H4.66683C5.03502 11.5658 5.3335 11.8642 5.3335 12.2324V13.055L7.65717 11.6608C7.76078 11.5986 7.87933 11.5658 8.00016 11.5658H12.6668C13.035 11.5658 13.3335 11.2673 13.3335 10.8991V4.23242C13.3335 3.86423 13.035 3.56576 12.6668 3.56576H3.3335Z" fill="currentColor"/> 12 + </svg> 13 + '''; 14 + 15 + /// Repost icon 16 + static const String _repostSvg = ''' 17 + <svg viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg"> 18 + <path d="M3.86204 9.76164C4.12239 9.50134 4.54442 9.50131 4.80475 9.76164C5.06503 10.022 5.06503 10.444 4.80475 10.7044L3.94277 11.5663H11.3334C12.0697 11.5663 12.6667 10.9693 12.6667 10.233V8.89966C12.6667 8.53147 12.9652 8.233 13.3334 8.233C13.7015 8.23305 14.0001 8.53151 14.0001 8.89966V10.233C14.0001 11.7057 12.8061 12.8996 11.3334 12.8997H3.94277L4.80475 13.7616C5.06503 14.022 5.06503 14.444 4.80475 14.7044C4.54442 14.9647 4.12239 14.9646 3.86204 14.7044L2.3334 13.1757C1.8127 12.655 1.8127 11.811 2.3334 11.2903L3.86204 9.76164ZM2.00006 7.56633V6.233C2.00006 4.76024 3.19397 3.56633 4.66673 3.56633H12.0574L11.1954 2.70435C10.935 2.444 10.935 2.02199 11.1954 1.76164C11.4557 1.50134 11.8778 1.50131 12.1381 1.76164L13.6667 3.29029C14.1873 3.81096 14.1873 4.65503 13.6667 5.17571L12.1381 6.70435C11.8778 6.96468 11.4557 6.96465 11.1954 6.70435C10.935 6.444 10.935 6.02199 11.1954 5.76164L12.0574 4.89966H4.66673C3.93035 4.89966 3.3334 5.49662 3.3334 6.233V7.56633C3.3334 7.93449 3.03487 8.23294 2.66673 8.233C2.29854 8.233 2.00006 7.93452 2.00006 7.56633Z" fill="currentColor"/> 19 + </svg> 20 + '''; 21 + 22 + /// Like/heart icon 23 + static const String _likeSvg = ''' 24 + <svg viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg"> 25 + <path fill-rule="evenodd" clip-rule="evenodd" d="M11.1561 3.62664C10.3307 3.44261 9.35086 3.65762 8.47486 4.54615C8.34958 4.67323 8.17857 4.74478 8.00012 4.74478C7.82167 4.74478 7.65066 4.67324 7.52538 4.54616C6.64938 3.65762 5.66955 3.44261 4.84416 3.62664C4.0022 3.81438 3.25812 4.43047 2.89709 5.33069C2.21997 7.01907 2.83524 10.1257 8.00015 13.1315C13.165 10.1257 13.7803 7.01906 13.1032 5.33069C12.7421 4.43047 11.998 3.81437 11.1561 3.62664ZM14.3407 4.83438C15.4101 7.50098 14.0114 11.2942 8.32611 14.4808C8.12362 14.5943 7.87668 14.5943 7.6742 14.4808C1.98891 11.2942 0.590133 7.501 1.65956 4.83439C2.1788 3.53968 3.26862 2.61187 4.55399 2.32527C5.68567 2.07294 6.92237 2.32723 8.00012 3.18278C9.07786 2.32723 10.3146 2.07294 11.4462 2.32526C12.7316 2.61186 13.8214 3.53967 14.3407 4.83438Z" fill="currentColor"/> 26 + </svg> 27 + '''; 28 + 29 + /// Bluesky butterfly logo 30 + static const String _logoSvg = ''' 31 + <svg viewBox="0 0 18 16" fill="none" xmlns="http://www.w3.org/2000/svg"> 32 + <path d="M3.79 1.775C5.795 3.289 7.951 6.359 8.743 8.006C9.534 6.359 11.69 3.289 13.695 1.775C15.141 0.683 17.485 -0.163 17.485 2.527C17.485 3.064 17.179 7.039 16.999 7.685C16.375 9.929 14.101 10.501 12.078 10.154C15.614 10.76 16.514 12.765 14.571 14.771C10.2 19.283 8.743 12.357 8.743 12.357C8.743 12.357 7.286 19.283 2.914 14.771C0.971 12.765 1.871 10.76 5.407 10.154C3.384 10.501 1.11 9.929 0.486 7.685C0.306 7.039 0 3.064 0 2.527C0 -0.163 2.344 0.683 3.79 1.775Z" fill="currentColor"/> 33 + </svg> 34 + '''; 35 + 36 + /// Build reply icon widget 37 + static Widget reply({double size = 20, Color? color}) { 38 + return SvgPicture.string( 39 + _replySvg.replaceAll('currentColor', _colorToHex(color)), 40 + width: size, 41 + height: size, 42 + ); 43 + } 44 + 45 + /// Build repost icon widget 46 + static Widget repost({double size = 20, Color? color}) { 47 + return SvgPicture.string( 48 + _repostSvg.replaceAll('currentColor', _colorToHex(color)), 49 + width: size, 50 + height: size, 51 + ); 52 + } 53 + 54 + /// Build like icon widget 55 + static Widget like({double size = 20, Color? color}) { 56 + return SvgPicture.string( 57 + _likeSvg.replaceAll('currentColor', _colorToHex(color)), 58 + width: size, 59 + height: size, 60 + ); 61 + } 62 + 63 + /// Build Bluesky logo widget 64 + static Widget logo({double size = 20, Color? color}) { 65 + return SvgPicture.string( 66 + _logoSvg.replaceAll('currentColor', _colorToHex(color)), 67 + width: size, 68 + height: size * (16 / 18), // Maintain aspect ratio 69 + ); 70 + } 71 + 72 + /// Convert Color to hex string for SVG 73 + static String _colorToHex(Color? color) { 74 + if (color == null) { 75 + return '#8B98A5'; 76 + } 77 + // Color.r/g/b are 0.0-1.0, multiply by 255 to get 0-255 range 78 + final r = (color.r * 255).round().toRadixString(16).padLeft(2, '0'); 79 + final g = (color.g * 255).round().toRadixString(16).padLeft(2, '0'); 80 + final b = (color.b * 255).round().toRadixString(16).padLeft(2, '0'); 81 + return '#$r$g$b'.toUpperCase(); 82 + } 83 + }
+13
lib/constants/embed_types.dart
··· 1 + /// Constants for Coves embed type identifiers. 2 + /// 3 + /// These type strings are used in the $type field of embed objects 4 + /// to identify the kind of embedded content in posts. 5 + class EmbedTypes { 6 + EmbedTypes._(); 7 + 8 + /// External link embed (URLs, articles, etc.) 9 + static const external = 'social.coves.embed.external'; 10 + 11 + /// Embedded Bluesky post 12 + static const post = 'social.coves.embed.post'; 13 + }
+118 -20
lib/models/bluesky_post.dart
··· 6 6 7 7 import 'post.dart'; 8 8 9 + /// External link embed from a Bluesky post (link cards with title/description). 10 + class BlueskyExternalEmbed { 11 + BlueskyExternalEmbed({ 12 + required this.uri, 13 + this.title, 14 + this.description, 15 + this.thumb, 16 + }); 17 + 18 + factory BlueskyExternalEmbed.fromJson(Map<String, dynamic> json) { 19 + final uri = json['uri']; 20 + if (uri == null || uri is! String) { 21 + throw const FormatException( 22 + 'Missing or invalid uri field in BlueskyExternalEmbed', 23 + ); 24 + } 25 + 26 + return BlueskyExternalEmbed( 27 + uri: uri, 28 + title: json['title'] as String?, 29 + description: json['description'] as String?, 30 + thumb: json['thumb'] as String?, 31 + ); 32 + } 33 + 34 + /// URL of the external link 35 + final String uri; 36 + 37 + /// Page title (from og:title or <title>) 38 + final String? title; 39 + 40 + /// Page description (from og:description or meta description) 41 + final String? description; 42 + 43 + /// Thumbnail URL 44 + final String? thumb; 45 + 46 + /// Extract a nice domain from the URL (e.g., "lemonde.fr" from full URL) 47 + String get domain { 48 + final parsed = Uri.tryParse(uri); 49 + if (parsed == null || parsed.host.isEmpty) { 50 + return uri; 51 + } 52 + var host = parsed.host; 53 + // Remove www. prefix 54 + if (host.startsWith('www.')) { 55 + host = host.substring(4); 56 + } 57 + return host; 58 + } 59 + } 60 + 9 61 class BlueskyPostResult { 10 62 BlueskyPostResult({ 11 63 required this.uri, ··· 21 73 this.quotedPost, 22 74 required this.unavailable, 23 75 this.message, 76 + this.embed, 24 77 }) : assert(replyCount >= 0, 'replyCount must be non-negative'), 25 78 assert(repostCount >= 0, 'repostCount must be non-negative'), 26 79 assert(likeCount >= 0, 'likeCount must be non-negative'), ··· 120 173 ); 121 174 } 122 175 176 + // Parse optional external embed 177 + BlueskyExternalEmbed? embed; 178 + if (json['embed'] != null) { 179 + try { 180 + embed = BlueskyExternalEmbed.fromJson( 181 + json['embed'] as Map<String, dynamic>, 182 + ); 183 + } on FormatException catch (e) { 184 + if (kDebugMode) { 185 + debugPrint('BlueskyPostResult: Failed to parse embed: $e'); 186 + } 187 + // Leave embed as null 188 + } 189 + } 190 + 123 191 return BlueskyPostResult( 124 192 uri: uri, 125 193 cid: cid, ··· 139 207 : null, 140 208 unavailable: unavailable, 141 209 message: json['message'] as String?, 210 + embed: embed, 142 211 ); 143 212 } 144 213 ··· 156 225 final bool unavailable; 157 226 final String? message; 158 227 228 + /// External link embed (link card) if present in the post 229 + final BlueskyExternalEmbed? embed; 230 + 159 231 // NOTE: Missing ==, hashCode overrides (known limitation) 160 232 // Consider using freezed package for value equality in the future 161 233 } ··· 191 263 ); 192 264 } 193 265 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 - ); 266 + // Try to parse resolved post, but handle gracefully if it fails 267 + // (e.g., deleted posts may have partial data without author) 268 + BlueskyPostResult? resolved; 269 + if (json['resolved'] != null) { 270 + try { 271 + resolved = BlueskyPostResult.fromJson( 272 + json['resolved'] as Map<String, dynamic>, 273 + ); 274 + } on FormatException catch (e) { 275 + if (kDebugMode) { 276 + debugPrint( 277 + 'BlueskyPostEmbed: Failed to parse resolved post, ' 278 + 'treating as unavailable. Error: $e', 279 + ); 280 + } 281 + // Leave resolved as null - UI will show unavailable card 282 + } 283 + } 284 + 285 + return BlueskyPostEmbed(uri: uri, cid: cid, resolved: resolved); 204 286 } 205 287 206 288 static const _blueskyBaseUrl = 'https://bsky.app'; ··· 218 300 /// at://did:plc:xxx/app.bsky.feed.post/abc123 -> https://bsky.app/profile/handle/post/abc123 219 301 /// 220 302 /// Returns null if the AT-URI is invalid. Logs debug information when validation fails. 303 + /// 304 + /// Note: We manually parse AT-URIs because Dart's Uri.tryParse() fails on 305 + /// DIDs containing colons (e.g., did:plc:xxx). 221 306 static String? getPostWebUrl(BlueskyPostResult post, String atUri) { 222 - final uri = Uri.tryParse(atUri); 223 - if (uri == null) { 307 + // AT-URI format: at://did:plc:xxx/app.bsky.feed.post/rkey 308 + const prefix = 'at://'; 309 + 310 + if (!atUri.startsWith(prefix)) { 224 311 if (kDebugMode) { 225 - debugPrint('getPostWebUrl: Failed to parse URI: $atUri'); 312 + debugPrint('getPostWebUrl: URI does not start with at://: $atUri'); 226 313 } 227 314 return null; 228 315 } 229 - if (uri.scheme != 'at') { 316 + 317 + // Remove the at:// prefix and find the path 318 + final remainder = atUri.substring(prefix.length); 319 + 320 + // Find the first slash after the DID to get the path 321 + final firstSlash = remainder.indexOf('/'); 322 + if (firstSlash == -1) { 230 323 if (kDebugMode) { 231 - debugPrint( 232 - 'getPostWebUrl: Invalid URI scheme (expected "at", got "${uri.scheme}"): $atUri', 233 - ); 324 + debugPrint('getPostWebUrl: No path found in URI: $atUri'); 234 325 } 235 326 return null; 236 327 } 237 - if (uri.pathSegments.length < 2) { 328 + 329 + // Extract path segments (collection/rkey) 330 + final path = remainder.substring(firstSlash + 1); 331 + final pathSegments = path.split('/'); 332 + 333 + if (pathSegments.length < 2) { 238 334 if (kDebugMode) { 239 335 debugPrint( 240 - 'getPostWebUrl: Invalid URI path (expected at least 2 segments, got ${uri.pathSegments.length}): $atUri', 336 + 'getPostWebUrl: Invalid path (expected collection/rkey): $atUri', 241 337 ); 242 338 } 243 339 return null; 244 340 } 245 - final rkey = uri.pathSegments.last; 341 + 342 + // The rkey is the last segment 343 + final rkey = pathSegments.last; 246 344 return '$_blueskyBaseUrl/profile/${post.author.handle}/post/$rkey'; 247 345 } 248 346
+49 -7
lib/models/community.dart
··· 4 4 // GET /xrpc/social.coves.community.list 5 5 // POST /xrpc/social.coves.community.post.create 6 6 7 + import '../constants/embed_types.dart'; 8 + 7 9 /// Response from GET /xrpc/social.coves.community.list 8 10 class CommunitiesResponse { 9 11 CommunitiesResponse({required this.communities, this.cursor}); ··· 197 199 198 200 /// External link embed input for creating posts 199 201 class ExternalEmbedInput { 200 - const ExternalEmbedInput({ 202 + /// Creates an [ExternalEmbedInput] with URI validation. 203 + /// 204 + /// Throws [ArgumentError] if [uri] is empty or not a valid URL. 205 + factory ExternalEmbedInput({ 206 + required String uri, 207 + String? title, 208 + String? description, 209 + String? thumb, 210 + }) { 211 + // Validate URI is not empty 212 + if (uri.isEmpty) { 213 + throw ArgumentError.value(uri, 'uri', 'URI cannot be empty'); 214 + } 215 + 216 + // Validate URI is a well-formed URL 217 + final parsedUri = Uri.tryParse(uri); 218 + if (parsedUri == null || 219 + !parsedUri.hasScheme || 220 + (!parsedUri.isScheme('http') && !parsedUri.isScheme('https'))) { 221 + throw ArgumentError.value( 222 + uri, 223 + 'uri', 224 + 'URI must be a valid HTTP or HTTPS URL', 225 + ); 226 + } 227 + 228 + return ExternalEmbedInput._( 229 + uri: uri, 230 + title: title, 231 + description: description, 232 + thumb: thumb, 233 + ); 234 + } 235 + 236 + const ExternalEmbedInput._({ 201 237 required this.uri, 202 238 this.title, 203 239 this.description, ··· 205 241 }); 206 242 207 243 Map<String, dynamic> toJson() { 208 - final json = <String, dynamic>{ 244 + final external = <String, dynamic>{ 209 245 'uri': uri, 210 246 }; 211 247 212 248 if (title != null) { 213 - json['title'] = title; 249 + external['title'] = title; 214 250 } 215 251 if (description != null) { 216 - json['description'] = description; 252 + external['description'] = description; 217 253 } 218 254 if (thumb != null) { 219 - json['thumb'] = thumb; 255 + external['thumb'] = thumb; 220 256 } 221 257 222 - return json; 258 + // Return proper embed structure expected by backend 259 + return { 260 + r'$type': EmbedTypes.external, 261 + 'external': external, 262 + }; 223 263 } 224 264 225 265 /// URL of the external link ··· 316 356 317 357 @override 318 358 bool operator ==(Object other) { 319 - if (identical(this, other)) return true; 359 + if (identical(this, other)) { 360 + return true; 361 + } 320 362 return other is CreateCommunityResponse && 321 363 other.uri == uri && 322 364 other.cid == cid &&
+35 -14
lib/models/post.dart
··· 4 4 // /xrpc/social.coves.feed.getTimeline 5 5 // /xrpc/social.coves.feed.getDiscover 6 6 7 + import 'package:flutter/foundation.dart'; 8 + 9 + import '../constants/embed_types.dart'; 7 10 import 'bluesky_post.dart'; 8 11 9 12 class TimelineResponse { ··· 12 15 factory TimelineResponse.fromJson(Map<String, dynamic> json) { 13 16 // Handle null feed array from backend 14 17 final feedData = json['feed']; 15 - final List<FeedViewPost> feedList; 18 + final feedList = <FeedViewPost>[]; 16 19 17 - if (feedData == null) { 18 - // Backend returned null, use empty list 19 - feedList = []; 20 - } else { 21 - // Parse feed items 22 - feedList = 23 - (feedData as List<dynamic>) 24 - .map( 25 - (item) => FeedViewPost.fromJson(item as Map<String, dynamic>), 26 - ) 27 - .toList(); 20 + if (feedData != null) { 21 + // Parse feed items, skipping any that fail to parse 22 + for (final item in feedData as List<dynamic>) { 23 + try { 24 + feedList.add( 25 + FeedViewPost.fromJson(item as Map<String, dynamic>), 26 + ); 27 + } on Exception catch (e) { 28 + // Skip malformed posts (e.g., deleted posts with missing data) 29 + if (kDebugMode) { 30 + debugPrint('鈿狅笍 Skipping malformed feed item: $e'); 31 + } 32 + } 33 + } 28 34 } 29 35 30 36 return TimelineResponse(feed: feedList, cursor: json['cursor'] as String?); ··· 225 231 ExternalEmbed? externalEmbed; 226 232 BlueskyPostEmbed? blueskyPostEmbed; 227 233 228 - if (embedType == 'social.coves.embed.external' && 234 + if (embedType == EmbedTypes.external && 229 235 json['external'] != null) { 230 236 externalEmbed = ExternalEmbed.fromJson( 231 237 json['external'] as Map<String, dynamic>, 232 238 ); 233 239 } 234 240 235 - if (embedType == 'social.coves.embed.post') { 241 + if (embedType == EmbedTypes.post) { 236 242 blueskyPostEmbed = BlueskyPostEmbed.fromJson(json); 243 + } 244 + 245 + // Fallback: if no typed embed was parsed but we have a uri field at the 246 + // top level, treat it as an external link embed. This handles cases where 247 + // the backend returns simple link embeds without the full $type wrapper. 248 + if (externalEmbed == null && 249 + blueskyPostEmbed == null && 250 + json['uri'] != null) { 251 + if (kDebugMode) { 252 + debugPrint( 253 + 'PostEmbed fallback: treating unrecognized embed as external link. ' 254 + 'Type was: ${json[r'$type']}, keys: ${json.keys.toList()}', 255 + ); 256 + } 257 + externalEmbed = ExternalEmbed.fromJson(json); 237 258 } 238 259 239 260 return PostEmbed(
+3 -2
lib/screens/home/create_post_screen.dart
··· 2 2 import 'package:provider/provider.dart'; 3 3 4 4 import '../../constants/app_colors.dart'; 5 + import '../../constants/embed_types.dart'; 5 6 import '../../models/community.dart'; 6 7 import '../../models/post.dart'; 7 8 import '../../providers/auth_provider.dart'; ··· 290 291 final url = _urlController.text.trim(); 291 292 if (url.isNotEmpty) { 292 293 embed = PostEmbed( 293 - type: 'social.coves.embed.external', 294 + type: EmbedTypes.external, 294 295 external: ExternalEmbed( 295 296 uri: url, 296 297 title: _titleController.text.trim().isNotEmpty ··· 301 302 : null, 302 303 ), 303 304 data: { 304 - r'$type': 'social.coves.embed.external', 305 + r'$type': EmbedTypes.external, 305 306 'external': { 306 307 'uri': url, 307 308 if (_titleController.text.trim().isNotEmpty)
+33
lib/utils/date_time_utils.dart
··· 48 48 return '${thousands.toStringAsFixed(1)}k'; 49 49 } 50 50 } 51 + 52 + /// Format datetime as full date/time string like Bluesky 53 + /// 54 + /// Example: "12:01PM 路 Dec 26, 2025" 55 + /// 56 + /// [dateTime] is the time to format 57 + static String formatFullDateTime(DateTime dateTime) { 58 + final hour = dateTime.hour; 59 + final minute = dateTime.minute.toString().padLeft(2, '0'); 60 + final period = hour >= 12 ? 'PM' : 'AM'; 61 + final hour12 = hour == 0 ? 12 : (hour > 12 ? hour - 12 : hour); 62 + 63 + const months = [ 64 + 'Jan', 65 + 'Feb', 66 + 'Mar', 67 + 'Apr', 68 + 'May', 69 + 'Jun', 70 + 'Jul', 71 + 'Aug', 72 + 'Sep', 73 + 'Oct', 74 + 'Nov', 75 + 'Dec', 76 + ]; 77 + assert(dateTime.month >= 1 && dateTime.month <= 12, 'Invalid month'); 78 + final month = months[dateTime.month - 1]; 79 + final day = dateTime.day; 80 + final year = dateTime.year; 81 + 82 + return '$hour12:$minute$period 路 $month $day, $year'; 83 + } 51 84 }
-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 - }
+436 -147
lib/widgets/bluesky_post_card.dart
··· 3 3 import 'package:flutter/material.dart'; 4 4 5 5 import '../constants/bluesky_colors.dart'; 6 + import '../constants/bluesky_icons.dart'; 6 7 import '../models/bluesky_post.dart'; 7 8 import '../models/post.dart'; 8 9 import '../utils/date_time_utils.dart'; 9 10 import '../utils/url_launcher.dart'; 10 - import 'bluesky_action_bar.dart'; 11 11 12 12 /// Bluesky post card widget for displaying Bluesky crossposts 13 13 /// ··· 36 36 return _blueskyBaseUrl; 37 37 } 38 38 39 - return BlueskyPostEmbed.getPostWebUrl(resolved, embed.uri) ?? 39 + // Use resolved.uri (the actual post URI) instead of embed.uri 40 + return BlueskyPostEmbed.getPostWebUrl(resolved, resolved.uri) ?? 40 41 BlueskyPostEmbed.getProfileUrl(resolved.author.handle); 41 42 } 42 43 ··· 59 60 final post = embed.resolved!; 60 61 final author = post.author; 61 62 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), 63 + // Card matching Bluesky's dim theme 64 + return GestureDetector( 65 + onTap: () { 66 + UrlLauncher.launchExternalUrl(_getPostUrl(), context: context); 67 + }, 68 + child: Container( 69 + decoration: BoxDecoration( 70 + color: BlueskyColors.cardBackground, 71 + borderRadius: BorderRadius.circular(BlueskyColors.innerBorderRadius), 72 + border: Border.all(color: BlueskyColors.cardBorder), 73 + ), 74 + padding: const EdgeInsets.all(16), 69 75 child: Column( 70 76 crossAxisAlignment: CrossAxisAlignment.start, 71 77 children: [ 72 - // Header: Avatar, display name, handle, timestamp 78 + // Header: Avatar, display name, handle 73 79 _buildHeader(context, author), 74 80 const SizedBox(height: 8), 75 81 76 - // Post text content 82 + // Post text content (no truncation - Bluesky posts are max 300 chars) 77 83 if (post.text.isNotEmpty) ...[ 78 84 Text( 79 85 post.text, 80 86 style: const TextStyle( 81 87 color: BlueskyColors.textPrimary, 82 - fontSize: 14, 88 + fontSize: 16, 83 89 height: 1.4, 84 90 ), 85 - maxLines: 6, 86 - overflow: TextOverflow.ellipsis, 87 91 ), 88 92 const SizedBox(height: 8), 89 93 ], ··· 94 98 const SizedBox(height: 8), 95 99 ], 96 100 101 + // External link embed (link card) 102 + if (post.embed != null) ...[ 103 + _buildExternalEmbed(context, post.embed!), 104 + const SizedBox(height: 8), 105 + ], 106 + 97 107 // Quoted post 98 108 if (post.quotedPost != null) ...[ 99 109 _buildQuotedPost(context, post.quotedPost!), 100 110 const SizedBox(height: 8), 101 111 ], 102 112 103 - // Action bar (disabled) 104 - BlueskyActionBar( 105 - replyCount: post.replyCount, 106 - repostCount: post.repostCount, 107 - likeCount: post.likeCount, 108 - ), 113 + // Timestamp row 114 + const SizedBox(height: 4), 115 + _buildTimestampRow(context, post), 109 116 110 - const SizedBox(height: 8), 117 + // Likes count (if any) 118 + if (post.likeCount > 0) ...[ 119 + const SizedBox(height: 12), 120 + _buildLikesCount(post.likeCount), 121 + ], 111 122 112 - // "View on Bluesky" footer 113 - _buildFooterLink(context), 123 + // Action bar 124 + const SizedBox(height: 12), 125 + _buildActionBar(post), 114 126 ], 115 127 ), 116 128 ), 117 129 ); 118 130 } 119 131 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 - }, 132 + /// Builds the timestamp row with date/time 133 + Widget _buildTimestampRow(BuildContext context, BlueskyPostResult post) { 134 + return Text( 135 + DateTimeUtils.formatFullDateTime(post.createdAt), 136 + style: const TextStyle( 137 + color: BlueskyColors.textSecondary, 138 + fontSize: 13, 139 + ), 140 + ); 141 + } 142 + 143 + /// Builds the likes count display (e.g., "3 likes") 144 + Widget _buildLikesCount(int likeCount) { 145 + return Container( 146 + padding: const EdgeInsets.only(top: 10), 147 + decoration: const BoxDecoration( 148 + border: Border( 149 + top: BorderSide(color: BlueskyColors.cardBorder), 150 + ), 151 + ), 126 152 child: Row( 127 153 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 - ], 154 + Text( 155 + '$likeCount', 156 + style: const TextStyle( 157 + color: BlueskyColors.textPrimary, 158 + fontSize: 14, 159 + fontWeight: FontWeight.bold, 160 + ), 161 + ), 162 + const SizedBox(width: 4), 163 + const Text( 164 + 'likes', 165 + style: TextStyle( 166 + color: BlueskyColors.textSecondary, 167 + fontSize: 14, 152 168 ), 153 169 ), 170 + ], 171 + ), 172 + ); 173 + } 174 + 175 + /// Builds the action bar with Bluesky icons 176 + Widget _buildActionBar(BlueskyPostResult post) { 177 + return Container( 178 + padding: const EdgeInsets.only(top: 10), 179 + decoration: const BoxDecoration( 180 + border: Border( 181 + top: BorderSide(color: BlueskyColors.cardBorder), 182 + ), 183 + ), 184 + child: Row( 185 + mainAxisAlignment: MainAxisAlignment.spaceAround, 186 + children: [ 187 + // Reply 188 + _buildActionWithSvg( 189 + BlueskyIcons.reply(size: 18, color: BlueskyColors.actionColor), 190 + post.replyCount, 191 + ), 192 + // Repost 193 + _buildActionWithSvg( 194 + BlueskyIcons.repost(size: 18, color: BlueskyColors.actionColor), 195 + post.repostCount, 196 + ), 197 + // Like 198 + _buildActionWithSvg( 199 + BlueskyIcons.like(size: 18, color: BlueskyColors.actionColor), 200 + post.likeCount, 201 + ), 202 + // Bookmark (keep Material icon for now) 203 + _buildActionIcon(Icons.bookmark_border, null), 204 + // Share/More (keep Material icon for now) 205 + _buildActionIcon(Icons.more_horiz, null), 206 + ], 207 + ), 208 + ); 209 + } 210 + 211 + /// Builds an action item with SVG icon and optional count 212 + Widget _buildActionWithSvg(Widget icon, int? count) { 213 + return Row( 214 + mainAxisSize: MainAxisSize.min, 215 + children: [ 216 + icon, 217 + if (count != null && count > 0) ...[ 218 + const SizedBox(width: 4), 154 219 Text( 155 - DateTimeUtils.formatTimeAgo( 156 - embed.resolved!.createdAt, 157 - currentTime: currentTime, 220 + DateTimeUtils.formatCount(count), 221 + style: const TextStyle( 222 + color: BlueskyColors.actionColor, 223 + fontSize: 13, 158 224 ), 225 + ), 226 + ], 227 + ], 228 + ); 229 + } 230 + 231 + /// Builds a single action icon with optional count (for Material icons) 232 + Widget _buildActionIcon(IconData icon, int? count) { 233 + return Row( 234 + mainAxisSize: MainAxisSize.min, 235 + children: [ 236 + Icon(icon, size: 20, color: BlueskyColors.actionColor), 237 + if (count != null && count > 0) ...[ 238 + const SizedBox(width: 4), 239 + Text( 240 + DateTimeUtils.formatCount(count), 159 241 style: const TextStyle( 160 - color: BlueskyColors.textSecondary, 161 - fontSize: 12, 242 + color: BlueskyColors.actionColor, 243 + fontSize: 13, 162 244 ), 163 245 ), 164 246 ], 165 - ), 247 + ], 248 + ); 249 + } 250 + 251 + /// Builds the header row with avatar, name, handle, and Bluesky logo 252 + Widget _buildHeader(BuildContext context, AuthorView author) { 253 + return Row( 254 + crossAxisAlignment: CrossAxisAlignment.start, 255 + children: [ 256 + // Avatar and author info (tappable to profile) 257 + Expanded( 258 + child: GestureDetector( 259 + onTap: () { 260 + UrlLauncher.launchExternalUrl(_getProfileUrl(), context: context); 261 + }, 262 + child: Row( 263 + children: [ 264 + _buildAvatar(author), 265 + const SizedBox(width: 10), 266 + Expanded( 267 + child: Column( 268 + crossAxisAlignment: CrossAxisAlignment.start, 269 + children: [ 270 + Text( 271 + author.displayName?.trim() ?? author.handle, 272 + style: const TextStyle( 273 + color: BlueskyColors.textPrimary, 274 + fontSize: 15, 275 + fontWeight: FontWeight.bold, 276 + height: 1.2, 277 + ), 278 + maxLines: 1, 279 + overflow: TextOverflow.ellipsis, 280 + ), 281 + Text( 282 + '@${author.handle}', 283 + style: const TextStyle( 284 + color: BlueskyColors.textSecondary, 285 + fontSize: 14, 286 + height: 1.3, 287 + ), 288 + maxLines: 1, 289 + overflow: TextOverflow.ellipsis, 290 + ), 291 + ], 292 + ), 293 + ), 294 + ], 295 + ), 296 + ), 297 + ), 298 + // Bluesky logo 299 + const SizedBox(width: 8), 300 + BlueskyIcons.logo(size: 24, color: BlueskyColors.blueskyBlue), 301 + ], 166 302 ); 167 303 } 168 304 ··· 171 307 final avatarUrl = author.avatar; 172 308 if (avatarUrl != null && avatarUrl.isNotEmpty) { 173 309 return ClipRRect( 174 - borderRadius: BorderRadius.circular(21), 310 + borderRadius: BorderRadius.circular(20), 175 311 child: CachedNetworkImage( 176 312 imageUrl: avatarUrl, 177 - width: 42, 178 - height: 42, 313 + width: 40, 314 + height: 40, 179 315 fit: BoxFit.cover, 180 316 placeholder: (context, url) => _buildFallbackAvatar(author), 181 317 errorWidget: (context, url, error) { 182 318 if (kDebugMode) { 183 - debugPrint('Bluesky avatar load error: $error'); 319 + debugPrint('Failed to load avatar from $url: $error'); 184 320 } 185 321 return _buildFallbackAvatar(author); 186 322 }, ··· 197 333 final firstLetter = text.isNotEmpty ? text[0].toUpperCase() : '?'; 198 334 199 335 return Container( 200 - width: 42, 201 - height: 42, 336 + width: 40, 337 + height: 40, 202 338 decoration: BoxDecoration( 203 - color: BlueskyColors.blueskyBlue, 204 - borderRadius: BorderRadius.circular(21), 339 + color: BlueskyColors.avatarFallback, 340 + borderRadius: BorderRadius.circular(20), 205 341 ), 206 342 child: Center( 207 343 child: Text( 208 344 firstLetter, 209 345 style: const TextStyle( 210 - color: BlueskyColors.textPrimary, 346 + color: BlueskyColors.textSecondary, 211 347 fontSize: 18, 212 348 fontWeight: FontWeight.bold, 213 349 ), ··· 221 357 final mediaText = 222 358 mediaCount == 1 ? 'Contains 1 image' : 'Contains $mediaCount images'; 223 359 360 + return Container( 361 + padding: const EdgeInsets.all(12), 362 + decoration: BoxDecoration( 363 + color: BlueskyColors.cardBorder.withValues(alpha: 0.3), 364 + borderRadius: BorderRadius.circular(8), 365 + border: Border.all(color: BlueskyColors.cardBorder), 366 + ), 367 + child: Row( 368 + children: [ 369 + const Icon( 370 + Icons.image_outlined, 371 + size: 18, 372 + color: BlueskyColors.textSecondary, 373 + ), 374 + const SizedBox(width: 8), 375 + Expanded( 376 + child: Text( 377 + mediaText, 378 + style: const TextStyle( 379 + color: BlueskyColors.textSecondary, 380 + fontSize: 14, 381 + ), 382 + ), 383 + ), 384 + const Icon( 385 + Icons.open_in_new, 386 + size: 14, 387 + color: BlueskyColors.textSecondary, 388 + ), 389 + ], 390 + ), 391 + ); 392 + } 393 + 394 + /// Builds the external link embed card (link preview) 395 + Widget _buildExternalEmbed(BuildContext context, BlueskyExternalEmbed embed) { 224 396 return GestureDetector( 225 397 onTap: () { 226 - UrlLauncher.launchExternalUrl(_getPostUrl(), context: context); 398 + UrlLauncher.launchExternalUrl(embed.uri, context: context); 227 399 }, 228 400 child: Container( 229 - padding: const EdgeInsets.all(10), 401 + clipBehavior: Clip.antiAlias, 230 402 decoration: BoxDecoration( 231 - color: BlueskyColors.background, 232 - borderRadius: BorderRadius.circular(6), 403 + borderRadius: BorderRadius.circular(12), 233 404 border: Border.all(color: BlueskyColors.cardBorder), 234 405 ), 235 - child: Row( 406 + child: Column( 407 + crossAxisAlignment: CrossAxisAlignment.start, 236 408 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, 409 + // Thumbnail (if available) 410 + if (embed.thumb != null && embed.thumb!.isNotEmpty) 411 + AspectRatio( 412 + aspectRatio: 1200 / 630, // Standard OG image ratio 413 + child: CachedNetworkImage( 414 + imageUrl: embed.thumb!, 415 + fit: BoxFit.cover, 416 + placeholder: (context, url) => Container( 417 + color: BlueskyColors.cardBorder.withValues(alpha: 0.3), 418 + child: const Center( 419 + child: Icon( 420 + Icons.image_outlined, 421 + color: BlueskyColors.textSecondary, 422 + size: 32, 423 + ), 424 + ), 425 + ), 426 + errorWidget: (context, url, error) => Container( 427 + color: BlueskyColors.cardBorder.withValues(alpha: 0.3), 428 + child: const Center( 429 + child: Icon( 430 + Icons.broken_image_outlined, 431 + color: BlueskyColors.textSecondary, 432 + size: 32, 433 + ), 434 + ), 435 + ), 249 436 ), 250 437 ), 251 - ), 252 - const Icon( 253 - Icons.open_in_new, 254 - size: 14, 255 - color: BlueskyColors.textSecondary, 438 + // Content 439 + Padding( 440 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 441 + child: Column( 442 + crossAxisAlignment: CrossAxisAlignment.start, 443 + children: [ 444 + // Domain 445 + Text( 446 + embed.domain, 447 + style: const TextStyle( 448 + color: BlueskyColors.textSecondary, 449 + fontSize: 13, 450 + ), 451 + maxLines: 1, 452 + overflow: TextOverflow.ellipsis, 453 + ), 454 + // Title 455 + if (embed.title != null && embed.title!.isNotEmpty) ...[ 456 + const SizedBox(height: 2), 457 + Text( 458 + embed.title!, 459 + style: const TextStyle( 460 + color: BlueskyColors.textPrimary, 461 + fontSize: 15, 462 + fontWeight: FontWeight.w600, 463 + ), 464 + maxLines: 3, 465 + overflow: TextOverflow.ellipsis, 466 + ), 467 + ], 468 + // Description 469 + if (embed.description != null && 470 + embed.description!.isNotEmpty) ...[ 471 + const SizedBox(height: 2), 472 + Text( 473 + embed.description!, 474 + style: const TextStyle( 475 + color: BlueskyColors.textSecondary, 476 + fontSize: 14, 477 + ), 478 + maxLines: 2, 479 + overflow: TextOverflow.ellipsis, 480 + ), 481 + ], 482 + ], 483 + ), 256 484 ), 257 485 ], 258 486 ), ··· 262 490 263 491 /// Builds the quoted post card (nested) 264 492 Widget _buildQuotedPost(BuildContext context, BlueskyPostResult quotedPost) { 493 + // Handle unavailable quoted posts (blocked, deleted, detached) 494 + if (quotedPost.unavailable) { 495 + return _buildUnavailableQuotedPost(quotedPost); 496 + } 497 + 498 + final timeAgo = DateTimeUtils.formatTimeAgo( 499 + quotedPost.createdAt, 500 + currentTime: currentTime, 501 + ); 502 + 265 503 return GestureDetector( 266 504 onTap: () { 267 505 // Open the quoted post on Bluesky ··· 271 509 UrlLauncher.launchExternalUrl(url, context: context); 272 510 }, 273 511 child: Container( 274 - padding: const EdgeInsets.all(10), 512 + padding: const EdgeInsets.all(12), 275 513 decoration: BoxDecoration( 276 - color: BlueskyColors.background, 277 - borderRadius: BorderRadius.circular(6), 514 + borderRadius: BorderRadius.circular(8), 278 515 border: Border.all(color: BlueskyColors.cardBorder), 279 516 ), 280 517 child: Column( ··· 283 520 Row( 284 521 children: [ 285 522 _buildSmallAvatar(quotedPost.author), 286 - const SizedBox(width: 6), 287 - Expanded( 523 + const SizedBox(width: 8), 524 + Flexible( 288 525 child: Text( 289 - quotedPost.author.displayName ?? quotedPost.author.handle, 526 + quotedPost.author.displayName?.trim() ?? 527 + quotedPost.author.handle, 290 528 style: const TextStyle( 291 529 color: BlueskyColors.textPrimary, 292 - fontSize: 12, 530 + fontSize: 14, 293 531 fontWeight: FontWeight.bold, 294 532 ), 533 + maxLines: 1, 295 534 overflow: TextOverflow.ellipsis, 296 535 ), 297 536 ), 537 + const SizedBox(width: 4), 538 + Flexible( 539 + child: Text( 540 + '@${quotedPost.author.handle}', 541 + style: const TextStyle( 542 + color: BlueskyColors.textSecondary, 543 + fontSize: 14, 544 + ), 545 + maxLines: 1, 546 + overflow: TextOverflow.ellipsis, 547 + ), 548 + ), 549 + const SizedBox(width: 4), 550 + Text( 551 + '路 $timeAgo', 552 + style: const TextStyle( 553 + color: BlueskyColors.textSecondary, 554 + fontSize: 14, 555 + ), 556 + ), 298 557 ], 299 558 ), 300 559 const SizedBox(height: 6), 301 560 302 - // Quoted post text 303 - if (quotedPost.text.isNotEmpty) 561 + // Quoted post text (no truncation - Bluesky posts are max 300 chars) 562 + if (quotedPost.text.isNotEmpty) ...[ 304 563 Text( 305 564 quotedPost.text, 306 565 style: const TextStyle( 307 566 color: BlueskyColors.textPrimary, 308 - fontSize: 13, 567 + fontSize: 15, 309 568 height: 1.4, 310 569 ), 311 - maxLines: 3, 312 - overflow: TextOverflow.ellipsis, 313 570 ), 571 + if (quotedPost.hasMedia || quotedPost.embed != null) 572 + const SizedBox(height: 8), 573 + ], 574 + 575 + // Media placeholder in quoted post 576 + if (quotedPost.hasMedia) ...[ 577 + _buildMediaPlaceholder(context, quotedPost.mediaCount), 578 + if (quotedPost.embed != null) const SizedBox(height: 8), 579 + ], 580 + 581 + // External link embed in quoted post 582 + if (quotedPost.embed != null) 583 + _buildExternalEmbed(context, quotedPost.embed!), 314 584 ], 315 585 ), 316 586 ), 317 587 ); 318 588 } 319 589 590 + /// Builds an unavailable quoted post card (blocked, deleted, detached) 591 + Widget _buildUnavailableQuotedPost(BlueskyPostResult quotedPost) { 592 + final message = 593 + quotedPost.message ?? 'Post not found, it may have been deleted.'; 594 + 595 + return Container( 596 + padding: const EdgeInsets.all(12), 597 + decoration: BoxDecoration( 598 + borderRadius: BorderRadius.circular(8), 599 + border: Border.all(color: BlueskyColors.cardBorder), 600 + ), 601 + child: Row( 602 + children: [ 603 + const Icon( 604 + Icons.block, 605 + size: 16, 606 + color: BlueskyColors.textSecondary, 607 + ), 608 + const SizedBox(width: 8), 609 + Expanded( 610 + child: Text( 611 + message, 612 + style: const TextStyle( 613 + color: BlueskyColors.textSecondary, 614 + fontSize: 14, 615 + ), 616 + ), 617 + ), 618 + ], 619 + ), 620 + ); 621 + } 622 + 320 623 /// Builds a small avatar widget with fallback for quoted posts 321 624 Widget _buildSmallAvatar(AuthorView author) { 322 625 final avatarUrl = author.avatar; ··· 331 634 placeholder: (context, url) => _buildSmallFallbackAvatar(author), 332 635 errorWidget: (context, url, error) { 333 636 if (kDebugMode) { 334 - debugPrint('Bluesky quoted post avatar load error: $error'); 637 + debugPrint('Failed to load avatar from $url: $error'); 335 638 } 336 639 return _buildSmallFallbackAvatar(author); 337 640 }, ··· 351 654 width: 20, 352 655 height: 20, 353 656 decoration: BoxDecoration( 354 - color: BlueskyColors.blueskyBlue, 657 + color: BlueskyColors.avatarFallback, 355 658 borderRadius: BorderRadius.circular(10), 356 659 ), 357 660 child: Center( 358 661 child: Text( 359 662 firstLetter, 360 663 style: const TextStyle( 361 - color: BlueskyColors.textPrimary, 664 + color: BlueskyColors.textSecondary, 362 665 fontSize: 10, 363 666 fontWeight: FontWeight.bold, 364 667 ), ··· 367 670 ); 368 671 } 369 672 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 673 /// Builds the unavailable post card 402 674 Widget _buildUnavailableCard() { 675 + // Use specific message from resolved embed if available, otherwise generic 676 + final message = 677 + embed.resolved?.message ?? 'Post not found, it may have been deleted.'; 678 + 403 679 return Container( 404 680 decoration: BoxDecoration( 681 + color: BlueskyColors.cardBackground, 682 + borderRadius: BorderRadius.circular(BlueskyColors.innerBorderRadius), 405 683 border: Border.all(color: BlueskyColors.cardBorder), 406 - borderRadius: BorderRadius.circular(8), 407 684 ), 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, 685 + padding: const EdgeInsets.all(16), 686 + child: Column( 687 + mainAxisSize: MainAxisSize.min, 688 + children: [ 689 + // Bluesky logo row (matching regular post header alignment) 690 + Row( 691 + mainAxisAlignment: MainAxisAlignment.end, 692 + children: [ 693 + BlueskyIcons.logo(size: 24, color: BlueskyColors.blueskyBlue), 694 + ], 695 + ), 696 + // Centered message 697 + Padding( 698 + padding: const EdgeInsets.symmetric(vertical: 24), 699 + child: Text( 700 + message, 701 + style: const TextStyle( 702 + color: BlueskyColors.textSecondary, 703 + fontSize: 15, 704 + ), 705 + textAlign: TextAlign.center, 417 706 ), 418 707 ), 419 - ), 708 + ], 420 709 ), 421 710 ); 422 711 }
+4 -2
lib/widgets/post_card.dart
··· 156 156 // Spacing after title 157 157 if (post.post.title != null && 158 158 (post.post.embed?.external != null || 159 + post.post.embed?.blueskyPost != null || 159 160 post.post.text.isNotEmpty)) 160 - const SizedBox(height: 8), 161 + const SizedBox(height: 12), 161 162 ], 162 163 ), 163 164 ) ··· 174 175 ), 175 176 ), 176 177 if (post.post.embed?.external != null || 178 + post.post.embed?.blueskyPost != null || 177 179 post.post.text.isNotEmpty) 178 - const SizedBox(height: 8), 180 + const SizedBox(height: 12), 179 181 ], 180 182 181 183 // Embed (handles its own taps - not wrapped in InkWell)
+999
test/models/bluesky_post_test.dart
··· 1 + import 'package:coves_flutter/models/bluesky_post.dart'; 2 + import 'package:coves_flutter/models/post.dart'; 3 + import 'package:flutter_test/flutter_test.dart'; 4 + 5 + void main() { 6 + group('BlueskyPostResult.fromJson', () { 7 + // Helper to create valid JSON with all required fields 8 + Map<String, dynamic> validPostJson({ 9 + String uri = 'at://did:plc:abc123/app.bsky.feed.post/xyz789', 10 + String cid = 'bafyreiabc123', 11 + String createdAt = '2025-01-15T12:30:00.000Z', 12 + Map<String, dynamic>? author, 13 + String text = 'Hello world!', 14 + int replyCount = 5, 15 + int repostCount = 10, 16 + int likeCount = 25, 17 + bool hasMedia = false, 18 + int mediaCount = 0, 19 + bool unavailable = false, 20 + String? message, 21 + Map<String, dynamic>? quotedPost, 22 + }) { 23 + return { 24 + 'uri': uri, 25 + 'cid': cid, 26 + 'createdAt': createdAt, 27 + 'author': author ?? 28 + { 29 + 'did': 'did:plc:testuser123', 30 + 'handle': 'testuser.bsky.social', 31 + 'displayName': 'Test User', 32 + 'avatar': 'https://example.com/avatar.jpg', 33 + }, 34 + 'text': text, 35 + 'replyCount': replyCount, 36 + 'repostCount': repostCount, 37 + 'likeCount': likeCount, 38 + 'hasMedia': hasMedia, 39 + 'mediaCount': mediaCount, 40 + 'unavailable': unavailable, 41 + if (message != null) 'message': message, 42 + if (quotedPost != null) 'quotedPost': quotedPost, 43 + }; 44 + } 45 + 46 + group('valid JSON parsing', () { 47 + test('parses all required fields correctly', () { 48 + final json = validPostJson(); 49 + final result = BlueskyPostResult.fromJson(json); 50 + 51 + expect(result.uri, 'at://did:plc:abc123/app.bsky.feed.post/xyz789'); 52 + expect(result.cid, 'bafyreiabc123'); 53 + expect(result.createdAt, DateTime.utc(2025, 1, 15, 12, 30, 0, 0)); 54 + expect(result.author.did, 'did:plc:testuser123'); 55 + expect(result.author.handle, 'testuser.bsky.social'); 56 + expect(result.author.displayName, 'Test User'); 57 + expect(result.text, 'Hello world!'); 58 + expect(result.replyCount, 5); 59 + expect(result.repostCount, 10); 60 + expect(result.likeCount, 25); 61 + expect(result.hasMedia, false); 62 + expect(result.mediaCount, 0); 63 + expect(result.unavailable, false); 64 + expect(result.quotedPost, isNull); 65 + expect(result.message, isNull); 66 + }); 67 + 68 + test('parses post with media', () { 69 + final json = validPostJson(hasMedia: true, mediaCount: 3); 70 + final result = BlueskyPostResult.fromJson(json); 71 + 72 + expect(result.hasMedia, true); 73 + expect(result.mediaCount, 3); 74 + }); 75 + 76 + test('parses unavailable post with message', () { 77 + final json = validPostJson( 78 + unavailable: true, 79 + message: 'Post was deleted by author', 80 + ); 81 + final result = BlueskyPostResult.fromJson(json); 82 + 83 + expect(result.unavailable, true); 84 + expect(result.message, 'Post was deleted by author'); 85 + }); 86 + 87 + test('parses author with minimal fields', () { 88 + final json = validPostJson( 89 + author: { 90 + 'did': 'did:plc:minimal', 91 + 'handle': 'minimal.bsky.social', 92 + // displayName and avatar are optional 93 + }, 94 + ); 95 + final result = BlueskyPostResult.fromJson(json); 96 + 97 + expect(result.author.did, 'did:plc:minimal'); 98 + expect(result.author.handle, 'minimal.bsky.social'); 99 + expect(result.author.displayName, isNull); 100 + expect(result.author.avatar, isNull); 101 + }); 102 + }); 103 + 104 + group('optional quotedPost parsing', () { 105 + test('parses nested quotedPost correctly', () { 106 + final quotedPostJson = validPostJson( 107 + uri: 'at://did:plc:quoted/app.bsky.feed.post/quoted123', 108 + text: 'This is the quoted post', 109 + author: { 110 + 'did': 'did:plc:quotedauthor', 111 + 'handle': 'quotedauthor.bsky.social', 112 + 'displayName': 'Quoted Author', 113 + }, 114 + ); 115 + final json = validPostJson(quotedPost: quotedPostJson); 116 + final result = BlueskyPostResult.fromJson(json); 117 + 118 + expect(result.quotedPost, isNotNull); 119 + expect( 120 + result.quotedPost!.uri, 121 + 'at://did:plc:quoted/app.bsky.feed.post/quoted123', 122 + ); 123 + expect(result.quotedPost!.text, 'This is the quoted post'); 124 + expect(result.quotedPost!.author.handle, 'quotedauthor.bsky.social'); 125 + }); 126 + 127 + test('handles null quotedPost', () { 128 + final json = validPostJson(); 129 + final result = BlueskyPostResult.fromJson(json); 130 + 131 + expect(result.quotedPost, isNull); 132 + }); 133 + }); 134 + 135 + group('missing required fields', () { 136 + test('throws FormatException when uri is missing', () { 137 + final json = validPostJson(); 138 + json.remove('uri'); 139 + 140 + expect( 141 + () => BlueskyPostResult.fromJson(json), 142 + throwsA( 143 + isA<FormatException>().having( 144 + (e) => e.message, 145 + 'message', 146 + contains('uri'), 147 + ), 148 + ), 149 + ); 150 + }); 151 + 152 + test('throws FormatException when cid is missing', () { 153 + final json = validPostJson(); 154 + json.remove('cid'); 155 + 156 + expect( 157 + () => BlueskyPostResult.fromJson(json), 158 + throwsA( 159 + isA<FormatException>().having( 160 + (e) => e.message, 161 + 'message', 162 + contains('cid'), 163 + ), 164 + ), 165 + ); 166 + }); 167 + 168 + test('throws FormatException when createdAt is missing', () { 169 + final json = validPostJson(); 170 + json.remove('createdAt'); 171 + 172 + expect( 173 + () => BlueskyPostResult.fromJson(json), 174 + throwsA( 175 + isA<FormatException>().having( 176 + (e) => e.message, 177 + 'message', 178 + contains('createdAt'), 179 + ), 180 + ), 181 + ); 182 + }); 183 + 184 + test('throws FormatException when author is missing', () { 185 + final json = validPostJson(); 186 + json.remove('author'); 187 + 188 + expect( 189 + () => BlueskyPostResult.fromJson(json), 190 + throwsA( 191 + isA<FormatException>().having( 192 + (e) => e.message, 193 + 'message', 194 + contains('author'), 195 + ), 196 + ), 197 + ); 198 + }); 199 + 200 + test('throws FormatException when text is missing', () { 201 + final json = validPostJson(); 202 + json.remove('text'); 203 + 204 + expect( 205 + () => BlueskyPostResult.fromJson(json), 206 + throwsA( 207 + isA<FormatException>().having( 208 + (e) => e.message, 209 + 'message', 210 + contains('text'), 211 + ), 212 + ), 213 + ); 214 + }); 215 + 216 + test('throws FormatException when replyCount is missing', () { 217 + final json = validPostJson(); 218 + json.remove('replyCount'); 219 + 220 + expect( 221 + () => BlueskyPostResult.fromJson(json), 222 + throwsA( 223 + isA<FormatException>().having( 224 + (e) => e.message, 225 + 'message', 226 + contains('replyCount'), 227 + ), 228 + ), 229 + ); 230 + }); 231 + 232 + test('throws FormatException when repostCount is missing', () { 233 + final json = validPostJson(); 234 + json.remove('repostCount'); 235 + 236 + expect( 237 + () => BlueskyPostResult.fromJson(json), 238 + throwsA( 239 + isA<FormatException>().having( 240 + (e) => e.message, 241 + 'message', 242 + contains('repostCount'), 243 + ), 244 + ), 245 + ); 246 + }); 247 + 248 + test('throws FormatException when likeCount is missing', () { 249 + final json = validPostJson(); 250 + json.remove('likeCount'); 251 + 252 + expect( 253 + () => BlueskyPostResult.fromJson(json), 254 + throwsA( 255 + isA<FormatException>().having( 256 + (e) => e.message, 257 + 'message', 258 + contains('likeCount'), 259 + ), 260 + ), 261 + ); 262 + }); 263 + 264 + test('throws FormatException when hasMedia is missing', () { 265 + final json = validPostJson(); 266 + json.remove('hasMedia'); 267 + 268 + expect( 269 + () => BlueskyPostResult.fromJson(json), 270 + throwsA( 271 + isA<FormatException>().having( 272 + (e) => e.message, 273 + 'message', 274 + contains('hasMedia'), 275 + ), 276 + ), 277 + ); 278 + }); 279 + 280 + test('throws FormatException when mediaCount is missing', () { 281 + final json = validPostJson(); 282 + json.remove('mediaCount'); 283 + 284 + expect( 285 + () => BlueskyPostResult.fromJson(json), 286 + throwsA( 287 + isA<FormatException>().having( 288 + (e) => e.message, 289 + 'message', 290 + contains('mediaCount'), 291 + ), 292 + ), 293 + ); 294 + }); 295 + 296 + test('throws FormatException when unavailable is missing', () { 297 + final json = validPostJson(); 298 + json.remove('unavailable'); 299 + 300 + expect( 301 + () => BlueskyPostResult.fromJson(json), 302 + throwsA( 303 + isA<FormatException>().having( 304 + (e) => e.message, 305 + 'message', 306 + contains('unavailable'), 307 + ), 308 + ), 309 + ); 310 + }); 311 + }); 312 + 313 + group('invalid field types', () { 314 + test('throws FormatException when uri is not a string', () { 315 + final json = validPostJson(); 316 + json['uri'] = 123; 317 + 318 + expect( 319 + () => BlueskyPostResult.fromJson(json), 320 + throwsA( 321 + isA<FormatException>().having( 322 + (e) => e.message, 323 + 'message', 324 + contains('uri'), 325 + ), 326 + ), 327 + ); 328 + }); 329 + 330 + test('throws FormatException when cid is not a string', () { 331 + final json = validPostJson(); 332 + json['cid'] = true; 333 + 334 + expect( 335 + () => BlueskyPostResult.fromJson(json), 336 + throwsA( 337 + isA<FormatException>().having( 338 + (e) => e.message, 339 + 'message', 340 + contains('cid'), 341 + ), 342 + ), 343 + ); 344 + }); 345 + 346 + test('throws FormatException when createdAt is not a string', () { 347 + final json = validPostJson(); 348 + json['createdAt'] = 1234567890; 349 + 350 + expect( 351 + () => BlueskyPostResult.fromJson(json), 352 + throwsA( 353 + isA<FormatException>().having( 354 + (e) => e.message, 355 + 'message', 356 + contains('createdAt'), 357 + ), 358 + ), 359 + ); 360 + }); 361 + 362 + test('throws FormatException when author is not a map', () { 363 + final json = validPostJson(); 364 + json['author'] = 'not a map'; 365 + 366 + expect( 367 + () => BlueskyPostResult.fromJson(json), 368 + throwsA( 369 + isA<FormatException>().having( 370 + (e) => e.message, 371 + 'message', 372 + contains('author'), 373 + ), 374 + ), 375 + ); 376 + }); 377 + 378 + test('throws FormatException when text is not a string', () { 379 + final json = validPostJson(); 380 + json['text'] = ['not', 'a', 'string']; 381 + 382 + expect( 383 + () => BlueskyPostResult.fromJson(json), 384 + throwsA( 385 + isA<FormatException>().having( 386 + (e) => e.message, 387 + 'message', 388 + contains('text'), 389 + ), 390 + ), 391 + ); 392 + }); 393 + 394 + test('throws FormatException when replyCount is not an int', () { 395 + final json = validPostJson(); 396 + json['replyCount'] = '5'; 397 + 398 + expect( 399 + () => BlueskyPostResult.fromJson(json), 400 + throwsA( 401 + isA<FormatException>().having( 402 + (e) => e.message, 403 + 'message', 404 + contains('replyCount'), 405 + ), 406 + ), 407 + ); 408 + }); 409 + 410 + test('throws FormatException when repostCount is not an int', () { 411 + final json = validPostJson(); 412 + json['repostCount'] = 10.5; 413 + 414 + expect( 415 + () => BlueskyPostResult.fromJson(json), 416 + throwsA( 417 + isA<FormatException>().having( 418 + (e) => e.message, 419 + 'message', 420 + contains('repostCount'), 421 + ), 422 + ), 423 + ); 424 + }); 425 + 426 + test('throws FormatException when likeCount is not an int', () { 427 + final json = validPostJson(); 428 + json['likeCount'] = null; 429 + 430 + expect( 431 + () => BlueskyPostResult.fromJson(json), 432 + throwsA( 433 + isA<FormatException>().having( 434 + (e) => e.message, 435 + 'message', 436 + contains('likeCount'), 437 + ), 438 + ), 439 + ); 440 + }); 441 + 442 + test('throws FormatException when hasMedia is not a bool', () { 443 + final json = validPostJson(); 444 + json['hasMedia'] = 'true'; 445 + 446 + expect( 447 + () => BlueskyPostResult.fromJson(json), 448 + throwsA( 449 + isA<FormatException>().having( 450 + (e) => e.message, 451 + 'message', 452 + contains('hasMedia'), 453 + ), 454 + ), 455 + ); 456 + }); 457 + 458 + test('throws FormatException when mediaCount is not an int', () { 459 + final json = validPostJson(); 460 + json['mediaCount'] = false; 461 + 462 + expect( 463 + () => BlueskyPostResult.fromJson(json), 464 + throwsA( 465 + isA<FormatException>().having( 466 + (e) => e.message, 467 + 'message', 468 + contains('mediaCount'), 469 + ), 470 + ), 471 + ); 472 + }); 473 + 474 + test('throws FormatException when unavailable is not a bool', () { 475 + final json = validPostJson(); 476 + json['unavailable'] = 0; 477 + 478 + expect( 479 + () => BlueskyPostResult.fromJson(json), 480 + throwsA( 481 + isA<FormatException>().having( 482 + (e) => e.message, 483 + 'message', 484 + contains('unavailable'), 485 + ), 486 + ), 487 + ); 488 + }); 489 + }); 490 + 491 + group('invalid date format for createdAt', () { 492 + test('throws FormatException for invalid date string', () { 493 + final json = validPostJson(createdAt: 'not-a-date'); 494 + 495 + expect( 496 + () => BlueskyPostResult.fromJson(json), 497 + throwsA( 498 + isA<FormatException>().having( 499 + (e) => e.message, 500 + 'message', 501 + contains('Invalid date format'), 502 + ), 503 + ), 504 + ); 505 + }); 506 + 507 + test('throws FormatException for malformed ISO date', () { 508 + // Use a format that DateTime.parse definitely rejects 509 + final json = validPostJson(createdAt: '2025/01/15 12:00:00'); 510 + 511 + expect( 512 + () => BlueskyPostResult.fromJson(json), 513 + throwsA( 514 + isA<FormatException>().having( 515 + (e) => e.message, 516 + 'message', 517 + contains('Invalid date format'), 518 + ), 519 + ), 520 + ); 521 + }); 522 + 523 + test('throws FormatException for empty date string', () { 524 + final json = validPostJson(createdAt: ''); 525 + 526 + expect( 527 + () => BlueskyPostResult.fromJson(json), 528 + throwsA( 529 + isA<FormatException>().having( 530 + (e) => e.message, 531 + 'message', 532 + contains('Invalid date format'), 533 + ), 534 + ), 535 + ); 536 + }); 537 + 538 + test('parses valid ISO 8601 date formats', () { 539 + // Standard ISO 8601 with timezone 540 + final json1 = validPostJson(createdAt: '2025-06-15T08:30:00.000Z'); 541 + final result1 = BlueskyPostResult.fromJson(json1); 542 + expect(result1.createdAt, DateTime.utc(2025, 6, 15, 8, 30)); 543 + 544 + // Without milliseconds 545 + final json2 = validPostJson(createdAt: '2025-06-15T08:30:00Z'); 546 + final result2 = BlueskyPostResult.fromJson(json2); 547 + expect(result2.createdAt, DateTime.utc(2025, 6, 15, 8, 30)); 548 + }); 549 + }); 550 + }); 551 + 552 + group('BlueskyPostEmbed.fromJson', () { 553 + test('parses valid embed JSON', () { 554 + final json = { 555 + 'post': { 556 + 'uri': 'at://did:plc:xyz/app.bsky.feed.post/abc', 557 + 'cid': 'bafyrei123', 558 + }, 559 + }; 560 + 561 + final embed = BlueskyPostEmbed.fromJson(json); 562 + 563 + expect(embed.uri, 'at://did:plc:xyz/app.bsky.feed.post/abc'); 564 + expect(embed.cid, 'bafyrei123'); 565 + expect(embed.resolved, isNull); 566 + }); 567 + 568 + test('parses embed with resolved post', () { 569 + final json = { 570 + 'post': { 571 + 'uri': 'at://did:plc:xyz/app.bsky.feed.post/abc', 572 + 'cid': 'bafyrei123', 573 + }, 574 + 'resolved': { 575 + 'uri': 'at://did:plc:xyz/app.bsky.feed.post/abc', 576 + 'cid': 'bafyrei123', 577 + 'createdAt': '2025-01-15T12:00:00Z', 578 + 'author': { 579 + 'did': 'did:plc:xyz', 580 + 'handle': 'test.bsky.social', 581 + }, 582 + 'text': 'Resolved post text', 583 + 'replyCount': 0, 584 + 'repostCount': 0, 585 + 'likeCount': 0, 586 + 'hasMedia': false, 587 + 'mediaCount': 0, 588 + 'unavailable': false, 589 + }, 590 + }; 591 + 592 + final embed = BlueskyPostEmbed.fromJson(json); 593 + 594 + expect(embed.resolved, isNotNull); 595 + expect(embed.resolved!.text, 'Resolved post text'); 596 + }); 597 + 598 + test('throws FormatException when post field is missing', () { 599 + final json = <String, dynamic>{}; 600 + 601 + expect( 602 + () => BlueskyPostEmbed.fromJson(json), 603 + throwsA( 604 + isA<FormatException>().having( 605 + (e) => e.message, 606 + 'message', 607 + contains('post field'), 608 + ), 609 + ), 610 + ); 611 + }); 612 + 613 + test('throws FormatException when post field is not a map', () { 614 + final json = {'post': 'not a map'}; 615 + 616 + expect( 617 + () => BlueskyPostEmbed.fromJson(json), 618 + throwsA( 619 + isA<FormatException>().having( 620 + (e) => e.message, 621 + 'message', 622 + contains('post field'), 623 + ), 624 + ), 625 + ); 626 + }); 627 + 628 + test('throws FormatException when uri in post is missing', () { 629 + final json = { 630 + 'post': {'cid': 'bafyrei123'}, 631 + }; 632 + 633 + expect( 634 + () => BlueskyPostEmbed.fromJson(json), 635 + throwsA( 636 + isA<FormatException>().having( 637 + (e) => e.message, 638 + 'message', 639 + contains('uri'), 640 + ), 641 + ), 642 + ); 643 + }); 644 + 645 + test('throws FormatException when cid in post is missing', () { 646 + final json = { 647 + 'post': {'uri': 'at://did:plc:xyz/app.bsky.feed.post/abc'}, 648 + }; 649 + 650 + expect( 651 + () => BlueskyPostEmbed.fromJson(json), 652 + throwsA( 653 + isA<FormatException>().having( 654 + (e) => e.message, 655 + 'message', 656 + contains('cid'), 657 + ), 658 + ), 659 + ); 660 + }); 661 + }); 662 + 663 + group('BlueskyPostEmbed.getPostWebUrl', () { 664 + // Helper to create a minimal BlueskyPostResult for testing 665 + BlueskyPostResult createPost({String handle = 'testuser.bsky.social'}) { 666 + return BlueskyPostResult( 667 + uri: 'at://did:plc:test/app.bsky.feed.post/test123', 668 + cid: 'bafyrei123', 669 + createdAt: DateTime.now(), 670 + author: _createAuthorView(handle: handle), 671 + text: 'Test post', 672 + replyCount: 0, 673 + repostCount: 0, 674 + likeCount: 0, 675 + hasMedia: false, 676 + mediaCount: 0, 677 + unavailable: false, 678 + ); 679 + } 680 + 681 + test('parses valid AT-URI correctly', () { 682 + final post = createPost(handle: 'alice.bsky.social'); 683 + const atUri = 'at://did:plc:abc123xyz/app.bsky.feed.post/rkey456'; 684 + 685 + final url = BlueskyPostEmbed.getPostWebUrl(post, atUri); 686 + 687 + expect(url, 'https://bsky.app/profile/alice.bsky.social/post/rkey456'); 688 + }); 689 + 690 + test('handles AT-URI with complex DID', () { 691 + final post = createPost(handle: 'bob.bsky.social'); 692 + const atUri = 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3k5qmrblv5c2a'; 693 + 694 + final url = BlueskyPostEmbed.getPostWebUrl(post, atUri); 695 + 696 + expect( 697 + url, 698 + 'https://bsky.app/profile/bob.bsky.social/post/3k5qmrblv5c2a', 699 + ); 700 + }); 701 + 702 + test('returns null when AT-URI is missing at:// prefix', () { 703 + final post = createPost(); 704 + const atUri = 'did:plc:abc123/app.bsky.feed.post/rkey456'; 705 + 706 + final url = BlueskyPostEmbed.getPostWebUrl(post, atUri); 707 + 708 + expect(url, isNull); 709 + }); 710 + 711 + test('returns null when AT-URI has wrong prefix', () { 712 + final post = createPost(); 713 + const atUri = 'https://did:plc:abc123/app.bsky.feed.post/rkey456'; 714 + 715 + final url = BlueskyPostEmbed.getPostWebUrl(post, atUri); 716 + 717 + expect(url, isNull); 718 + }); 719 + 720 + test('returns null when AT-URI has no path', () { 721 + final post = createPost(); 722 + const atUri = 'at://did:plc:abc123'; 723 + 724 + final url = BlueskyPostEmbed.getPostWebUrl(post, atUri); 725 + 726 + expect(url, isNull); 727 + }); 728 + 729 + test('returns null when path has less than 2 segments', () { 730 + final post = createPost(); 731 + const atUri = 'at://did:plc:abc123/app.bsky.feed.post'; 732 + 733 + final url = BlueskyPostEmbed.getPostWebUrl(post, atUri); 734 + 735 + expect(url, isNull); 736 + }); 737 + 738 + test('handles path with exactly 2 segments', () { 739 + final post = createPost(handle: 'minimal.bsky.social'); 740 + const atUri = 'at://did:plc:abc123/collection/rkey'; 741 + 742 + final url = BlueskyPostEmbed.getPostWebUrl(post, atUri); 743 + 744 + expect(url, 'https://bsky.app/profile/minimal.bsky.social/post/rkey'); 745 + }); 746 + 747 + test('extracts last segment as rkey even with extra segments', () { 748 + final post = createPost(handle: 'user.bsky.social'); 749 + const atUri = 'at://did:plc:abc123/extra/path/segments/finalrkey'; 750 + 751 + final url = BlueskyPostEmbed.getPostWebUrl(post, atUri); 752 + 753 + expect(url, 'https://bsky.app/profile/user.bsky.social/post/finalrkey'); 754 + }); 755 + 756 + test('handles empty string AT-URI', () { 757 + final post = createPost(); 758 + const atUri = ''; 759 + 760 + final url = BlueskyPostEmbed.getPostWebUrl(post, atUri); 761 + 762 + expect(url, isNull); 763 + }); 764 + 765 + test('handles AT-URI with only at:// prefix', () { 766 + final post = createPost(); 767 + const atUri = 'at://'; 768 + 769 + final url = BlueskyPostEmbed.getPostWebUrl(post, atUri); 770 + 771 + expect(url, isNull); 772 + }); 773 + }); 774 + 775 + group('BlueskyPostEmbed.getProfileUrl', () { 776 + test('builds profile URL from handle', () { 777 + final url = BlueskyPostEmbed.getProfileUrl('alice.bsky.social'); 778 + 779 + expect(url, 'https://bsky.app/profile/alice.bsky.social'); 780 + }); 781 + 782 + test('handles custom domain handle', () { 783 + final url = BlueskyPostEmbed.getProfileUrl('alice.dev'); 784 + 785 + expect(url, 'https://bsky.app/profile/alice.dev'); 786 + }); 787 + 788 + test('handles handle with numbers', () { 789 + final url = BlueskyPostEmbed.getProfileUrl('user123.bsky.social'); 790 + 791 + expect(url, 'https://bsky.app/profile/user123.bsky.social'); 792 + }); 793 + 794 + test('handles empty handle', () { 795 + final url = BlueskyPostEmbed.getProfileUrl(''); 796 + 797 + expect(url, 'https://bsky.app/profile/'); 798 + }); 799 + }); 800 + 801 + group('BlueskyExternalEmbed', () { 802 + group('fromJson', () { 803 + test('parses valid embed with all fields', () { 804 + final json = { 805 + 'uri': 'https://lemonde.fr/article', 806 + 'title': 'Breaking News', 807 + 'description': 'An important article about world events.', 808 + 'thumb': 'https://cdn.lemonde.fr/thumbnail.jpg', 809 + }; 810 + 811 + final embed = BlueskyExternalEmbed.fromJson(json); 812 + 813 + expect(embed.uri, 'https://lemonde.fr/article'); 814 + expect(embed.title, 'Breaking News'); 815 + expect(embed.description, 'An important article about world events.'); 816 + expect(embed.thumb, 'https://cdn.lemonde.fr/thumbnail.jpg'); 817 + }); 818 + 819 + test('parses embed with only required uri field', () { 820 + final json = {'uri': 'https://example.com'}; 821 + 822 + final embed = BlueskyExternalEmbed.fromJson(json); 823 + 824 + expect(embed.uri, 'https://example.com'); 825 + expect(embed.title, isNull); 826 + expect(embed.description, isNull); 827 + expect(embed.thumb, isNull); 828 + }); 829 + 830 + test('throws FormatException when uri is missing', () { 831 + final json = { 832 + 'title': 'Some Title', 833 + 'description': 'Some description', 834 + }; 835 + 836 + expect( 837 + () => BlueskyExternalEmbed.fromJson(json), 838 + throwsA(isA<FormatException>()), 839 + ); 840 + }); 841 + 842 + test('throws FormatException when uri is not a string', () { 843 + final json = {'uri': 123}; 844 + 845 + expect( 846 + () => BlueskyExternalEmbed.fromJson(json), 847 + throwsA(isA<FormatException>()), 848 + ); 849 + }); 850 + 851 + test('throws FormatException when uri is null', () { 852 + final json = {'uri': null}; 853 + 854 + expect( 855 + () => BlueskyExternalEmbed.fromJson(json), 856 + throwsA(isA<FormatException>()), 857 + ); 858 + }); 859 + }); 860 + 861 + group('domain getter', () { 862 + test('extracts domain from full URL', () { 863 + final embed = BlueskyExternalEmbed( 864 + uri: 'https://www.lemonde.fr/article/123', 865 + ); 866 + 867 + expect(embed.domain, 'lemonde.fr'); 868 + }); 869 + 870 + test('removes www prefix', () { 871 + final embed = BlueskyExternalEmbed( 872 + uri: 'https://www.example.com/page', 873 + ); 874 + 875 + expect(embed.domain, 'example.com'); 876 + }); 877 + 878 + test('handles URL without www', () { 879 + final embed = BlueskyExternalEmbed(uri: 'https://bbc.co.uk/news'); 880 + 881 + expect(embed.domain, 'bbc.co.uk'); 882 + }); 883 + 884 + test('handles subdomain', () { 885 + final embed = BlueskyExternalEmbed( 886 + uri: 'https://blog.example.com/post', 887 + ); 888 + 889 + expect(embed.domain, 'blog.example.com'); 890 + }); 891 + 892 + test('returns uri for invalid URL', () { 893 + final embed = BlueskyExternalEmbed(uri: 'not-a-valid-url'); 894 + 895 + expect(embed.domain, 'not-a-valid-url'); 896 + }); 897 + 898 + test('handles empty uri', () { 899 + final embed = BlueskyExternalEmbed(uri: ''); 900 + 901 + expect(embed.domain, ''); 902 + }); 903 + }); 904 + }); 905 + 906 + group('BlueskyPostResult with embed', () { 907 + Map<String, dynamic> validPostJsonWithEmbed({ 908 + Map<String, dynamic>? embed, 909 + }) { 910 + return { 911 + 'uri': 'at://did:plc:abc123/app.bsky.feed.post/xyz789', 912 + 'cid': 'bafyreiabc123', 913 + 'createdAt': '2025-01-15T12:30:00.000Z', 914 + 'author': { 915 + 'did': 'did:plc:testuser123', 916 + 'handle': 'testuser.bsky.social', 917 + 'displayName': 'Test User', 918 + 'avatar': 'https://example.com/avatar.jpg', 919 + }, 920 + 'text': 'Check out this article!', 921 + 'replyCount': 5, 922 + 'repostCount': 10, 923 + 'likeCount': 25, 924 + 'hasMedia': false, 925 + 'mediaCount': 0, 926 + 'unavailable': false, 927 + if (embed != null) 'embed': embed, 928 + }; 929 + } 930 + 931 + test('parses post with external embed', () { 932 + final json = validPostJsonWithEmbed( 933 + embed: { 934 + 'uri': 'https://lemonde.fr/article', 935 + 'title': 'News Article', 936 + 'description': 'Article description', 937 + 'thumb': 'https://cdn.lemonde.fr/thumb.jpg', 938 + }, 939 + ); 940 + 941 + final result = BlueskyPostResult.fromJson(json); 942 + 943 + expect(result.embed, isNotNull); 944 + expect(result.embed!.uri, 'https://lemonde.fr/article'); 945 + expect(result.embed!.title, 'News Article'); 946 + expect(result.embed!.description, 'Article description'); 947 + expect(result.embed!.thumb, 'https://cdn.lemonde.fr/thumb.jpg'); 948 + }); 949 + 950 + test('parses post without embed', () { 951 + final json = validPostJsonWithEmbed(); 952 + 953 + final result = BlueskyPostResult.fromJson(json); 954 + 955 + expect(result.embed, isNull); 956 + }); 957 + 958 + test('handles malformed embed gracefully', () { 959 + final json = validPostJsonWithEmbed( 960 + embed: {'title': 'Missing URI'}, // Missing required 'uri' field 961 + ); 962 + 963 + // Should not throw - malformed embed is silently ignored 964 + final result = BlueskyPostResult.fromJson(json); 965 + 966 + expect(result.embed, isNull); 967 + expect(result.text, 'Check out this article!'); 968 + }); 969 + 970 + test('parses embed with minimal fields', () { 971 + final json = validPostJsonWithEmbed( 972 + embed: {'uri': 'https://example.com'}, 973 + ); 974 + 975 + final result = BlueskyPostResult.fromJson(json); 976 + 977 + expect(result.embed, isNotNull); 978 + expect(result.embed!.uri, 'https://example.com'); 979 + expect(result.embed!.title, isNull); 980 + expect(result.embed!.description, isNull); 981 + expect(result.embed!.thumb, isNull); 982 + }); 983 + }); 984 + } 985 + 986 + // Helper to create AuthorView for tests 987 + AuthorView _createAuthorView({ 988 + String did = 'did:plc:test', 989 + required String handle, 990 + String? displayName, 991 + String? avatar, 992 + }) { 993 + return AuthorView( 994 + did: did, 995 + handle: handle, 996 + displayName: displayName, 997 + avatar: avatar, 998 + ); 999 + }
+161
test/utils/date_time_utils_test.dart
··· 116 116 expect(DateTimeUtils.formatCount(1567), '1.6k'); 117 117 }); 118 118 }); 119 + 120 + group('DateTimeUtils.formatFullDateTime', () { 121 + test('formats midnight (12:00 AM) correctly', () { 122 + final midnight = DateTime(2025, 6, 15, 0, 0); 123 + expect(DateTimeUtils.formatFullDateTime(midnight), '12:00AM 路 Jun 15, 2025'); 124 + }); 125 + 126 + test('formats noon (12:00 PM) correctly', () { 127 + final noon = DateTime(2025, 6, 15, 12, 0); 128 + expect(DateTimeUtils.formatFullDateTime(noon), '12:00PM 路 Jun 15, 2025'); 129 + }); 130 + 131 + test('formats 12:01 AM correctly', () { 132 + final justAfterMidnight = DateTime(2025, 6, 15, 0, 1); 133 + expect( 134 + DateTimeUtils.formatFullDateTime(justAfterMidnight), 135 + '12:01AM 路 Jun 15, 2025', 136 + ); 137 + }); 138 + 139 + test('formats 12:59 PM correctly', () { 140 + final lateNoon = DateTime(2025, 6, 15, 12, 59); 141 + expect(DateTimeUtils.formatFullDateTime(lateNoon), '12:59PM 路 Jun 15, 2025'); 142 + }); 143 + 144 + test('pads single digit minutes correctly', () { 145 + final singleDigitMinute = DateTime(2025, 3, 10, 9, 5); 146 + expect( 147 + DateTimeUtils.formatFullDateTime(singleDigitMinute), 148 + '9:05AM 路 Mar 10, 2025', 149 + ); 150 + }); 151 + 152 + test('formats double digit minutes correctly', () { 153 + final doubleDigitMinute = DateTime(2025, 3, 10, 14, 35); 154 + expect( 155 + DateTimeUtils.formatFullDateTime(doubleDigitMinute), 156 + '2:35PM 路 Mar 10, 2025', 157 + ); 158 + }); 159 + 160 + test('formats AM hours correctly (1-11)', () { 161 + // 1 AM 162 + final oneAm = DateTime(2025, 1, 1, 1, 30); 163 + expect(DateTimeUtils.formatFullDateTime(oneAm), '1:30AM 路 Jan 1, 2025'); 164 + 165 + // 11 AM 166 + final elevenAm = DateTime(2025, 1, 1, 11, 45); 167 + expect(DateTimeUtils.formatFullDateTime(elevenAm), '11:45AM 路 Jan 1, 2025'); 168 + }); 169 + 170 + test('formats PM hours correctly (13-23)', () { 171 + // 1 PM (13:00) 172 + final onePm = DateTime(2025, 1, 1, 13, 0); 173 + expect(DateTimeUtils.formatFullDateTime(onePm), '1:00PM 路 Jan 1, 2025'); 174 + 175 + // 11 PM (23:00) 176 + final elevenPm = DateTime(2025, 1, 1, 23, 30); 177 + expect(DateTimeUtils.formatFullDateTime(elevenPm), '11:30PM 路 Jan 1, 2025'); 178 + }); 179 + 180 + test('formats all months correctly', () { 181 + expect( 182 + DateTimeUtils.formatFullDateTime(DateTime(2025, 1, 15, 10, 0)), 183 + '10:00AM 路 Jan 15, 2025', 184 + ); 185 + expect( 186 + DateTimeUtils.formatFullDateTime(DateTime(2025, 2, 15, 10, 0)), 187 + '10:00AM 路 Feb 15, 2025', 188 + ); 189 + expect( 190 + DateTimeUtils.formatFullDateTime(DateTime(2025, 3, 15, 10, 0)), 191 + '10:00AM 路 Mar 15, 2025', 192 + ); 193 + expect( 194 + DateTimeUtils.formatFullDateTime(DateTime(2025, 4, 15, 10, 0)), 195 + '10:00AM 路 Apr 15, 2025', 196 + ); 197 + expect( 198 + DateTimeUtils.formatFullDateTime(DateTime(2025, 5, 15, 10, 0)), 199 + '10:00AM 路 May 15, 2025', 200 + ); 201 + expect( 202 + DateTimeUtils.formatFullDateTime(DateTime(2025, 6, 15, 10, 0)), 203 + '10:00AM 路 Jun 15, 2025', 204 + ); 205 + expect( 206 + DateTimeUtils.formatFullDateTime(DateTime(2025, 7, 15, 10, 0)), 207 + '10:00AM 路 Jul 15, 2025', 208 + ); 209 + expect( 210 + DateTimeUtils.formatFullDateTime(DateTime(2025, 8, 15, 10, 0)), 211 + '10:00AM 路 Aug 15, 2025', 212 + ); 213 + expect( 214 + DateTimeUtils.formatFullDateTime(DateTime(2025, 9, 15, 10, 0)), 215 + '10:00AM 路 Sep 15, 2025', 216 + ); 217 + expect( 218 + DateTimeUtils.formatFullDateTime(DateTime(2025, 10, 15, 10, 0)), 219 + '10:00AM 路 Oct 15, 2025', 220 + ); 221 + expect( 222 + DateTimeUtils.formatFullDateTime(DateTime(2025, 11, 15, 10, 0)), 223 + '10:00AM 路 Nov 15, 2025', 224 + ); 225 + expect( 226 + DateTimeUtils.formatFullDateTime(DateTime(2025, 12, 15, 10, 0)), 227 + '10:00AM 路 Dec 15, 2025', 228 + ); 229 + }); 230 + 231 + test('formats AM/PM boundary at 11:59 AM transitioning to 12:00 PM', () { 232 + final beforeNoon = DateTime(2025, 6, 15, 11, 59); 233 + expect( 234 + DateTimeUtils.formatFullDateTime(beforeNoon), 235 + '11:59AM 路 Jun 15, 2025', 236 + ); 237 + 238 + final atNoon = DateTime(2025, 6, 15, 12, 0); 239 + expect(DateTimeUtils.formatFullDateTime(atNoon), '12:00PM 路 Jun 15, 2025'); 240 + }); 241 + 242 + test('formats PM/AM boundary at 11:59 PM transitioning to 12:00 AM', () { 243 + final beforeMidnight = DateTime(2025, 6, 15, 23, 59); 244 + expect( 245 + DateTimeUtils.formatFullDateTime(beforeMidnight), 246 + '11:59PM 路 Jun 15, 2025', 247 + ); 248 + 249 + final atMidnight = DateTime(2025, 6, 16, 0, 0); 250 + expect( 251 + DateTimeUtils.formatFullDateTime(atMidnight), 252 + '12:00AM 路 Jun 16, 2025', 253 + ); 254 + }); 255 + 256 + test('handles edge case: minute 00', () { 257 + final zeroMinute = DateTime(2025, 5, 20, 15, 0); 258 + expect(DateTimeUtils.formatFullDateTime(zeroMinute), '3:00PM 路 May 20, 2025'); 259 + }); 260 + 261 + test('handles single digit days', () { 262 + final singleDigitDay = DateTime(2025, 8, 5, 14, 30); 263 + expect( 264 + DateTimeUtils.formatFullDateTime(singleDigitDay), 265 + '2:30PM 路 Aug 5, 2025', 266 + ); 267 + }); 268 + 269 + test('handles different years', () { 270 + final oldDate = DateTime(2020, 3, 1, 9, 15); 271 + expect(DateTimeUtils.formatFullDateTime(oldDate), '9:15AM 路 Mar 1, 2020'); 272 + 273 + final futureDate = DateTime(2030, 12, 31, 23, 59); 274 + expect( 275 + DateTimeUtils.formatFullDateTime(futureDate), 276 + '11:59PM 路 Dec 31, 2030', 277 + ); 278 + }); 279 + }); 119 280 }